previously meant to include this exercise, too

This commit is contained in:
John Poole 2026-04-16 10:08:15 -07:00
commit 8aae6d4003
5 changed files with 475 additions and 0 deletions

View file

@ -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`.

View file

@ -0,0 +1 @@
2026-04-16 14.47° E ± 0.36° changing by 0.11° W per year

View file

@ -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

View file

@ -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 <latitude> <longitude> [outfile] [YYYY-MM-DD]
Example:
fetch_declination.pl 44.93642012667761 -123.02203699545396 declination.txt 2026-04-16
USAGE
}

View file

@ -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),
]
)