RadiaCode-Spectrometer/tools/radiacode_to_background.py
highway 3d6005b10a RadiaCode Spectrometer Display: initial public release
Standalone ESP32-S3 touchscreen companion for RadiaCode gamma spectrometers.
Connects over BLE, analyses the live 1024-channel spectrum on-device and
reports probable isotopes, with background subtraction and SD logging.

- Classical peak search with resolution-aware windows, competitive peak
  assignment and detectability-weighted line matching
- Decay-chain aggregation: uranium identified via Bi-214/Pb-214 rather than
  the gamma-silent parent
- Counting statistics drive a 'how much longer to measure' estimate
- Background references, recorded on-device or imported from RadiaCode XML
- Acquisition survives BLE dropouts, disconnects and reboots
- WiFi credentials entered on-device into NVS; nothing secret is compiled in
- OTA updates from this repository's releases

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 10:25:50 +02:00

195 lines
6.9 KiB
Python

#!/usr/bin/env python3
"""
Convert a background spectrum exported by the official RadiaCode software
into the background-reference CSV this firmware reads from the SD card.
Why convert on a PC rather than on the device: the RadiaCode export is XML,
and parsing XML on the ESP32 would be both heavy and brittle. The firmware
reads a trivial `channel,cps` CSV instead, and the files are already on a PC.
Usage
-----
python radiacode_to_background.py MySpectrum.xml --slot HOME
python radiacode_to_background.py *.xml --slot CAR --out E:/background
Then copy the resulting <SLOT>.csv into /background/ on the SD card, and on
the device: Data screen -> BKG LIBRARY -> MODE: LOAD -> tap the slot.
Valid slots (fixed, because the device has no keyboard):
HOME CAR BASEMENT GARDEN FIELD SPARE
"""
import argparse
import glob
import os
import sys
import xml.etree.ElementTree as ET
CHANNELS = 1024
SLOTS = ["HOME", "CAR", "BASEMENT", "GARDEN", "FIELD", "SPARE"]
# The device's own calibration, for a sanity check. A stored background is
# only meaningful if its channels map to the same energies as the live
# spectrum, so a mismatch here means the reference will be subtracted from
# the wrong places.
DEVICE_CALIB = (3.839503, 2.375420, 0.000364)
def localname(tag):
"""Strip any XML namespace so tag matching is namespace-agnostic."""
return tag.rsplit("}", 1)[-1]
def find_first(root, name):
for el in root.iter():
if localname(el.tag) == name:
return el
return None
def find_all(root, name):
return [el for el in root.iter() if localname(el.tag) == name]
def parse_spectrum(path, prefer_background):
"""Returns (counts, duration_seconds, coefficients)."""
tree = ET.parse(path)
root = tree.getroot()
# RadiaCode exports can carry both the measurement and a separately
# recorded background in one file.
wanted = "BackgroundEnergySpectrum" if prefer_background else "EnergySpectrum"
spec_parent = find_first(root, wanted)
if spec_parent is None:
if prefer_background:
raise ValueError(
"no <BackgroundEnergySpectrum> in this file - omit --from-background-element"
)
raise ValueError("no <EnergySpectrum> found - is this a RadiaCode export?")
# Duration. MeasurementTime is seconds in the exports seen so far.
duration = None
for name in ("MeasurementTime", "RealTime", "LiveTime"):
el = find_first(spec_parent, name)
if el is not None and el.text and el.text.strip():
try:
duration = float(el.text.strip())
break
except ValueError:
pass
if duration is None or duration <= 0:
raise ValueError("could not read a positive MeasurementTime")
coeffs = [float(c.text) for c in find_all(spec_parent, "Coefficient") if c.text]
points = find_all(spec_parent, "DataPoint")
counts = []
for p in points:
if p.text is None:
continue
try:
counts.append(int(float(p.text.strip())))
except ValueError:
pass
if not counts:
raise ValueError("no <DataPoint> values found")
return counts, duration, coeffs
def write_reference(counts, duration, out_path):
if len(counts) != CHANNELS:
# Rebinning would silently distort the energy mapping, so refuse
# rather than produce a reference that quietly subtracts the wrong
# channels.
raise ValueError(
f"expected {CHANNELS} channels, got {len(counts)} - this firmware "
f"assumes the RC-110's native 1024-channel spectrum"
)
total = sum(counts)
with open(out_path, "w", newline="\n") as f:
f.write("# background reference\n")
f.write(f"# duration_s,{int(round(duration))}\n")
f.write(f"# total_counts,{total}\n")
f.write("channel,cps\n")
for ch, c in enumerate(counts):
f.write(f"{ch},{c / duration:.4f}\n")
return total
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("files", nargs="+", help="RadiaCode XML export(s)")
ap.add_argument("--slot", default="HOME",
help="target slot name (default HOME). One file per slot.")
ap.add_argument("--out", default=".",
help="output directory - point this at <SD>/background")
ap.add_argument("--from-background-element", action="store_true",
help="read <BackgroundEnergySpectrum> instead of <EnergySpectrum>")
args = ap.parse_args()
slot = args.slot.upper()
if slot not in SLOTS:
print(f"warning: '{slot}' is not one of the device's slots "
f"({', '.join(SLOTS)}); it will not be loadable from the UI.",
file=sys.stderr)
paths = []
for pattern in args.files:
paths.extend(glob.glob(pattern))
if not paths:
print("no input files matched", file=sys.stderr)
return 1
if len(paths) > 1:
print(f"note: {len(paths)} files matched but a slot holds one reference; "
f"only the first ({os.path.basename(paths[0])}) will be written.",
file=sys.stderr)
path = paths[0]
try:
counts, duration, coeffs = parse_spectrum(path, args.from_background_element)
except Exception as e:
print(f"{os.path.basename(path)}: {e}", file=sys.stderr)
return 1
os.makedirs(args.out, exist_ok=True)
out_path = os.path.join(args.out, f"{slot}.csv")
try:
total = write_reference(counts, duration, out_path)
except Exception as e:
print(f"{os.path.basename(path)}: {e}", file=sys.stderr)
return 1
print(f"{os.path.basename(path)} -> {out_path}")
print(f" {len(counts)} channels, {duration:.0f} s, {total} counts, "
f"{total / duration:.2f} cps")
if coeffs:
shown = ", ".join(f"{c:g}" for c in coeffs[:3])
print(f" calibration: {shown}")
if len(coeffs) >= 3:
drift = [abs(coeffs[i] - DEVICE_CALIB[i]) for i in range(3)]
# a1 dominates the energy mapping; ~1% of it is a few keV at the
# top of the range, which is well inside one FWHM.
if drift[1] > DEVICE_CALIB[1] * 0.01:
print(" WARNING: calibration differs noticeably from this device's "
f"({', '.join(f'{c:g}' for c in DEVICE_CALIB)}).")
print(" The reference channels will not line up in energy; "
"subtracting it would remove counts from the wrong places.")
if duration < 60:
print(" WARNING: shorter than a minute - a noisy reference adds noise "
"to every measurement it is subtracted from.")
print(f"\nCopy {out_path} to <SD>/background/{slot}.csv, then on the device:")
print(" Data screen -> BKG LIBRARY -> MODE: LOAD -> tap " + slot)
return 0
if __name__ == "__main__":
sys.exit(main())