← Eggert Gudmundsson Journal

Working journal

Dated notes from the bench — what I'm working on, what it showed, and where it went wrong. This is the Grid-EYE thermal-sensor thread of a personal project around remote environmental sensing, built on the nRF9151. Rough by design; a working phase, not finished things.

The tidied-up results live under Projects.

2026-09-29

BLE bridge: a scheduling bug, a host-side leak, and the real rate ceiling

Continued the wired nRF9151 → PAN1781 → BLE relay path from the previous session, this time chasing down why raising the Grid-EYE commissioning rate kept breaking the link. First found a scheduling bug on the nRF9151 side: the firmware re-armed its next capture as “now + period” after a blocking I²C read and UART send, so the two costs stacked on top of the requested period every cycle — a requested 100 ms rate was landing at ~150 ms on the wire, confirmed directly on the scope. Fixed by anchoring to a running tick target instead of re-arming off “now” each time.

A ~4h09m unattended overnight run at 200 ms surfaced a second, separate bug: the host-side viewer app had an unbounded console widget that grew to 4.3 GB resident and got killed outright by the kernel's OOM killer. One missing setMaximumBlockCount() call. While in there, also added manual min/max color-scale controls to the Grid-EYE and LiDAR live views — the auto-ranging had been renormalizing the color map to each frame's own min/max, which quietly makes frame-to-frame comparison meaningless.

The main find was on the PAN1781 bridge itself. Fast commissioning rates (20 ms and below) reliably crashed the link within seconds, and it looked at first like a bandwidth problem — it wasn't. Real measured throughput at every rate tested was under 1.5 kB/s, nowhere near the link's own ~12 kB/s isolated ceiling. The actual constraint was packet count per frame against the BLE connection interval's fixed drain capacity: each ~140-byte frame needed about three small BLE packets at the bridge's old 60-byte chunk size, and a fixed 1024-byte firmware heap had no bound on how many of those packets could queue up if the drain ever fell behind — which is exactly what a fast commissioning rate did. Added a bounded, drop-oldest queue as a safety net first (turns an unbounded-queue crash into graceful data loss, nothing more), then went further: resized the relay buffer, BLE MTU, and Data Length Extension to fit one whole Grid-EYE frame into a single BLE packet instead of three, and grew the firmware's heap using RAM already freed by trimming unused logging and security features earlier in the project.

Result: a 20 ms commissioning rate that previously crashed the link within 17–48 seconds, every time, now runs clean — 0.02% checksum failure rate, steady heap, no drops. Treated this explicitly as a chance to actually learn the BLE link mechanics properly (connection intervals, PHY, MTU vs. Data Length Extension, packet count vs. raw bandwidth) rather than just patch the symptom, since the same knobs will matter again on whatever comes after this project.

2026-09-19

LiDAR: first working bring-up, and what it took to get there

cal tof init then cal tof raw now run cleanly on real hardware — firmware upload, resolution set, ranging start, and a real 8×8 distance frame, over the same I²C bus the Grid-EYE already shares. The first frame was physically coherent on sight: columns 0–4 read a consistent ~1.8 m (the ceiling above the bench), column 7 read ~550 mm (a nearby shelf edge), with a clean diagonal transition between the two — exactly the bench scene in front of the sensor.

Two panels of the same 8x8 distance frame. Left: a heatmap, columns 0-3 a uniform dark blue near 1750-1800mm (the ceiling), column 7 pale at about 550mm (a nearby shelf edge), with a visible transition band between them. Right: the same data as a 3D bar plot viewed from the near side, short pale bars in front stepping up to a tall dark block behind.
The first clean frame, read two ways — heatmap for the exact per-zone number, a 3D block plot (bar height = measured distance) for how the scene actually looks. Settled on keeping both after a direct side-by-side check showed neither alone was enough. Click for full size.

Two real software bugs stood between “wired up” and this, both found by methodical isolation — a second, never-stressed board failed identically, which is what pointed at software rather than a damaged part. First, lidar_is_alive() never set the device address before calling into the ULD driver, so a standalone liveness check (before init had run) silently addressed the I²C general-call address instead of the sensor, producing a NACK indistinguishable from a dead chip. Second, the ULD driver's multi-byte register write hit the nRF TWIM driver's internal concatenation-buffer limit on anything over 16 bytes — our write chunks run up to 512 — which the driver itself reported as an ENOSPC naming the exact devicetree property. Fixed by building the combined write buffer locally and sending it as one plain write instead of relying on driver-level message concatenation.

On the electrical side, connecting the sensor to the shared bus produced a sustained ~14 MHz oscillation on both SDA and SCL, swinging well past the 1.8 V bus rail. Root cause: the board's onboard level translator uses edge-triggered, auto-direction sensing, which is a known bad combination with an already RC-limited, weak-pullup open-drain bus — the translator's own direction logic was reacting to the bus's own slow charge curve. A 100 Ω series resistor between the shared bus and the translator settled it completely, with negligible voltage drop at these pullup values.

The most interesting bug wasn't electrical at all. Even after the wiring was solid, ranging failed intermittently in a way that survived a full reinit and a power cycle — both of which rule out corrupted device state, so the cause had to be elsewhere. It was a missing bus-arbitration lock: the Grid-EYE's periodic capture (main thread, on a wall-clock timer) and the LiDAR's own multi-step ranging sequence (shell thread) could interleave, since Zephyr only serializes individual low-level transfers, not each device's operation as a whole. A capture landing mid-sequence in the LiDAR's start/wait/read/stop chain was enough to break it. Fixed with one shared mutex held for each device's entire I²C-touching operation, not just around each transfer — validated by computing the next collision window and firing continuous ranging calls straight through it: 50 of 50 succeeded. Worth remembering generally: sharing a bus needs a whole-operation lock, not just per-transfer safety, and that requirement will come back when the SPI bus picks up a second device.

First Bring-Up →Window Transmission →

2026-09-03

The window material, and how not to scratch it

The infrared window is confirmed: a UQG Optics ZSW-132 — Ø13 mm by 2 mm, multispectral zinc sulphide, uncoated. Its datasheet thermal conductivity is 27 W/m·K, so the temperature difference across the 2 mm thickness works out to a few thousandths of a degree under any realistic heat flow. The window is effectively one temperature front to back, which means a single sensor bonded anywhere on it reads the whole part. That closes a question from the window calibration work — one PT1000 on the edge is enough.

Multispectral ZnS is soft (Knoop hardness 160, about the same as calcium fluoride) and marks easily, so I wrote up handling and cleaning notes for myself: blow the grit off first, drop-and-drag with spectroscopic-grade methanol or acetone, never a dry wipe, no ultrasonic bath. A fine PT1000 is glued to the window edge now with thermal adhesive, curing overnight.

2026-09-02

Lifting the window; and the sensor's own warmth

Re-ran the window warm-up with the sensor and window raised 35 mm clear of the insulation chimney, to test whether the window would then settle at the sensor's own temperature. It did not. Backing the window temperature out of the radiometric model, it moved from about 7 °C (sitting down in the cold air pool) to about 18 °C (room air) — not to the ~26 °C die. The window tracks whatever air is around it. The bench can't reproduce “window equals sensor temperature”; that has to come from the sealed enclosure, not the optics.

Left: apparent-temperature error versus scene for three runs, no window, window in the cold pool, window elevated. Right: the window effect versus the no-window baseline for both window runs, crossing zero at different scene temperatures.
Three runs compared. Raising the window (red) moves where its effect crosses zero from ~7 °C to ~18 °C — toward room air, not toward the sensor. Click for full size.

Separately, measured the Grid-EYE's own self-heating. Running continuously it sits about 2 °C above ambient, with a package thermal time constant near 2.5 minutes. Duty-cycling the capture to once every ten minutes with the sensor asleep in between should bring that down to a few hundredths of a degree.

Also spent time on housekeeping — pulled the bench runs into dated experiment folders and archived the server tables before they grow further.

Window Calibration →

2026-09-01

What the window run actually measured

Worked through the window warm-up data. The window shifts the apparent temperature by a scene-dependent amount — roughly +0.4 °C when the scene is cold, −1.1 °C near ambient, crossing zero around 9–10 °C. A pure transmission loss can't do that; a cold window with a fixed transmission would push every reading the same direction. The window was running at the chimney's cold-air-pool temperature, not the sensor's, so one warm-up only pins the combined term (1−τ)·a ≈ 0.13, not the transmission on its own.

Read into the thermography literature and found the FLIR technical publication and an InfraMation paper describe exactly this method and exactly this dead end — the transmittance correction goes indeterminate as the window and target temperatures converge. Added a literature list to the project pages and worked out the accuracy budget for an uncompensated window.

Left: object-temperature error versus window temperature for targets at 5, 10, 15 and 20 C, four parallel lines each crossing zero at its own target. Right: the residual after correcting to a fixed assumed window temperature, one line reaching plus or minus 1 C at plus or minus 8 C of drift.
What an uncompensated window temperature costs. Each target reads true only when the window happens to sit at that temperature; correcting to a fixed assumed value re-centres the error but barely changes the slope. Click for full size.

Window Calibration →Literature →

2026-08-31

Infrared window, first controlled run

Set up the window warm-up: the same cold-steel-block-warming-to-ambient sweep as the flat-field work, but with the ZnS window glued to the front of the Grid-EYE and the same PT1000 unmoved on the block as the shared reference. Four hours, automated, logging block temperature against the sensor frames.

2026-08-30

Flat-field study, and a correction to the geometry

Published the pixel-to-pixel uniformity write-up. The array's raw spread is about 2.9 °C peak-to-peak. A per-pixel offset table removes more than half of it; adding a per-pixel gain term takes the residual down to the sensor's temporal noise floor with no spatial structure left. The block target is isothermal to well below the measurement resolution, so the pattern is the sensor, not the rig.

Left: 64 pixel temperature traces against the PT1000 reference over the sweep. Right: each pixel's deviation from the array mean versus scene temperature, near-constant across the range.
All 64 pixels tracking the reference, and how far each sits from the array mean — the deviations barely change across the whole sweep, which is what makes a fixed correction table work. Click for full size.
8 by 8 heatmap of each pixel's deviation from the array mean in degrees Celsius, run-averaged, showing a diagonal gradient, isolated hot and cold pixels, and larger deviations toward the array edges.
The actual pixel data — each pixel's run-averaged offset from the array mean. A diagonal gradient, a few isolated outliers, and it grows toward the edges, consistent with lens vignetting. This is the correction table. Click for full size.

Also corrected a mistake I'd been carrying: the bench sensor is the narrow-angle AMG8854M01 with a 35.6° field of view, not the 60° part I'd assumed. That changes the footprint on the target to 0.643·d and, usefully, means an earlier “outer-pixel rig artefact” reading was really just the sensor's own pattern. Revised the pages.

Sketched a production calibration approach — a Peltier plate at three setpoints, an on-device least-squares fit, coefficients written to flash.

Flat-Field Uniformity Study →Production Calibration →

2026-08-28–29

Ground truth

First real check of the thermal channel against an independent reference: a PT1000 on the bench block, read by an Agilent 34401A, against the Grid-EYE frame. Once the PT1000 was on the same surface the sensor actually images — not tucked behind a protective layer — agreement landed at 0.5–1.3 °C, inside the sensor's datasheet accuracy. Started an automated warm-up sweep to get a full curve rather than a single point.

2026-08-22–23

Field test plan and bench rigs

Wrote up a minimal real-world test — press a button, capture a frame, send it over CoAP, store it, watch it appear on a web page — deliberately stripped of everything that wasn't the core path. Started the enclosure CAD and a two-zone contrast rig: a Peltier-driven target on an isothermal plate, for measuring apparent-temperature error against range and window state.

2026-08-17–21

Porting to the production module, and a counterfeit debugger

Began porting the working Grid-EYE firmware to the LooUQ MTC2-N9151 — the module intended for the actual product, pre-certified for NB-IoT and satellite. The first J-Link I bought for the bring-up turned out to be counterfeit: it fails SEGGER's own anti-counterfeit handshake, which was clean to confirm by running it side by side against the dev kit's genuine onboard debugger. Ordered a real one. Also found on paper that the Grid-EYE's I²C lines are swapped between the two boards — caught before the first flash, not after.

2026-08-14–16

Grid-EYE bring-up

Got the Panasonic Grid-EYE reading over I²C on the nRF9151 — all 64 pixels plus the onboard thermistor, decoded from the sign-magnitude register format and checked against the datasheet's worked examples, with the array orientation mapped. Forked it out of the CoAP debug harness into its own project. Added sensor sleep and wake control with the documented power-up sequence, and moved the capture timing into one place so a duty-cycle policy has somewhere to live.

Written by Eggert Gudmundsson. Updated 2026-09-29.