From 8aae6d4003a70d7061becdca193ea8e178df4dca Mon Sep 17 00:00:00 2001 From: John Poole Date: Thu, 16 Apr 2026 10:08:15 -0700 Subject: [PATCH] previously meant to include this exercise, too --- exercises/22_compass/README.md | 218 ++++++++++++++++++ exercises/22_compass/declination_manual.txt | 1 + exercises/22_compass/platformio.ini | 123 ++++++++++ .../22_compass/scripts/fetch_declination.pl | 121 ++++++++++ .../22_compass/scripts/set_build_epoch.py | 12 + 5 files changed, 475 insertions(+) create mode 100644 exercises/22_compass/README.md create mode 100644 exercises/22_compass/declination_manual.txt create mode 100644 exercises/22_compass/platformio.ini create mode 100644 exercises/22_compass/scripts/fetch_declination.pl create mode 100644 exercises/22_compass/scripts/set_build_epoch.py diff --git a/exercises/22_compass/README.md b/exercises/22_compass/README.md new file mode 100644 index 0000000..4ab1bdb --- /dev/null +++ b/exercises/22_compass/README.md @@ -0,0 +1,218 @@ +# Exercise 22: Compass / Magnetometer Executive Summary + +This exercise will target the T-Beam Supreme magnetometer and build toward a real-time compass / field monitor. The manufacturer examples reviewed were: + +- `QMC6310_CalibrateExample` +- `QMC6310_CompassExample` +- `QMC6310_GetDataExample` +- `QMC6310_GetPolarExample` + +## What The Sensor Is Useful For + +The QMC6310-class sensor is a 3-axis magnetometer. In practical terms, it is useful for: + +- Magnetic heading estimation: a compass that points toward magnetic north. +- Relative orientation work: detecting yaw changes and turn direction. +- Magnetic field observation: watching X/Y/Z field components and total field strength change in real time. +- Sensor fusion: combining magnetometer data with the IMU to stabilize heading over time. +- Hardware assay: identifying board population differences by probing the magnetometer address and response. + +It is not a complete navigation solution by itself. A magnetometer is sensitive to nearby ferrous metal, current-carrying wires, speakers, magnets, and board-level bias. For good heading, it needs calibration and a local magnetic declination correction if the goal is true north rather than magnetic north. + +## What Each Example Actually Does + +### `QMC6310_GetDataExample` + +This is the basic bring-up example. It: + +- Uses `setupBoards()` and the board I2C scan to discover the magnetometer address. +- Initializes the sensor. +- Configures continuous measurement mode. +- Prints compensated values and raw values for X/Y/Z to serial. + +This is the best starting point for an exercise whose first goal is "show me live data." + +### `QMC6310_GetPolarExample` + +This example is the smallest heading-oriented demo. It: + +- Reads magnetometer data. +- Converts the reading to a polar heading. +- Applies a declination correction. +- Prints heading, Gauss, and microtesla values. + +This is useful once raw axis data is already trusted. + +### `QMC6310_CompassExample` + +This is a user-interface example. It: + +- Reads X/Y values. +- Computes a heading with a hard-coded declination. +- Draws a compass arrow on the OLED. +- Also emits debug values on serial. + +This demonstrates a real-time visual display, but it is still only as good as the underlying calibration. + +### `QMC6310_CalibrateExample` + +This example computes hard-iron offsets by watching the min/max raw values while the board is slowly rotated. It then applies: + +- `x_offset = (x_max + x_min) / 2` +- `y_offset = (y_max + y_min) / 2` +- `z_offset = (z_max + z_min) / 2` + +Those offsets are passed to `qmc.setOffset(...)`, after which subsequent readings are shifted by those values. + +This is a runtime calibration aid, not a persistent factory calibration workflow. + +## Calibration: Does It Need To Happen Every Boot? + +Short answer: no, not if you save the offsets somewhere and re-apply them at boot. + +Important detail from the local driver: + +- `setOffset(...)` only stores offsets in the driver object's RAM. +- The offsets are not written into nonvolatile sensor storage. +- `begin(...)` calls `initImpl(...)`, and `initImpl(...)` performs `reset()`. + +Implications: + +- A normal reboot loses the offsets unless your firmware stores them elsewhere. +- Removing main power loses them. +- Keeping a battery attached does not make these software offsets persist in the magnetometer. +- If the board environment has not changed, you do not need to physically recalibrate at every boot, but you do need to reload previously measured offsets from persistent storage. + +Recommended practice for this exercise: + +- Add a one-time calibration mode. +- Save the resulting offsets in persistent storage on the ESP32. +- Re-apply offsets automatically at startup. +- Re-run calibration only when the enclosure, mounting, nearby wiring, or magnetic environment changes. + +## Declination Offset + +Declination is separate from magnetometer calibration. + +- Calibration removes local sensor bias and board-level hard-iron offset. +- Declination converts magnetic north into true north for a specific latitude, longitude, and date. + +So yes: if you want true heading, the user should specify a coordinate, obtain the current declination for that coordinate, and store that value with provenance. + +For this exercise, the practical design is: + +1. Choose the operating coordinates. +2. Obtain declination on the host, not on the ESP32. +3. Save the fetched value in a simple text file such as `declination.txt`. +4. Load that value into firmware at build time or into ESP32 persistent storage at provisioning time. +5. Re-fetch when the unit is moved a meaningful distance or after enough time has passed that the stored date is stale. + +Why host-side instead of device-side: + +- The ESP32 firmware should not depend on live network access to a geomagnetic service. +- Declination changes slowly, so caching is appropriate. +- A text file with comments gives you traceability for how the value was obtained. + +Recommended file shape: + +```text +# procured: April 16, 2026 +# for coordinates: 44.93642012667761, -123.02203699545396 +# source: NOAA/NCEI geomagnetic declination calculator +# sign convention: east positive, west negative +declination_deg=14.47 +``` + +For your current manually captured value: + +```text +2026-04-16 14.47° E ± 0.36° changing by 0.11° W per year +``` + +the appropriate build-time value is: + +```text +MAG_DECLINATION_DEG = 14.47 +``` + +using the convention: + +- East declination is positive. +- West declination is negative. + +That convention should be written directly into the firmware and README so there is no hidden sign ambiguity. + +Operational guidance: + +- For a fixed station, one fetched value is usually sufficient and should simply be refreshed occasionally. +- For a mobile unit used across a region, a single fixed declination is still acceptable for an early exercise, but it should be understood as an approximation. +- For this project stage, storing one deployment-specific declination value is the right tradeoff. + +## Multi-Unit Assay For Seven Boards + +Lewis He's current note says T-Beam Supreme units may contain `QMC6310N`, `QMC6310U`, or `QMC6309`, each with a different I2C address. + +What the local LilyGo code currently supports: + +- `0x1C` -> treated as `QMC6310U` +- `0x3C` -> treated as `QMC6310N` after distinguishing it from the OLED at the same address family + +What is missing locally: + +- I do not see `QMC6309` support anywhere in the local clone. +- The referenced GitHub commit is not present in the local repository, so the current local examples cannot yet identify `QMC6309`. + +Practical assay recommendation for units `A` through `G`: + +1. Build a small probe sketch for a selected PlatformIO environment such as `cy`. +2. Run the same board scan that `setupBoards()` already performs. +3. Print the detected magnetometer I2C address. +4. Print `getChipID()`. +5. Record the result per unit label: `AMY`, `BOB`, `CY`, `DAN`, `ED`, `FLO`, `GUY`. + +Expected result set: + +- If the unit answers at `0x1C`, classify it as `QMC6310U`. +- If the unit answers at `0x3C`, classify it as `QMC6310N`. +- If a unit answers at some third address, that is the candidate path for `QMC6309`, but the local code will need to be extended to name it explicitly. + +## Recommended Design Direction For This Exercise + +The clean design is a real-time serial-first field monitor, with OLED support as a secondary display. + +Suggested output in real time: + +- Unit identifier, for example `CY` +- Detected magnetometer address +- Chip ID +- Build UTC tag +- Raw X/Y/Z +- Offset-corrected X/Y/Z +- Declination in degrees and its source date +- Heading in degrees +- Total field magnitude +- Calibration status + +Suggested workflow: + +1. Boot and identify the attached board and detected magnetometer address. +2. Load saved calibration offsets if present. +3. Stream readings continuously over serial for capture and inspection. +4. Optionally mirror heading and a simple arrow on the OLED. +5. Provide a serial command or compile-time mode to enter calibration. +6. Save newly computed offsets for future boots. +7. Apply stored declination before reporting true heading. + +## Bottom Line + +The examples are enough to establish that this sensor is suitable for a live heading and magnetic field display exercise. The only caution is that the example labeled "calibration" is session-local unless we explicitly persist the offsets. The first useful deliverable for this exercise should therefore be a per-unit magnetometer assay plus a serial real-time monitor, followed by persistent calibration storage and then an OLED compass view. + +## Local Helper Scripts + +Exercise 22 now includes: + +- `scripts/set_build_epoch.py` copied from Exercise 18 so the same unique build timestamp mechanism is available here. +- `scripts/fetch_declination.pl` to fetch a declination value on the host and write a provenance-rich `declination.txt`. +- `platformio.ini` scaffolded with the build-stamp hook and manual declination build defines. + +The NOAA fetch helper remains available, but given the current registration requirement, the recommended workflow is manual lookup plus a checked-in text record such as `declination_manual.txt`. diff --git a/exercises/22_compass/declination_manual.txt b/exercises/22_compass/declination_manual.txt new file mode 100644 index 0000000..3b81b15 --- /dev/null +++ b/exercises/22_compass/declination_manual.txt @@ -0,0 +1 @@ +2026-04-16 14.47° E ± 0.36° changing by 0.11° W per year diff --git a/exercises/22_compass/platformio.ini b/exercises/22_compass/platformio.ini new file mode 100644 index 0000000..6dad222 --- /dev/null +++ b/exercises/22_compass/platformio.ini @@ -0,0 +1,123 @@ +; 20260416 ChatGPT +; Exercise 22_compass + +[platformio] +default_envs = cy + +[env] +platform = espressif32 +framework = arduino +board = esp32-s3-devkitc-1 +board_build.partitions = default_8MB.csv +monitor_speed = 115200 +extra_scripts = pre:scripts/set_build_epoch.py +lib_deps = + Wire + olikraus/U8g2@^2.36.4 + lewisxhe/XPowersLib@0.3.3 + +build_flags = + -I ../../shared/boards + -I ../../external/microReticulum_Firmware + -D BOARD_MODEL=BOARD_TBEAM_S_V1 + -D OLED_SDA=17 + -D OLED_SCL=18 + -D OLED_ADDR=0x3C + -D GPS_RX_PIN=9 + -D GPS_TX_PIN=8 + -D GPS_WAKEUP_PIN=7 + -D GPS_1PPS_PIN=6 + -D GPS_L76K + -D NODE_SLOT_COUNT=7 + -D ARDUINO_USB_MODE=1 + -D ARDUINO_USB_CDC_ON_BOOT=1 + + ; Declination convention: + ; east = positive + ; west = negative + ; Manual source: + ; declination_manual.txt + ; 2026-04-16 14.47° E ±0.36° changing by 0.11° W per year + -D MAG_DECLINATION_DEG=14.47f + -D MAG_DECLINATION_SOURCE=\"manual_noaa_web_ui\" + -D MAG_DECLINATION_DATE=\"2026-04-16\" + -D MAG_DECLINATION_LAT=44.93642012667761 + -D MAG_DECLINATION_LON=-123.02203699545396 + +[env:amy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"AMY\" + -D NODE_LABEL=\"Amy\" + -D NODE_SHORT=\"A\" + -D NODE_SLOT_INDEX=0 + -D LOG_AP_IP_OCTET=23 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:bob] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"BOB\" + -D NODE_LABEL=\"Bob\" + -D NODE_SHORT=\"B\" + -D NODE_SLOT_INDEX=1 + -D LOG_AP_IP_OCTET=24 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:cy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"CY\" + -D NODE_LABEL=\"Cy\" + -D NODE_SHORT=\"C\" + -D NODE_SLOT_INDEX=2 + -D LOG_AP_IP_OCTET=25 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:dan] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"DAN\" + -D NODE_LABEL=\"Dan\" + -D NODE_SHORT=\"D\" + -D NODE_SLOT_INDEX=3 + -D LOG_AP_IP_OCTET=26 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:ed] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"ED\" + -D NODE_LABEL=\"Ed\" + -D NODE_SHORT=\"E\" + -D NODE_SLOT_INDEX=4 + -D LOG_AP_IP_OCTET=27 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:flo] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"FLO\" + -D NODE_LABEL=\"Flo\" + -D NODE_SHORT=\"F\" + -D NODE_SLOT_INDEX=5 + -D LOG_AP_IP_OCTET=28 + -D GNSS_CHIP_NAME=\"L76K\" + +[env:guy] +extends = env +build_flags = + ${env.build_flags} + -D BOARD_ID=\"GUY\" + -D NODE_LABEL=\"Guy\" + -D NODE_SHORT=\"G\" + -D NODE_SLOT_INDEX=6 + -D LOG_AP_IP_OCTET=29 + -D GNSS_CHIP_NAME=\"MAX-M10S\" + -D GPS_UBLOX diff --git a/exercises/22_compass/scripts/fetch_declination.pl b/exercises/22_compass/scripts/fetch_declination.pl new file mode 100644 index 0000000..0893120 --- /dev/null +++ b/exercises/22_compass/scripts/fetch_declination.pl @@ -0,0 +1,121 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use HTTP::Tiny; +use POSIX qw(strftime); +use Text::ParseWords qw(parse_line); +use URI::Escape qw(uri_escape); + +my ($lat, $lon, $outfile, $iso_date) = @ARGV; + +die usage() unless defined $lat && defined $lon; + +$outfile ||= "declination.txt"; +$iso_date ||= strftime("%Y-%m-%d", gmtime()); + +die "Latitude must be numeric\n" unless $lat =~ /\A-?\d+(?:\.\d+)?\z/; +die "Longitude must be numeric\n" unless $lon =~ /\A-?\d+(?:\.\d+)?\z/; +die "Latitude out of range\n" unless $lat >= -90 && $lat <= 90; +die "Longitude out of range\n" unless $lon >= -180 && $lon <= 180; +die "Date must be YYYY-MM-DD\n" unless $iso_date =~ /\A(\d{4})-(\d{2})-(\d{2})\z/; + +my ($year, $month, $day) = ($1, $2, $3); + +my $base_url = "https://www.ngdc.noaa.gov/geomag-web/calculators/calculateDeclination"; +my %query = ( + lat1 => $lat, + lon1 => $lon, + model => "WMM", + startYear => $year, + startMonth => $month + 0, + startDay => $day + 0, + resultFormat => "csv", +); + +my $url = $base_url . "?" . join("&", + map { uri_escape($_) . "=" . uri_escape($query{$_}) } sort keys %query +); + +my $http = HTTP::Tiny->new( + agent => "microReticulum-ex22-declination-fetch/1.0", + timeout => 30, + verify_SSL => 1, +); + +my $res = $http->get($url); +die "HTTP request failed: $res->{status} $res->{reason}\n" unless $res->{success}; + +my $body = $res->{content}; +my $declination = extract_declination_from_csv($body); +$declination = extract_declination_from_text($body) unless defined $declination; + +die "Unable to parse declination from NOAA response\n" unless defined $declination; + +my $procured = format_procured_date($year, $month, $day); +open my $fh, ">", $outfile or die "Cannot write $outfile: $!\n"; +print {$fh} "# procured: $procured\n"; +print {$fh} "# for coordinates: $lat, $lon\n"; +print {$fh} "# source: NOAA/NCEI geomagnetic declination calculator\n"; +print {$fh} "# date: $iso_date\n"; +print {$fh} "# sign convention: east positive, west negative\n"; +printf {$fh} "declination_deg=%.6f\n", $declination; +close $fh or die "Cannot close $outfile: $!\n"; + +print "Wrote $outfile\n"; +printf "declination_deg=%.6f\n", $declination; + +sub extract_declination_from_csv { + my ($text) = @_; + my @lines = grep { /\S/ } split /\r?\n/, $text; + return undef unless @lines >= 2; + + my @header = parse_line(",", 0, $lines[0] // ""); + my $decl_idx; + for my $i (0 .. $#header) { + next unless defined $header[$i]; + if ($header[$i] =~ /\Adeclination\z/i) { + $decl_idx = $i; + last; + } + } + return undef unless defined $decl_idx; + + for my $line (@lines[1 .. $#lines]) { + my @fields = parse_line(",", 0, $line); + next unless defined $fields[$decl_idx]; + my $value = $fields[$decl_idx]; + $value =~ s/^\s+|\s+$//g; + $value =~ s/[^\d+.\-]//g; + return $value + 0 if $value =~ /\A[+-]?\d+(?:\.\d+)?\z/; + } + + return undef; +} + +sub extract_declination_from_text { + my ($text) = @_; + return $1 + 0 if $text =~ /declination[^-+0-9]*([+-]?\d+(?:\.\d+)?)/i; + return undef; +} + +sub format_procured_date { + my ($y, $m, $d) = @_; + my @month_names = qw( + January February March April May June + July August September October November December + ); + my $name = $month_names[$m - 1] // die "Invalid month\n"; + return sprintf("%s %d, %04d", $name, $d, $y); +} + +sub usage { + return <<"USAGE"; +Usage: + fetch_declination.pl [outfile] [YYYY-MM-DD] + +Example: + fetch_declination.pl 44.93642012667761 -123.02203699545396 declination.txt 2026-04-16 +USAGE +} diff --git a/exercises/22_compass/scripts/set_build_epoch.py b/exercises/22_compass/scripts/set_build_epoch.py new file mode 100644 index 0000000..40ef7ca --- /dev/null +++ b/exercises/22_compass/scripts/set_build_epoch.py @@ -0,0 +1,12 @@ +import time +Import("env") + +epoch = int(time.time()) +utc_tag = time.strftime("%Y%m%d_%H%M%S_z", time.gmtime(epoch)) + +env.Append( + CPPDEFINES=[ + ("FW_BUILD_EPOCH", str(epoch)), + ("FW_BUILD_UTC", '"%s"' % utc_tag), + ] +)