diff --git a/Boards/shimmer_boards.c b/Boards/shimmer_boards.c index 2287e6c3..7cc74999 100644 --- a/Boards/shimmer_boards.c +++ b/Boards/shimmer_boards.c @@ -316,6 +316,36 @@ uint8_t ShimBrd_isBoardSrNumber(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8 && daughterCardIdPage.expansion_brd.exp_brd_minor == exp_brd_minor)); } +uint8_t ShimBrd_isBoardSrNumberGte(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor) +{ + if (!ShimBrd_isDaughterCardIdSet() || daughterCardIdPage.expansion_brd.exp_brd_id != exp_brd_id) + { + return 0; + } + uint8_t brd_major = daughterCardIdPage.expansion_brd.exp_brd_major; + uint8_t brd_minor = daughterCardIdPage.expansion_brd.exp_brd_minor; + return (brd_major > exp_brd_major) + || (brd_major == exp_brd_major && brd_minor >= exp_brd_minor); +} + +uint8_t ShimBrd_isBmp581PresentPerSrNumber(void) +{ + /* The BMP581 replaces the BMP390 on up-rev'd Shimmer3R boards. Per DEV-818 + * the fitted models are (>= applies to both rev fields): + * SR31-11-2, SR47-8-2, SR48-7-2, SR48-8-2, SR49-4-2 + * SR48 (GSR unified) has two lines: rev 7 from .2 AND rev 8 from .2. The + * rev-7 case must be "major == 7 && minor >= 2" (expressed as >= 7.2 AND + * < 8.0) - a plain >= 7.2 would wrongly include SR48-8-0 / SR48-8-1, which do + * not carry the BMP581. Rev 8 is covered by the separate >= 8.2 check. */ + return (ShimBrd_isHwId(HW_ID_SHIMMER3R) + && (ShimBrd_isBoardSrNumberGte(SHIMMER3_IMU, 11, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_EXG_UNIFIED, 8, 2) + || (ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 7, 2) + && !ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 0)) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_GSR_UNIFIED, 8, 2) + || ShimBrd_isBoardSrNumberGte(EXP_BRD_BR_AMP_UNIFIED, 4, 2))); +} + uint8_t ShimBrd_isHwId(uint8_t hwIdToCheck) { return hwId == hwIdToCheck; diff --git a/Boards/shimmer_boards.h b/Boards/shimmer_boards.h index 2b928db8..11e6e9ee 100644 --- a/Boards/shimmer_boards.h +++ b/Boards/shimmer_boards.h @@ -102,6 +102,8 @@ uint8_t ShimBrd_isI2cOnPPGControlledByAdcChip(void); uint8_t ShimBrd_areMcuAdcsUsedForSensing(void); uint8_t ShimBrd_isBoardSrNumber(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor); +uint8_t ShimBrd_isBoardSrNumberGte(uint8_t exp_brd_id, uint8_t exp_brd_major, uint8_t exp_brd_minor); +uint8_t ShimBrd_isBmp581PresentPerSrNumber(void); uint8_t ShimBrd_isHwId(uint8_t hwIdToCheck); uint8_t ShimBrd_isExpBrdId(uint8_t expIdToCheck); uint8_t ShimBrd_checkCorrectStateForBoot0(void); diff --git a/Comms/shimmer_bt_uart.c b/Comms/shimmer_bt_uart.c index 295a1fb9..d03c7b23 100644 --- a/Comms/shimmer_bt_uart.c +++ b/Comms/shimmer_bt_uart.c @@ -797,7 +797,6 @@ void ShimBt_processCmd(void) case GET_GYRO_RANGE_COMMAND: case GET_BMP180_CALIBRATION_COEFFICIENTS_COMMAND: case GET_BMP280_CALIBRATION_COEFFICIENTS_COMMAND: - case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: case GET_GYRO_SAMPLING_RATE_COMMAND: case GET_ALT_ACCEL_RANGE_COMMAND: case GET_PRESSURE_OVERSAMPLING_RATIO_COMMAND: @@ -831,6 +830,14 @@ void ShimBt_processCmd(void) getCmdWaitingResponse = gAction; break; } + case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: + { + /* Handled in ShimBt_sendRsp alongside GET_BMP180/BMP280_CALIBRATION_ + * COEFFICIENTS_COMMAND: a BMP581 self-compensates (no coefficients) and + * NACKs there; a BMP390 returns its coefficients. */ + getCmdWaitingResponse = gAction; + break; + } case TEST_CONNECTION_COMMAND: { break; @@ -1877,6 +1884,16 @@ void ShimBt_sendRsp(void) } case GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND: { +#if defined(SHIMMER3R) + /* BMP581 self-compensates - no calibration coefficients, so NACK (like + * the BMP180/BMP280 cases above). The switch-set NACK is turned into a + * clean single-byte NACK by the sendNack fixup after this switch. */ + if (isBmp581InUse()) + { + sendNack = 1; + break; + } +#endif bmpCalibByteLen = get_bmp_calib_data_bytes_len(); *(resPacket + packet_length++) = PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE; *(resPacket + packet_length++) = 1U + bmpCalibByteLen; @@ -1894,6 +1911,7 @@ void ShimBt_sendRsp(void) *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; } #elif defined(SHIMMER3R) + /* Only reached when a BMP390 is fitted (BMP581 NACKs above). */ *(resPacket + packet_length++) = PRESSURE_SENSOR_BMP390; #endif memcpy(resPacket + packet_length, get_bmp_calib_data_bytes(), bmpCalibByteLen); @@ -2181,6 +2199,18 @@ void ShimBt_sendRsp(void) } getCmdWaitingResponse = 0; + /* A response case above may set sendNack (an unsupported command for the + * fitted hardware - e.g. a pressure-calibration request on a self- + * compensating sensor). The ACK/NACK byte is written before the switch, so + * honour a switch-set NACK here: overwrite the staged ACK with a NACK and + * drop any response bytes so a clean single-byte NACK is sent. */ + if (sendNack) + { + resPacket[0] = NACK_COMMAND_PROCESSED; + packet_length = 1; + sendNack = 0; + } + COMMS_CRC_MODE crcMode = ShimBt_getCrcMode(); if (crcMode != CRC_OFF) { diff --git a/Comms/shimmer_bt_uart.h b/Comms/shimmer_bt_uart.h index ded6f4cc..bb9e0c08 100644 --- a/Comms/shimmer_bt_uart.h +++ b/Comms/shimmer_bt_uart.h @@ -243,7 +243,8 @@ enum { PRESSURE_SENSOR_BMP180 = 0, PRESSURE_SENSOR_BMP280 = 1, - PRESSURE_SENSOR_BMP390 = 2 + PRESSURE_SENSOR_BMP390 = 2, + PRESSURE_SENSOR_BMP581 = 3 }; enum diff --git a/Configuration/shimmer_config.c b/Configuration/shimmer_config.c index e6996422..0d847cea 100644 --- a/Configuration/shimmer_config.c +++ b/Configuration/shimmer_config.c @@ -378,7 +378,10 @@ void ShimConfig_configBytePressureOversamplingRatioSet(uint8_t value) #if defined(SHIMMER3) value = (value <= BMPX80_OSS_8) ? (value & 0x03) : BMPX80_OSS_1; #elif defined(SHIMMER3R) - value = (value <= BMP3_OVERSAMPLING_32X) ? value : BMP3_NO_OVERSAMPLING; + /* The BMP581 supports two additional oversampling settings (64x and 128x) + * over the BMP390's maximum of 32x */ + uint8_t maxOversamplingRatio = isBmp581InUse() ? BMP5_OVERSAMPLING_128X : BMP3_OVERSAMPLING_32X; + value = (value <= maxOversamplingRatio) ? value : BMP3_NO_OVERSAMPLING; #endif storedConfig.pressureOversamplingRatioLsb = value & 0x03; #if defined(SHIMMER3R) diff --git a/Extras/python_scripts/Bluetooth commands/bmp390_plot.py b/Extras/python_scripts/Bluetooth commands/bmp390_plot.py new file mode 100644 index 00000000..9c40d578 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp390_plot.py @@ -0,0 +1,522 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# BMP390 live PLOT + CSV logger - the BMP390 counterpart of bmp581_plot.py, for +# running a fridge test in parallel and seeing the real difference. +# +# UNLIKE the BMP581 (which self-compensates on-chip and only needs raw/64), the +# BMP390 streams RAW UNCOMPENSATED pressure/temperature plus 21 NVM calibration +# coefficients that the HOST must apply (Bosch BMP3 floating-point compensation, +# BST-BMP390-DS002 sec 8). This script reads those coefficients over BT +# (GET_PRESSURE_CALIBRATION_COEFFICIENTS, 0xA7 -> 0xA6 response) and applies the +# full polynomial, so the plotted pressure/temperature are properly compensated. +# +# ROBUSTNESS: survives Bluetooth dropouts. If the link stalls or errors it closes +# the port, reconnects, and re-issues the streaming commands automatically (up to +# MAX_RECONNECTS times). Every sample is flushed to CSV immediately, so captured +# data is safe even on a hard crash. Calibration is read once and reused across +# reconnects. +# +# Usage (same as bmp581_plot.py): +# bmp390_plot.py COM bmp390_plot.py bmp390_plot.py --list +# +# Requires pyserial; matplotlib is auto-installed on first run if missing. + +import bisect +import os +import re +import struct +import subprocess +import sys +import time + +import serial +import serial.tools.list_ports + + +def _ensure_matplotlib(): + try: + import matplotlib # noqa: F401 + return True + except Exception: + print("matplotlib not found - attempting a one-time install via pip ...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "matplotlib"]) + import matplotlib # noqa: F401 + return True + except Exception as e: + print(" auto-install failed (%s). Run manually: pip install matplotlib" % e) + return False + + +HAVE_MPL = _ensure_matplotlib() +if HAVE_MPL: + import matplotlib + try: + matplotlib.use("TkAgg") + except Exception: + pass + import matplotlib.pyplot as plt +else: + print("Continuing without plots - data still logged to bmp390_stream.csv") + +# --- User settings --------------------------------------------------------- +DEFAULT_MAC = "" +DESIRED_SAMPLING_FREQ_HZ = 51.2 +PLOT_WINDOW_S = 120.0 # rolling x-axis window (fridge runs are minutes long) + +# --- Robustness settings --------------------------------------------------- +SERIAL_TIMEOUT_S = 1.0 # per-read timeout (keeps the GUI responsive) +STALL_TIMEOUT_S = 12.0 # no bytes for this long -> treat link as stalled +RECONNECT_WAIT_S = 3.0 # pause between reconnect attempts +MAX_RECONNECTS = 20 # give up after this many consecutive failures +ACK_TIMEOUT_S = 4.0 # how long to wait for a command ACK + +# --- Shimmer BT bytes ------------------------------------------------------ +ACK = 0xFF +NACK = 0xFE +GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND = 0xA7 +PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE = 0xA6 +SET_SENSORS_COMMAND = 0x08 +SET_SAMPLING_RATE_COMMAND = 0x05 +START_STREAMING_COMMAND = 0x07 +STOP_STREAMING_COMMAND = 0x20 +SENSORS0 = 0x00 +SENSORS1 = 0x00 +SENSORS2 = 0x04 # pressure & temperature + + +# --- Port / MAC helpers (same as bmp581_plot.py) --------------------------- +def normalize_mac(s): + return re.sub(r'[^0-9A-Fa-f]', '', s).upper() + + +def extract_mac_from_hwid(hwid): + h = re.sub(r'\{[^}]*\}', '', hwid or "").replace(':', '') + BASE = '00805F9B34FB' + cands = [c.upper() for c in re.findall(r'([0-9A-Fa-f]{12})', h) + if set(c.upper()) != {'0'} and c.upper() != BASE] + return cands[-1] if cands else None + + +def serial_ports_bluetooth(): + return [p for p in serial.tools.list_ports.comports() + if "Bluetooth" in (p.description or "")] + + +def list_bt_ports(): + print("Bluetooth serial ports (MAC | port | description):") + found = False + for p in serial.tools.list_ports.comports(): + mac = extract_mac_from_hwid(p.hwid) + if mac: + found = True + print(" %-12s %-6s %s" % (mac, p.device, p.description)) + if not found: + print(" (none found - is the device paired?)") + + +def pick_com_port_interactive(): + options = serial_ports_bluetooth() + if not options: + print("No Bluetooth serial ports found - is the Shimmer paired?") + return None + print("Pick an option:") + for index, item in enumerate(options): + mac = extract_mac_from_hwid(item.hwid) or "?" + print(" %d) %s (MAC %s) %s" % (index + 1, item.device, mac, item.description)) + ui = "" + while (not ui.isdigit()) or int(ui) < 1 or int(ui) > len(options): + ui = input("Your choice: ").strip() + port = options[int(ui) - 1].device + print("You picked: %s\n" % port) + return port + + +def find_port_by_mac(mac): + target = normalize_mac(mac) + outgoing = fallback = None + for p in serial.tools.list_ports.comports(): + hwid = (p.hwid or "").upper().replace(':', '').replace('-', '') + if target and target in hwid: + if '_C00000000' in hwid: + outgoing = p.device + elif fallback is None: + fallback = p.device + return outgoing or fallback + + +def resolve_port(arg): + if not arg: + arg = DEFAULT_MAC + if not arg: + return None + a = arg.strip() + if a.upper().startswith('COM') or a.startswith('/dev/'): + return a + n = normalize_mac(a) + if len(n) == 12: + port = find_port_by_mac(n) + print(("MAC %s -> %s" % (a, port)) if port else ("No paired port for MAC %s." % a)) + return port + return a + + +# --- little-endian integer helpers ----------------------------------------- +def u24_le(b0, b1, b2): + return b0 | (b1 << 8) | (b2 << 16) + + +def u16_le(b): + return b[0] | (b[1] << 8) + + +def s16_le(b): + v = u16_le(b) + return v - 0x10000 if v >= 0x8000 else v + + +def s8(b): + return b - 0x100 if b >= 0x80 else b + + +def wait_for_ack(ser, timeout=ACK_TIMEOUT_S): + """Wait up to `timeout` s for a 0xFF ACK. Returns True/False (never hangs).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + d = ser.read(1) + if d and d[0] == ACK: + return True + return False + + +# --- BMP390 calibration: read 21 NVM coefficients and convert to floats ----- +def read_bmp390_calib(ser): + """Send 0xA7, expect 0xFF 0xA6 <21 bytes>. Returns a dict of par_*.""" + ser.write(struct.pack('B', GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND)) + deadline = time.monotonic() + 3.0 + resp_seen = False + while time.monotonic() < deadline: + b = ser.read(1) + if not b: + continue + if b[0] == NACK: + raise RuntimeError("device NACK'd (0xFE) the calibration command - this is a " + "BMP581 (self-compensating). Use bmp581_plot.py instead.") + if b[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: # 0xA6 + resp_seen = True + break + # else: ACK (0xFF) or stray byte - keep scanning + if not resp_seen: + raise RuntimeError("no 0xA6 calibration response received (timeout).") + length = ser.read(1)[0] # 1 (sensor id) + 21 (calib) = 22 + payload = ser.read(length) + sensor_id = payload[0] + c = payload[1:1 + 21] # 21 raw NVM bytes, register order 0x31..0x45 + if len(c) < 21: + raise RuntimeError("short calibration payload (%d bytes)." % len(c)) + + nvm_t1 = u16_le(c[0:2]) # unsigned + nvm_t2 = u16_le(c[2:4]) # unsigned + nvm_t3 = s8(c[4]) + nvm_p1 = s16_le(c[5:7]) + nvm_p2 = s16_le(c[7:9]) + nvm_p3 = s8(c[9]) + nvm_p4 = s8(c[10]) + nvm_p5 = u16_le(c[11:13]) # unsigned + nvm_p6 = u16_le(c[13:15]) # unsigned + nvm_p7 = s8(c[15]) + nvm_p8 = s8(c[16]) + nvm_p9 = s16_le(c[17:19]) + nvm_p10 = s8(c[19]) + nvm_p11 = s8(c[20]) + + par = { + 't1': nvm_t1 / 2.0 ** -8, 't2': nvm_t2 / 2.0 ** 30, 't3': nvm_t3 / 2.0 ** 48, + 'p1': (nvm_p1 - 2.0 ** 14) / 2.0 ** 20, + 'p2': (nvm_p2 - 2.0 ** 14) / 2.0 ** 29, + 'p3': nvm_p3 / 2.0 ** 32, 'p4': nvm_p4 / 2.0 ** 37, + 'p5': nvm_p5 / 2.0 ** -3, 'p6': nvm_p6 / 2.0 ** 6, + 'p7': nvm_p7 / 2.0 ** 8, 'p8': nvm_p8 / 2.0 ** 15, + 'p9': nvm_p9 / 2.0 ** 48, 'p10': nvm_p10 / 2.0 ** 48, + 'p11': nvm_p11 / 2.0 ** 65, + } + print("calibration read (sensor id 0x%02x): 21 BMP3 coefficients OK." % sensor_id) + return par + + +def compensate_temperature(uncomp_temp, par): + d1 = uncomp_temp - par['t1'] + d2 = d1 * par['t2'] + return d2 + (d1 * d1) * par['t3'] # t_lin, degC + + +def compensate_pressure(uncomp_press, t_lin, par): + d1 = par['p6'] * t_lin + d2 = par['p7'] * (t_lin * t_lin) + d3 = par['p8'] * (t_lin * t_lin * t_lin) + out1 = par['p5'] + d1 + d2 + d3 + d1 = par['p2'] * t_lin + d2 = par['p3'] * (t_lin * t_lin) + d3 = par['p4'] * (t_lin * t_lin * t_lin) + out2 = uncomp_press * (par['p1'] + d1 + d2 + d3) + d1 = uncomp_press * uncomp_press + d2 = par['p9'] + par['p10'] * t_lin + d3 = d1 * d2 + d4 = d3 + (uncomp_press * uncomp_press * uncomp_press) * par['p11'] + return out1 + out2 + d4 # Pa + + +def open_serial(port): + ser = serial.Serial(port, 115200, timeout=SERIAL_TIMEOUT_S) + ser.flushInput() + return ser + + +def start_streaming(ser): + """Handshake (SET_SENSORS / rate / START). Calibration is read separately.""" + ser.write(struct.pack('BBBB', SET_SENSORS_COMMAND, SENSORS0, SENSORS1, SENSORS2)) + if not wait_for_ack(ser): + print("WARNING: no ACK for SET_SENSORS.") + clock_wait = int(32768 / DESIRED_SAMPLING_FREQ_HZ) + ser.write(struct.pack(' bmp390_stream.csv."))) + +csv_path = os.path.abspath("bmp390_stream.csv") +csv_file = open(csv_path, "w") +csv_file.write("elapsed_s,pressure_kPa,temperature_C,raw_press,raw_temp\n") + +t_all, p_all, temp_all = [], [], [] + +live = False +if HAVE_MPL: + try: + plt.ion() + fig, (axp, axt) = plt.subplots(2, 1, figsize=(9, 8), sharex=True) + (ln_p,) = axp.plot([], [], color="tab:green", lw=0.8) + (ln_t,) = axt.plot([], [], color="tab:red", lw=0.8) + axp.set_ylabel("Pressure (kPa)") + axt.set_ylabel("Temperature (degC)") + axt.set_xlabel("elapsed (s)") + axp.set_title("Shimmer BMP390 host-compensated (live) - %s" % port) + axp.grid(True, alpha=0.3) + axt.grid(True, alpha=0.3) + fig.tight_layout() + plt.show(block=False) + live = True + except Exception as e: + print("live plot unavailable (%s) - will still save the PNG on exit." % e) + +ddata = bytes() +framesize = 10 # 1 type + 3 ts + 3 pressure + 3 temperature (pressure first) +t0 = time.monotonic() +i = 0 +last_rx = time.monotonic() +stopped_clean = False +try: + while True: + # --- resilient read ------------------------------------------------ + try: + navail = ser.in_waiting + except Exception: + navail = 0 + try: + chunk = ser.read(navail if navail > framesize else framesize) + except (serial.SerialException, OSError) as e: + print("\nserial error: %s" % e) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + now = time.monotonic() + if chunk: + ddata += chunk + last_rx = now + elif now - last_rx > STALL_TIMEOUT_S: + print("\nno data for %.0fs - link stalled, reconnecting ..." % (now - last_rx)) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + # --- parse whatever complete frames we have ------------------------ + while len(ddata) >= framesize: + frame = ddata[0:framesize] + ddata = ddata[framesize:] + try: + (p0, p1, p2, tt0, tt1, tt2) = struct.unpack('BBBBBB', frame[4:framesize]) + uncomp_press = u24_le(p0, p1, p2) + uncomp_temp = u24_le(tt0, tt1, tt2) + t_lin = compensate_temperature(uncomp_temp, par) + press_pa = compensate_pressure(uncomp_press, t_lin, par) + pressure_kpa = press_pa / 1000.0 + temperature_c = t_lin + t = time.monotonic() - t0 + t_all.append(t) + p_all.append(pressure_kpa) + temp_all.append(temperature_c) + csv_file.write("%.4f,%.5f,%.5f,%d,%d\n" + % (t, pressure_kpa, temperature_c, uncomp_press, uncomp_temp)) + csv_file.flush() + i += 1 + except Exception: + continue # skip a malformed frame rather than crash + + if live and (i % 5 == 0): + lo = t - PLOT_WINDOW_S + start = bisect.bisect_left(t_all, lo) # t_all is monotonic + xs = t_all[start:] + ps = p_all[start:] + ts = temp_all[start:] + ln_p.set_data(xs, ps) + ln_t.set_data(xs, ts) + axp.set_xlim(max(0, lo), t + 0.5) + set_ylim_padded(axp, ps, 0.002) + set_ylim_padded(axt, ts, 0.01) + try: + plt.pause(0.001) + if not plt.fignum_exists(fig.number): + stopped_clean = True + break + except Exception: + live = False + if stopped_clean: + break + +except KeyboardInterrupt: + pass +finally: + try: + ser.write(struct.pack('B', STOP_STREAMING_COMMAND)) + wait_for_ack(ser, timeout=1.0) + except Exception: + pass + try: + ser.close() + except Exception: + pass + csv_file.close() + print("\nstopped. %d samples -> %s" % (len(t_all), csv_path)) + if len(temp_all) > 2: + slope, _, r2 = linfit(temp_all, [p * 1000.0 for p in p_all]) + total_pa = (max(p_all) - min(p_all)) * 1000.0 + print("pressure vs temperature: slope = %+.1f Pa/degC R^2 = %.3f excursion = %.0f Pa" + % (slope, r2, total_pa)) + if abs(slope) < 5: + print(" => BMP390 compensated correctly (near the +/-0.6 Pa/degC datasheet spec).") + else: + print(" => residual %.0f Pa/degC (check coefficients / thermal transients)." % slope) + png = save_summary_png(t_all, p_all, temp_all, slope, r2, port) + if png: + print(" plot saved -> %s" % png) + try: + plt.ioff(); plt.show() + except Exception: + pass diff --git a/Extras/python_scripts/Bluetooth commands/bmp581.py b/Extras/python_scripts/Bluetooth commands/bmp581.py new file mode 100644 index 00000000..4057fef5 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp581.py @@ -0,0 +1,304 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# BMP581 Bluetooth streaming test (Shimmer3R). +# +# You can pass EITHER a serial port OR the Shimmer's Bluetooth MAC address - the +# script resolves a MAC to its outgoing COM port automatically: +# bmp581.py COM28 # explicit port +# bmp581.py 00:06:66:B1:4B:B7 # MAC (colons optional) +# bmp581.py 000666B14BB7 # MAC, no separators +# bmp581.py --list # list all BT serial ports + their MACs +# Or set DEFAULT_MAC below and run with no argument. +# +# Flow: +# 1. Send GET_PRESSURE_CALIBRATION_COEFFICIENTS (0xA7). A BMP581 self-compensates +# and has NO calibration coefficients, so the firmware replies with a NACK +# (0xFE). This script expects that NACK and proceeds - no coefficients needed. +# 2. Enable pressure + temperature, set the sampling rate, start streaming. +# 3. Convert each sample straight from the RAW 24-bit values (no host trimming): +# pressure (uint24 little-endian) Pa = raw / 64 +# temperature (int24 little-endian) degC = raw / 65536 +# +# Packet layout while only pressure+temperature are enabled (framesize 10): +# [1] packet type | [3] timestamp | [3] pressure | [3] temperature (all little-endian) + +import re +import serial +import serial.tools.list_ports +import struct +import sys + +# --- User setting: optionally hard-code your Shimmer's MAC here ------------- +DEFAULT_MAC = "" # e.g. "00:06:66:B1:4B:B7" (leave "" to require a CLI argument) + +# --- Shimmer BT bytes ------------------------------------------------------ +ACK = 0xFF +NACK = 0xFE +GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND = 0xA7 +PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE = 0xA6 +SET_SENSORS_COMMAND = 0x08 +SET_SAMPLING_RATE_COMMAND = 0x05 +START_STREAMING_COMMAND = 0x07 +STOP_STREAMING_COMMAND = 0x20 + +# chEnPressureAndTemperature is bit 2 of the "Sensors 2" byte (3rd sensor byte) +SENSORS0 = 0x00 +SENSORS1 = 0x00 +SENSORS2 = 0x04 # pressure & temperature + +DESIRED_SAMPLING_FREQ_HZ = 10.0 + +# BMP581 sanity ranges (quick pass/fail hint) +PRESS_MIN_PA = 30000.0 +PRESS_MAX_PA = 125000.0 +TEMP_MIN_C = -40.0 +TEMP_MAX_C = 85.0 + + +# --- Port / MAC helpers ---------------------------------------------------- +def normalize_mac(s): + """Strip separators, upper-case -> 12 hex chars (or whatever hex it holds).""" + return re.sub(r'[^0-9A-Fa-f]', '', s).upper() + + +def extract_mac_from_hwid(hwid): + """Pull the device MAC (a contiguous 12-hex run) out of a Windows BT hwid. + Strips the {..} service-UUID first so the Bluetooth base UUID fragment + (00805F9B34FB) on non-connectable local ports isn't mistaken for a MAC.""" + h = re.sub(r'\{[^}]*\}', '', hwid or "").replace(':', '') + BASE = '00805F9B34FB' # Bluetooth base UUID tail - not a device MAC + candidates = [c.upper() for c in re.findall(r'([0-9A-Fa-f]{12})', h) + if set(c.upper()) != {'0'} and c.upper() != BASE] + return candidates[-1] if candidates else None + + +def serial_ports_bluetooth(): + """Ports whose description mentions Bluetooth (same filter as + shimmer_device.serial_ports_bluetooth()).""" + return [p for p in serial.tools.list_ports.comports() + if "Bluetooth" in (p.description or "")] + + +def list_bt_ports(): + print("Bluetooth serial ports (MAC | port | description):") + found = False + for p in serial.tools.list_ports.comports(): + mac = extract_mac_from_hwid(p.hwid) + if mac: # only ports carrying a real device MAC are connectable + found = True + print(" %-12s %-6s %s" % (mac, p.device, p.description)) + if not found: + print(" (none found - is the device paired?)") + + +def pick_com_port_interactive(): + """Mirror of shimmer_app_common.get_selected_com_port(dock_ports=False): + list the Bluetooth serial ports and let the user pick one by number.""" + options = serial_ports_bluetooth() + if not options: + print("No Bluetooth serial ports found - is the Shimmer paired?") + return None + print("Pick an option:") + for index, item in enumerate(options): + mac = extract_mac_from_hwid(item.hwid) or "?" + print(" %d) %s (MAC %s) %s" % (index + 1, item.device, mac, item.description)) + user_input = "" + while (not user_input.isdigit()) or int(user_input) < 1 or int(user_input) > len(options): + user_input = input("Your choice: ").strip() + port = options[int(user_input) - 1].device + print("You picked: %s\n" % port) + return port + + +def find_port_by_mac(mac): + """Return the COM port whose hwid contains this MAC, preferring the + outgoing port (hwid ..._C00000000).""" + target = normalize_mac(mac) + outgoing = None + fallback = None + for p in serial.tools.list_ports.comports(): + hwid = (p.hwid or "").upper().replace(':', '').replace('-', '') + if target and target in hwid: + if '_C00000000' in hwid: + outgoing = p.device + elif fallback is None: + fallback = p.device + return outgoing or fallback + + +def resolve_port(arg): + """arg may be a COM/dev path or a MAC address. Returns a port string or None.""" + if not arg: + arg = DEFAULT_MAC + if not arg: + return None + a = arg.strip() + if a.upper().startswith('COM') or a.startswith('/dev/'): + return a + n = normalize_mac(a) + if len(n) == 12: + port = find_port_by_mac(n) + if port: + print("MAC %s -> %s" % (a, port)) + else: + print("No paired serial port found for MAC %s." % a) + list_bt_ports() + return port + # not COM, not a 12-hex MAC: use verbatim and let serial.Serial complain + return a + + +def wait_for_ack(): + ack = struct.pack('B', ACK) + ddata = bytes() + while ddata != ack: + ddata = ser.read(1) + return + + +def u24_le(b0, b1, b2): + """Unsigned 24-bit, little-endian (b0 = XLSB).""" + return b0 | (b1 << 8) | (b2 << 16) + + +def s24_le(b0, b1, b2): + """Signed 24-bit, little-endian (b0 = XLSB).""" + v = u24_le(b0, b1, b2) + if v >= 0x800000: + v -= 0x1000000 + return v + + +def check_calibration_nack(): + """Send GET_PRESSURE_CALIBRATION_COEFFICIENTS and expect a NACK for a BMP581.""" + ser.write(struct.pack('B', GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND)) + resp = ser.read(1) + if len(resp) == 0: + print("WARNING: no reply to calibration command (timeout).") + return False + b = resp[0] + if b == NACK: + print("calibration command NACK'd (0xFE) - BMP581 is pre-compensated, " + "no coefficients needed. Good.") + return True + if b == ACK: + hdr = ser.read(1) + if len(hdr) and hdr[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: + lenb = ser.read(1) + if len(lenb) == 0: + print("WARNING: timed out reading calibration length byte.") + return False + length = lenb[0] + payload = ser.read(length) + sensor_id = payload[0] if len(payload) else -1 + print("WARNING: device ACK'd and returned coefficients (sensor id " + "0x%02x). NACK not active for this unit - discarding " + "coefficients and streaming raw anyway." % sensor_id) + else: + print("WARNING: unexpected response header after ACK: %r" % hdr) + return False + print("WARNING: unexpected reply 0x%02x to calibration command." % b) + return False + + +# --- Argument handling ----------------------------------------------------- +raw_args = sys.argv[1:] + +if any(a in ('--list', '-l', 'list') for a in raw_args): + list_bt_ports() + sys.exit(0) + +# Join args and drop stray spaces so "COM 38" -> "COM38" and a spaced MAC works too +arg = "".join(raw_args).strip() if raw_args else None + +port = resolve_port(arg) +if not port: + # No argument (or MAC not found): fall back to the interactive numbered + # picker, same behaviour as test_bt_cmds.py / get_selected_com_port(). + port = pick_com_port_interactive() +if not port: + print("No device selected. You can also pass a COM port or MAC directly:") + print(" bmp581.py COM28") + print(" bmp581.py 00:06:66:B1:4B:B7") + print(" bmp581.py --list (show paired BT ports + MACs)") + sys.exit(1) + +ser = serial.Serial(port, 115200, timeout=2) +ser.flushInput() +print("port %s opened." % port) + +# 1. calibration handshake - expect NACK for a BMP581 +check_calibration_nack() + +# 2. enable pressure + temperature +ser.write(struct.pack('BBBB', SET_SENSORS_COMMAND, SENSORS0, SENSORS1, SENSORS2)) +wait_for_ack() +print("sensors set (pressure + temperature).") + +# 3. set sampling rate: clock_wait = 32768 / freq, little-endian uint16 +clock_wait = int(32768 / DESIRED_SAMPLING_FREQ_HZ) +ser.write(struct.pack(' 0 + else "CHECK - some/all samples out of range.")) + else: + print("RESULT: FAIL - no samples received.") diff --git a/Extras/python_scripts/Bluetooth commands/bmp581_plot.py b/Extras/python_scripts/Bluetooth commands/bmp581_plot.py new file mode 100644 index 00000000..ff49d314 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp581_plot.py @@ -0,0 +1,517 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# BMP581 live PLOT + CSV logger, with an optional host-side TEMPERATURE +# CORRECTION so you can watch raw vs corrected pressure in real time. +# +# * streams pressure (raw/64) + temperature (signed/65536) over BT, +# * applies a quadratic temp correction P_corr = P - (a*(T-Tref)^2 + b*(T-Tref)), +# * live-plots THREE panels: temperature, raw pressure, corrected pressure, +# * logs elapsed, raw kPa, corrected kPa, temp, raw counts to bmp581_stream.csv, +# * on exit prints the raw AND corrected pressure-vs-temperature slope, saves PNG. +# +# ROBUSTNESS: survives Bluetooth dropouts. If the link stalls or errors it closes +# the port, reconnects, and re-issues the streaming commands automatically (up to +# MAX_RECONNECTS times), so a long fridge sweep is not lost to a transient glitch. +# Every sample is flushed to CSV immediately, so data already captured is safe +# even on a hard crash. +# +# The correction coefficients below are PER-UNIT (fit from a fridge sweep). Refit +# for each sensor; set CORRECTION_ENABLE=False to see only the raw comp=3 output. +# +# Usage: bmp581_plot.py COM56 | | --list +# Requires pyserial; matplotlib is auto-installed on first run if missing. + +import bisect +import os +import re +import struct +import subprocess +import sys +import time + +import serial +import serial.tools.list_ports + + +def _ensure_matplotlib(): + try: + import matplotlib # noqa: F401 + return True + except Exception: + print("matplotlib not found - attempting a one-time install via pip ...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "matplotlib"]) + import matplotlib # noqa: F401 + return True + except Exception as e: + print(" auto-install failed (%s). Run manually: pip install matplotlib" % e) + return False + + +HAVE_MPL = _ensure_matplotlib() +if HAVE_MPL: + import matplotlib + try: + matplotlib.use("TkAgg") + except Exception: + pass + import matplotlib.pyplot as plt +else: + print("Continuing without plots - data still logged to bmp581_stream.csv") + +# --- User settings --------------------------------------------------------- +DEFAULT_MAC = "" +DESIRED_SAMPLING_FREQ_HZ = 51.2 +PLOT_WINDOW_S = 120.0 + +# --- Robustness settings --------------------------------------------------- +SERIAL_TIMEOUT_S = 1.0 # per-read timeout (keeps the GUI responsive) +STALL_TIMEOUT_S = 12.0 # no bytes for this long -> treat link as stalled +RECONNECT_WAIT_S = 3.0 # pause between reconnect attempts +MAX_RECONNECTS = 20 # give up after this many consecutive failures +ACK_TIMEOUT_S = 4.0 # how long to wait for a command ACK + +# --- Host-side pressure temperature-correction (PER-UNIT; fit from a fridge sweep) +# P_corr = P_meas - (CORR_C*dt^3 + CORR_A*dt^2 + CORR_B*dt), dt = T_raw - CORR_TREF [Pa] +# Fit to the 2026-07-29 15:22 run (BMP581 UID 1600 2988 86DF C7B2) vs the RAW +# on-chip temperature. Cubic gave negligible gain here (the residual is +# noise-limited until the firmware IIR/OSR fix lands) so the default is quadratic; +# for cubic use CORR_C=0.019269, CORR_A=2.65922, CORR_B=450.4511. +# Set CORRECTION_ENABLE=False to plot only the raw comp=3 output. +CORRECTION_ENABLE = True +CORR_C = 0.0 # Pa/degC^3 +CORR_A = 1.89211 # Pa/degC^2 +CORR_B = 444.3297 # Pa/degC +CORR_TREF = 25.0 # degC +# Constant absolute-alignment offset ADDED to the corrected pressure so it matches +# the co-located BMP390 (the working reference). +1182 Pa (at 25 C) from the +# 2026-07-29 16:52 run; the sensor-to-sensor offset repeated to ~4 Pa at the +# reference temp across 2 runs, but the raw slope drifts ~10% boot-to-boot so this +# is PROVISIONAL pending the power-cycle repeatability test. Set 0.0 to disable. +CORR_OFFSET = 1182.0 # Pa + +# --- BMP581 temperature-channel calibration (2-point) ------------------------- +# The BMP581 temperature is uncompensated too (same broken on-chip engine as +# pressure): it OVER-reads the swing by ~28%. Interim mapping to true temperature +# derived by aligning this unit against a co-located BMP390 +# (T_true ~= 0.782*T_raw + 6.70, RMS 0.76 C). REPLACE with a real 2-point +# measurement: read T_raw in an ice bath (0 C) and at a known warm reference +# against a thermometer, then +# TEMP_CAL_GAIN = (T_hi - T_lo) / (Traw_hi - Traw_lo) +# TEMP_CAL_OFFSET = T_lo - TEMP_CAL_GAIN * Traw_lo +# This ONLY fixes the reported temperature (logged as temperature_cal_C and shown +# on the temp panel); the pressure correction above uses the raw temp and is +# unaffected, so you can re-tune this without refitting the pressure coeffs. +TEMP_CAL_ENABLE = True +TEMP_CAL_GAIN = 0.7822 +TEMP_CAL_OFFSET = 6.695 + +# --- Shimmer BT bytes ------------------------------------------------------ +ACK = 0xFF +NACK = 0xFE +GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND = 0xA7 +PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE = 0xA6 +SET_SENSORS_COMMAND = 0x08 +SET_SAMPLING_RATE_COMMAND = 0x05 +START_STREAMING_COMMAND = 0x07 +STOP_STREAMING_COMMAND = 0x20 +SENSORS0 = 0x00 +SENSORS1 = 0x00 +SENSORS2 = 0x04 # pressure & temperature + + +# --- Port / MAC helpers ---------------------------------------------------- +def normalize_mac(s): + return re.sub(r'[^0-9A-Fa-f]', '', s).upper() + + +def extract_mac_from_hwid(hwid): + h = re.sub(r'\{[^}]*\}', '', hwid or "").replace(':', '') + BASE = '00805F9B34FB' + cands = [c.upper() for c in re.findall(r'([0-9A-Fa-f]{12})', h) + if set(c.upper()) != {'0'} and c.upper() != BASE] + return cands[-1] if cands else None + + +def serial_ports_bluetooth(): + return [p for p in serial.tools.list_ports.comports() + if "Bluetooth" in (p.description or "")] + + +def list_bt_ports(): + print("Bluetooth serial ports (MAC | port | description):") + found = False + for p in serial.tools.list_ports.comports(): + mac = extract_mac_from_hwid(p.hwid) + if mac: + found = True + print(" %-12s %-6s %s" % (mac, p.device, p.description)) + if not found: + print(" (none found - is the device paired?)") + + +def pick_com_port_interactive(): + options = serial_ports_bluetooth() + if not options: + print("No Bluetooth serial ports found - is the Shimmer paired?") + return None + print("Pick an option:") + for index, item in enumerate(options): + mac = extract_mac_from_hwid(item.hwid) or "?" + print(" %d) %s (MAC %s) %s" % (index + 1, item.device, mac, item.description)) + ui = "" + while (not ui.isdigit()) or int(ui) < 1 or int(ui) > len(options): + ui = input("Your choice: ").strip() + port = options[int(ui) - 1].device + print("You picked: %s\n" % port) + return port + + +def find_port_by_mac(mac): + target = normalize_mac(mac) + outgoing = fallback = None + for p in serial.tools.list_ports.comports(): + hwid = (p.hwid or "").upper().replace(':', '').replace('-', '') + if target and target in hwid: + if '_C00000000' in hwid: + outgoing = p.device + elif fallback is None: + fallback = p.device + return outgoing or fallback + + +def resolve_port(arg): + if not arg: + arg = DEFAULT_MAC + if not arg: + return None + a = arg.strip() + if a.upper().startswith('COM') or a.startswith('/dev/'): + return a + n = normalize_mac(a) + if len(n) == 12: + port = find_port_by_mac(n) + print(("MAC %s -> %s" % (a, port)) if port else ("No paired port for MAC %s." % a)) + return port + return a + + +def u24_le(b0, b1, b2): + return b0 | (b1 << 8) | (b2 << 16) + + +def s24_le(b0, b1, b2): + v = u24_le(b0, b1, b2) + return v - 0x1000000 if v >= 0x800000 else v + + +def wait_for_ack(ser, timeout=ACK_TIMEOUT_S): + """Wait up to `timeout` s for a 0xFF ACK. Returns True/False (never hangs).""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + d = ser.read(1) + if d and d[0] == ACK: + return True + return False + + +def check_calibration_nack(ser): + try: + ser.write(struct.pack('B', GET_PRESSURE_CALIBRATION_COEFFICIENTS_COMMAND)) + resp = ser.read(1) + except Exception as e: + print("WARNING: calibration probe failed (%s)." % e) + return + if len(resp) == 0: + print("WARNING: no reply to calibration command (timeout).") + return + b = resp[0] + if b == NACK: + print("calibration command NACK'd (0xFE) - BMP581 pre-compensated. Good.") + elif b == ACK: + hdr = ser.read(1) + if len(hdr) and hdr[0] == PRESSURE_CALIBRATION_COEFFICIENTS_RESPONSE: + lenb = ser.read(1) + if len(lenb): + ser.read(lenb[0]) + print("WARNING: device ACK'd coefficients - streaming raw anyway.") + else: + print("WARNING: unexpected reply 0x%02x." % b) + + +def open_serial(port): + ser = serial.Serial(port, 115200, timeout=SERIAL_TIMEOUT_S) + ser.flushInput() + return ser + + +def start_streaming(ser): + """Run the full handshake. Returns True if streaming was (re)started.""" + check_calibration_nack(ser) + ser.write(struct.pack('BBBB', SET_SENSORS_COMMAND, SENSORS0, SENSORS1, SENSORS2)) + if not wait_for_ack(ser): + print("WARNING: no ACK for SET_SENSORS.") + clock_wait = int(32768 / DESIRED_SAMPLING_FREQ_HZ) + ser.write(struct.pack(' kPa.""" + if not CORRECTION_ENABLE: + return pressure_kpa + dt = temperature_c - CORR_TREF + corr_pa = CORR_C * dt * dt * dt + CORR_A * dt * dt + CORR_B * dt + return pressure_kpa - corr_pa / 1000.0 + CORR_OFFSET / 1000.0 + + +def calibrate_temp(temperature_c): + """Map the raw on-chip temperature to true temperature (2-point). -> degC.""" + if not TEMP_CAL_ENABLE: + return temperature_c + return TEMP_CAL_GAIN * temperature_c + TEMP_CAL_OFFSET + + +def save_summary_png(t_all, p_all, pc_all, temp_all, sr, sc, port): + if not HAVE_MPL or len(t_all) < 2: + return None + fig, ax = plt.subplots(3, 1, figsize=(10, 11), sharex=True) + ax[0].plot(t_all, temp_all, color="tab:red", lw=0.5) + ax[0].set_ylabel("Temperature (degC)"); ax[0].grid(alpha=0.3) + ax[0].set_title("BMP581 %s (N=%d, %.0fs)" % (port, len(t_all), t_all[-1] - t_all[0])) + ax[1].plot(t_all, p_all, color="tab:blue", lw=0.5) + ax[1].set_ylabel("Raw pressure (kPa)"); ax[1].grid(alpha=0.3) + ax[1].set_title("raw comp=3: %+.0f Pa/degC" % sr, fontsize=9, loc="right") + ax[2].plot(t_all, pc_all, color="tab:green", lw=0.5) + ax[2].set_ylabel("Corrected pressure (kPa)"); ax[2].set_xlabel("elapsed s"); ax[2].grid(alpha=0.3) + ax[2].set_title("corrected: %+.1f Pa/degC" % sc, fontsize=9, loc="right") + fig.tight_layout() + png = os.path.abspath("bmp581_plot.png") + fig.savefig(png, dpi=110) + return png + + +# --- Main ------------------------------------------------------------------ +raw_args = sys.argv[1:] +if any(a in ('--list', '-l', 'list') for a in raw_args): + list_bt_ports() + sys.exit(0) + +arg = "".join(raw_args).strip() if raw_args else None +port = resolve_port(arg) or pick_com_port_interactive() +if not port: + print("No device selected. Pass a COM port or MAC (see --list).") + sys.exit(1) + +ser = open_serial(port) +print("port %s opened." % port) +if CORRECTION_ENABLE: + print("temp correction ON: P_corr = P - (%.4f*(T-%.1f)^2 + %.3f*(T-%.1f))" + % (CORR_A, CORR_TREF, CORR_B, CORR_TREF)) +else: + print("temp correction OFF (raw comp=3 only).") + +if not start_streaming(ser): + print("streaming did not start cleanly - continuing to read anyway.") +print("streaming at %.1f Hz. %s\n" + % (DESIRED_SAMPLING_FREQ_HZ, + ("Close the plot window or press Ctrl+C to stop." if HAVE_MPL + else "Press Ctrl+C to stop; data -> bmp581_stream.csv."))) + +csv_path = os.path.abspath("bmp581_stream.csv") +csv_file = open(csv_path, "w") +csv_file.write("elapsed_s,pressure_kPa,pressure_corr_kPa,temperature_C,temperature_cal_C,raw_press,raw_temp\n") + +t_all, p_all, pc_all, temp_all = [], [], [], [] + +live = False +if HAVE_MPL: + try: + plt.ion() + fig, (axt, axp, axpc) = plt.subplots(3, 1, figsize=(9, 10), sharex=True) + (ln_t,) = axt.plot([], [], color="tab:red", lw=0.8) + (ln_p,) = axp.plot([], [], color="tab:blue", lw=0.8) + (ln_pc,) = axpc.plot([], [], color="tab:green", lw=0.8) + axt.set_ylabel("Temperature (degC, cal)" if TEMP_CAL_ENABLE else "Temperature (degC)") + axp.set_ylabel("Raw pressure (kPa)") + axpc.set_ylabel("Corrected pressure (kPa)") + axpc.set_xlabel("elapsed (s)") + axt.set_title("Shimmer BMP581 live - temperature / raw / corrected (%s)" % port) + for a in (axt, axp, axpc): + a.grid(True, alpha=0.3) + fig.tight_layout() + plt.show(block=False) + live = True + except Exception as e: + print("live plot unavailable (%s) - will still save the PNG on exit." % e) + +ddata = bytes() +framesize = 10 +t0 = time.monotonic() +i = 0 +last_rx = time.monotonic() +stopped_clean = False +try: + while True: + # --- resilient read ------------------------------------------------ + try: + navail = ser.in_waiting + except Exception: + navail = 0 + try: + chunk = ser.read(navail if navail > framesize else framesize) + except (serial.SerialException, OSError) as e: + print("\nserial error: %s" % e) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + now = time.monotonic() + if chunk: + ddata += chunk + last_rx = now + elif now - last_rx > STALL_TIMEOUT_S: + print("\nno data for %.0fs - link stalled, reconnecting ..." % (now - last_rx)) + try: + ser.close() + except Exception: + pass + ser = reconnect(port) + if ser is None: + print("could not reconnect - stopping and saving.") + break + ddata = bytes() + last_rx = time.monotonic() + continue + + # --- parse whatever complete frames we have ------------------------ + while len(ddata) >= framesize: + frame = ddata[0:framesize] + ddata = ddata[framesize:] + try: + (p0, p1, p2, tt0, tt1, tt2) = struct.unpack('BBBBBB', frame[4:framesize]) + pressure_kpa = u24_le(p0, p1, p2) / 64000.0 + temperature_c = s24_le(tt0, tt1, tt2) / 65536.0 + temperature_cal = calibrate_temp(temperature_c) + pressure_corr_kpa = correct_pa(pressure_kpa, temperature_c) + t = time.monotonic() - t0 + t_all.append(t) + p_all.append(pressure_kpa) + pc_all.append(pressure_corr_kpa) + temp_all.append(temperature_cal) # plot/report the calibrated temp + csv_file.write("%.4f,%.5f,%.5f,%.5f,%.5f,%d,%d\n" + % (t, pressure_kpa, pressure_corr_kpa, temperature_c, + temperature_cal, u24_le(p0, p1, p2), s24_le(tt0, tt1, tt2))) + csv_file.flush() + i += 1 + except Exception: + continue # skip a malformed frame rather than crash + + if live and (i % 5 == 0): + lo = t - PLOT_WINDOW_S + start = bisect.bisect_left(t_all, lo) # t_all is monotonic + xs = t_all[start:] + ps = p_all[start:] + pcs = pc_all[start:] + ts = temp_all[start:] + ln_t.set_data(xs, ts) + ln_p.set_data(xs, ps) + ln_pc.set_data(xs, pcs) + axpc.set_xlim(max(0, lo), t + 0.5) + set_ylim_padded(axt, ts, 0.01) + set_ylim_padded(axp, ps, 0.002) + set_ylim_padded(axpc, pcs, 0.002) + try: + plt.pause(0.001) + if not plt.fignum_exists(fig.number): + stopped_clean = True + break + except Exception: + live = False + if stopped_clean: + break + +except KeyboardInterrupt: + pass +finally: + try: + ser.write(struct.pack('B', STOP_STREAMING_COMMAND)) + wait_for_ack(ser, timeout=1.0) + except Exception: + pass + try: + ser.close() + except Exception: + pass + csv_file.close() + print("\nstopped. %d samples -> %s" % (len(t_all), csv_path)) + sr = sc = 0.0 + if len(temp_all) > 2: + sr, _, r2r = linfit(temp_all, [p * 1000.0 for p in p_all]) + sc, _, r2c = linfit(temp_all, [p * 1000.0 for p in pc_all]) + print("pressure vs temperature slope:") + print(" RAW comp=3 : %+.1f Pa/degC (R2=%.3f)" % (sr, r2r)) + if CORRECTION_ENABLE: + print(" CORRECTED : %+.2f Pa/degC (R2=%.3f) <- should be near 0" % (sc, r2c)) + png = save_summary_png(t_all, p_all, pc_all, temp_all, sr, sc, port) + if png: + print(" plot saved -> %s" % png) + try: + plt.ioff(); plt.show() + except Exception: + pass diff --git a/Extras/python_scripts/Bluetooth commands/bmp_compare.py b/Extras/python_scripts/Bluetooth commands/bmp_compare.py new file mode 100644 index 00000000..faaec519 --- /dev/null +++ b/Extras/python_scripts/Bluetooth commands/bmp_compare.py @@ -0,0 +1,246 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Combine the outputs of bmp581_plot.py and bmp390_plot.py into ONE figure. +# +# Run the two live scripts (ideally started together) during a fridge test: +# python bmp581_plot.py COM -> bmp581_stream.csv +# python bmp390_plot.py COM -> bmp390_stream.csv +# then run this to overlay them: +# python bmp_compare.py +# python bmp_compare.py # explicit paths +# +# The result is FIVE stacked panels, each on its OWN independent y-scale, sharing +# the elapsed-time axis: +# 1) BMP581 temperature +# 2) BMP581 original pressure (raw comp=3) +# 3) BMP581 corrected pressure +# 4) BMP390 temperature +# 5) BMP390 pressure (host-compensated) +# Independent scales mean a well-corrected BMP581 and the BMP390 both look flat +# even though the two chips sit at different absolute offsets ("not very off"). +# +# Requires matplotlib (auto-installed on first run if missing). + +import csv +import os +import subprocess +import sys + +DEFAULT_581 = "bmp581_stream.csv" +DEFAULT_390 = "bmp390_stream.csv" + +COL_RAW = "tab:blue" +COL_COR = "tab:green" +COL_390 = "tab:red" + + +def _ensure_matplotlib(): + try: + import matplotlib # noqa: F401 + return True + except Exception: + print("matplotlib not found - attempting a one-time install via pip ...") + try: + subprocess.check_call([sys.executable, "-m", "pip", "install", "matplotlib"]) + import matplotlib # noqa: F401 + return True + except Exception as e: + print(" auto-install failed (%s). Run manually: pip install matplotlib" % e) + return False + + +HAVE_MPL = _ensure_matplotlib() +if HAVE_MPL: + import matplotlib + try: + matplotlib.use("TkAgg") + except Exception: + pass + import matplotlib.pyplot as plt + + +def find_csv(name): + """Look in cwd first, then next to this script.""" + if os.path.isfile(name): + return os.path.abspath(name) + here = os.path.join(os.path.dirname(os.path.abspath(__file__)), name) + return here if os.path.isfile(here) else None + + +def read_stream_csv(path): + """Read a bmpXXX_stream.csv into {column_name: [floats]} keyed by header.""" + cols = {} + with open(path, "r") as f: + reader = csv.reader(f) + header = [h.strip() for h in next(reader)] + for h in header: + cols[h] = [] + for row in reader: + if len(row) < len(header): + continue + vals = [] + ok = True + for v in row[:len(header)]: + try: + vals.append(float(v)) + except ValueError: + ok = False + break + if not ok: + continue + for h, v in zip(header, vals): + cols[h].append(v) + return cols + + +def col(cols, *names): + for n in names: + if n in cols and cols[n]: + return cols[n] + return None + + +def linfit(x, y): + n = min(len(x), len(y)) + if n < 2: + return 0.0, 0.0 + x = x[:n]; y = y[:n] + mx = sum(x) / n + my = sum(y) / n + sxx = sum((xi - mx) ** 2 for xi in x) + sxy = sum((xi - mx) * (yi - my) for xi, yi in zip(x, y)) + syy = sum((yi - my) ** 2 for yi in y) + if sxx == 0 or syy == 0: + return 0.0, 0.0 + return sxy / sxx, (sxy * sxy) / (sxx * syy) + + +def report(label, temp, press_kpa): + if not temp or not press_kpa: + return + slope, r2 = linfit(temp, [p * 1000.0 for p in press_kpa]) + span = (max(press_kpa) - min(press_kpa)) * 1000.0 + mean = sum(press_kpa) / len(press_kpa) + print(" %-24s slope %+8.2f Pa/degC R2=%.4f span=%6.0f Pa mean=%.3f kPa" + % (label, slope, r2, span, mean)) + + +# --- Load ------------------------------------------------------------------ +NO_SHOW = ("--no-show" in sys.argv) or bool(os.environ.get("BMP_NOSHOW")) +args = [a for a in sys.argv[1:] if a and not a.startswith("-")] +p581 = args[0] if len(args) >= 1 else DEFAULT_581 +p390 = args[1] if len(args) >= 2 else DEFAULT_390 +path581 = find_csv(p581) +path390 = find_csv(p390) + +if not path581 and not path390: + print("No input CSVs found. Expected %s and/or %s in this folder." % (DEFAULT_581, DEFAULT_390)) + print("Run bmp581_plot.py / bmp390_plot.py first, or pass paths as arguments.") + sys.exit(1) + +d581 = read_stream_csv(path581) if path581 else {} +d390 = read_stream_csv(path390) if path390 else {} + +t581 = col(d581, "elapsed_s") +p581_raw = col(d581, "pressure_kPa") +p581_cor = col(d581, "pressure_corr_kPa") or p581_raw # fall back if not corrected +temp581 = col(d581, "temperature_cal_C", "temperature_C") # prefer calibrated temp + +t390 = col(d390, "elapsed_s") +p390_c = col(d390, "pressure_kPa") +temp390 = col(d390, "temperature_C") + +print("\nInputs:") +if path581: + print(" BMP581: %s (%d samples)" % (path581, len(t581 or []))) +if path390: + print(" BMP390: %s (%d samples)" % (path390, len(t390 or []))) + +print("\nPressure vs temperature (flatness):") +report("BMP581 raw comp=3", temp581, p581_raw) +if col(d581, "pressure_corr_kPa"): + report("BMP581 corrected", temp581, p581_cor) +report("BMP390 host-compensated", temp390, p390_c) + +# Absolute offset of the corrected BMP581 vs the BMP390 at the 25 C reference. +# Weather cancels (co-located), so this is the run-to-run repeatability metric: +# tabulate it across reboots - near-constant => a fixed offset holds. +def value_at(temp, p_kpa, tref=25.0): + """Linear-fit pressure (Pa) evaluated at tref.""" + pa = [p * 1000.0 for p in p_kpa] + s, _ = linfit(temp, pa) + mt = sum(temp) / len(temp) + mp = sum(pa) / len(pa) + return mp - s * (mt - tref) + + +if temp581 and p581_cor and temp390 and p390_c: + off = value_at(temp390, p390_c) - value_at(temp581, p581_cor) + print("\nAbsolute offset at 25 C: BMP390 - corrected BMP581 = %+.0f Pa" % off) + print(" (~0 means the CORR_OFFSET in bmp581_plot.py is aligned; track this across reboots)") + +if not HAVE_MPL: + print("\n(matplotlib unavailable - stats only.)") + sys.exit(0) + + +# --- Plot ------------------------------------------------------------------ +# Five stacked panels, each on its OWN independent y-scale, sharing the time axis: +# 1) BMP581 temperature +# 2) BMP581 original pressure (raw comp=3) +# 3) BMP581 corrected pressure +# 4) BMP390 temperature +# 5) BMP390 pressure (host-compensated) +# For a pressure panel, its pressure-vs-temperature slope is shown at the right. +COL_T = "tab:red" +has_corr = bool(col(d581, "pressure_corr_kPa")) + + +def slope_note(temp, press_kpa): + if not temp or not press_kpa: + return "" + s, r2 = linfit(temp, [p * 1000.0 for p in press_kpa]) + return "%+.1f Pa/degC R2=%.3f" % (s, r2) + + +# (title, ylabel, x, y, colour, temp_for_slope_or_None) +panels = [] +if t581 and temp581: + panels.append(("BMP581 temperature", "Temp (degC)", t581, temp581, COL_T, None)) +if t581 and p581_raw: + panels.append(("BMP581 original pressure (raw comp=3)", "Pressure (kPa)", + t581, p581_raw, COL_RAW, temp581)) +if t581 and has_corr and p581_cor: + panels.append(("BMP581 corrected pressure", "Pressure (kPa)", + t581, p581_cor, COL_COR, temp581)) +if t390 and temp390: + panels.append(("BMP390 temperature", "Temp (degC)", t390, temp390, COL_T, None)) +if t390 and p390_c: + panels.append(("BMP390 pressure (compensated)", "Pressure (kPa)", + t390, p390_c, COL_390, temp390)) + +if not panels: + print("Nothing to plot - CSVs had no usable columns.") + sys.exit(1) + +n = len(panels) +fig, axes = plt.subplots(n, 1, figsize=(11, 2.2 * n), sharex=True) +if n == 1: + axes = [axes] + +for ax, (title, ylabel, x, y, colour, temp_ref) in zip(axes, panels): + ax.plot(x, y, color=colour, lw=0.7) + ax.set_ylabel(ylabel) + note = slope_note(temp_ref, y) if temp_ref is not None else "" + ax.set_title(title + ((" [%s]" % note) if note else ""), fontsize=9, loc="left") + ax.grid(True, alpha=0.3) + +axes[-1].set_xlabel("elapsed (s) [BMP581 and BMP390 line up only if both scripts started together]") +fig.suptitle("BMP581 vs BMP390 - combined (independent scales)", fontsize=11) +fig.tight_layout(rect=(0, 0, 1, 0.99)) +out = os.path.abspath("bmp_compare.png") +fig.savefig(out, dpi=110) +print("\ncombined plot (%d panels) saved -> %s" % (n, out)) +if not NO_SHOW: + plt.show() diff --git a/SDCard/shimmer_sd_cfg_file.c b/SDCard/shimmer_sd_cfg_file.c index 4f8ed2e0..d3e3e930 100644 --- a/SDCard/shimmer_sd_cfg_file.c +++ b/SDCard/shimmer_sd_cfg_file.c @@ -75,6 +75,7 @@ static int cfg_key_is(const char *line, const char *key) #define CFG_KEY_PRES_BMP180_PREC "pres_bmp180_prec" #define CFG_KEY_PRES_BMP280_PREC "pres_bmp280_prec" #define CFG_KEY_PRES_BMP390_PREC "pres_bmp390_prec" +#define CFG_KEY_PRES_BMP581_PREC "pres_bmp581_prec" #define CFG_KEY_GSR_RANGE "gsr_range" #define CFG_KEY_EXP_POWER "exp_power" #define CFG_KEY_GYRO_RANGE "gyro_range" @@ -252,7 +253,8 @@ void ShimSdCfgFile_generate(void) (isBmp180InUse() ? CFG_KEY_PRES_BMP180_PREC : CFG_KEY_PRES_BMP280_PREC), ShimConfig_configBytePressureOversamplingRatioGet()); #elif defined(SHIMMER3R) - CFG_WRITE_INT(&cfgFile, CFG_KEY_PRES_BMP390_PREC, + CFG_WRITE_INT(&cfgFile, + (isBmp581InUse() ? CFG_KEY_PRES_BMP581_PREC : CFG_KEY_PRES_BMP390_PREC), ShimConfig_configBytePressureOversamplingRatioGet()); #endif CFG_WRITE_INT(&cfgFile, CFG_KEY_GSR_RANGE, storedConfig->gsrRange); @@ -638,7 +640,8 @@ void ShimSdCfgFile_parse(void) ShimConfig_configBytePressureOversamplingRatioSet(atoi(equals)); } #elif defined(SHIMMER3R) - else if (cfg_key_is(buffer, CFG_KEY_PRES_BMP390_PREC)) + else if (cfg_key_is(buffer, CFG_KEY_PRES_BMP390_PREC) + || cfg_key_is(buffer, CFG_KEY_PRES_BMP581_PREC)) { ShimConfig_configBytePressureOversamplingRatioSet(atoi(equals)); } diff --git a/SDCard/shimmer_sd_header.c b/SDCard/shimmer_sd_header.c index 51b38e80..ad373009 100644 --- a/SDCard/shimmer_sd_header.c +++ b/SDCard/shimmer_sd_header.c @@ -206,7 +206,12 @@ void ShimSdHead_saveBmpCalibrationToSdHeader(void) bmpCalibPtr + BMP180_CALIB_DATA_SIZE, BMP280_CALIB_XTRA_BYTES); } #elif defined(SHIMMER3R) - /* BMP390 had 21 bytes stored in index SDH_TEMP_PRES_CALIBRATION */ - memcpy(&sdHeadText[SDH_TEMP_PRES_CALIBRATION], bmpCalibPtr, BMP3_LEN_CALIB_DATA); + /* BMP390 had 21 bytes stored in index SDH_TEMP_PRES_CALIBRATION. The BMP581 + * outputs pre-compensated pressure/temperature and so has no calibration + * coefficients to store. */ + if (!isBmp581InUse()) + { + memcpy(&sdHeadText[SDH_TEMP_PRES_CALIBRATION], bmpCalibPtr, BMP3_LEN_CALIB_DATA); + } #endif }