Reference Python scripts for capturing raw data from MAPIR LATTICE cameras and DAQ light sensors with your own computer, then processing it in Chloros afterward.
These are deliberately small, dependency-light, and heavily commented — meant to be read, copied, and adapted (including as a reference for AI-assisted coding) for DIY drone and research setups.
The idea: you capture raw data in the field; Chloros calibrates and processes it later. The scripts apply no processing and no calibration. They stamp each file with the device's serial number and model, and on import Chloros fetches that exact device's factory calibration from the cloud and applies it. Capture is yours; the science is handled at import.
And Chloros hands the result back: every
.daqyou import is written out again, calibrated, as a.daqand a.csvof spectral irradiance — see Light sensor only. That works for DAQ-U, DAQ-M and DAQ-E alike, and needs no camera.
| File | Purpose |
|---|---|
capture_lattice.py |
Control + raw capture from LATTICE cameras (M3C/M3M), with hardware-cable multi-camera sync |
record_daq.py |
Record raw spectra from a DAQ-U / DAQ-M / DAQ-E to a Chloros-compatible .daq |
daq_stream.py |
Listen to any number of DAQ-E sensors over multicast (raw or calibrated) |
daq_cal.py |
Apply a DAQ-E's onboard factory calibration offline — no cloud, no Chloros |
mapir_metadata.py |
The Chloros ingest contract: writes raw LATTICE TIFFs + the .daq SQLite format |
selftest.py |
Self-contained checks that the output matches what Chloros reads on import |
requirements.txt |
Dependencies |
Cross-platform: Windows, Linux x86-64, and Linux arm64 (NVIDIA Jetson, Raspberry Pi).
python -m pip install -r requirements.txt- DAQ-U (USB serial) needs
pyserial. On Linux add yourself to thedialoutgroup for serial access:sudo usermod -aG dialout $USER(re-login). - DAQ-M (Bluetooth LE) needs
bleak. On Linux it uses BlueZ (sudo apt install bluez); Jetson/Raspberry Pi work out of the box. - DAQ-E (Ethernet) needs nothing beyond the standard library.
- Running
selftest.pyadditionally needstifffile(pip install tifffile) — only to read back TIFFs the way Chloros does; the capture scripts don't need it.
capture_lattice.py talks to the cameras through the Arena SDK and its
arena_api Python wrapper. arena_api is not installable from PyPI on its
own — install the native Arena SDK for your platform first (Windows, and Linux
x86-64 / arm64 including Jetson and Raspberry Pi builds), then its Python
package. Put the host NIC on the cameras' subnet and enable jumbo frames if your
switch supports them. record_daq.py does not need the Arena SDK.
These scripts do no image processing — they just move raw data to disk — so CPU load is low. What matters is the device interface, a bit of RAM, and (for cameras) write speed. The two scripts have very different needs.
DAQ recording (record_daq.py) — tiny footprint: a few hundred small
readings per second, parsed and written to SQLite.
| Recommendation | |
|---|---|
| Minimum | Python 3.8+, ~256 MB free RAM, and the sensor's interface (USB for DAQ-U, Bluetooth LE for DAQ-M, Ethernet for DAQ-E). A Raspberry Pi Zero 2 W handles it. |
| Ideal | Any Raspberry Pi 4 / 5 or Jetson — far more than enough. |
Camera capture (capture_lattice.py) — the demanding one. The Arena SDK
sets a hard floor: it ships only for 64-bit ARM (aarch64) and x86-64 (no
32-bit / ARMv6 / ARMv7 build), and the cameras are wired Gigabit Ethernet.
| Recommendation | |
|---|---|
| Minimum | A 64-bit OS on 64-bit ARM or x86-64, Gigabit Ethernet (onboard, or a USB-to-Gigabit adapter — available for essentially every platform; use a USB 3 port for full bandwidth, since USB 2.0 caps ~480 Mbps), ~1 GB free RAM for one camera (budget ≈70 MB more per additional camera for frame buffers). Board floor: Raspberry Pi 4 (2 GB+) on 64-bit Raspberry Pi OS / Ubuntu. A microSD card is fine for a single camera at low frame rates. |
| Ideal | NVIDIA Jetson Orin Nano / NX / AGX (or an x86-64 mini-PC), 4 GB+ RAM, with an SSD (USB 3 / NVMe). Arrays need the SSD: frames are uncompressed (~6.3 MB each), so e.g. 5 cameras at 2 fps is ~60 MB/s of sustained writes a microSD card can't keep up with. |
A Raspberry Pi Zero cannot run the cameras — it's ARMv6, which the Arena SDK doesn't support. (A board without onboard Ethernet can always add it with a USB-Gigabit adapter, but that doesn't get around the ARMv6 limitation.) The smallest practical camera host is a Raspberry Pi 4 on a 64-bit OS; a Jetson is the smoothest ARM path and matches what most users already fly. Storage and network bandwidth both scale with camera count × frame rate, so step up to a Jetson or x86-64 host for larger arrays.
# single camera, auto-exposure, 50 frames
python capture_lattice.py --frames 50
# multi-camera array, HARDWARE cable sync, pick the master by serial
python capture_lattice.py --sync cable --master 213602328 --interval 1.0
# fixed 5 ms exposure, run until Ctrl-C
python capture_lattice.py --exposure-us 5000| Option | Meaning | Default |
|---|---|---|
--sync cable|software |
cable = hardware M8 sync; software = single-cam / no-cable |
cable |
--master SERIAL |
master camera for cable sync | lowest serial |
--serials A,B,… |
use only these cameras | all connected |
--model STR |
model to fall back to when a camera's DeviceUserID is empty (factory reset) |
read from the camera |
--exposure-us N |
fixed exposure (µs) | auto-exposure |
--frames N / --duration S / --interval S |
stop after N shots / S seconds / wait S between shots | until Ctrl-C |
--output-dir DIR |
where to write TIFFs | . |
Hardware sync. With --sync cable, the master camera is software-triggered
and drives an ExposureActive pulse out on Line2; every slave triggers off that
edge over the MAPIR M8 sync cable (pin 2 → Line2). That gives sub-frame,
simultaneous exposure across the array with no PTP — fine for a single cabled
rig. (Syncing cameras that aren't cabled together, or measuring the exact
skew, is what PTP is for; that's not implemented here.) Frames are saved
uncompressed (~6.3 MB each) so the required EXIF survives; Chloros debayers and
calibrates on import.
# DAQ-U over USB serial
python record_daq.py u --port COM7 # Windows
python record_daq.py u --port /dev/ttyUSB0 # Linux / Jetson / Pi
# DAQ-M over Bluetooth LE (the sensor's BLE address)
python record_daq.py m --mac AA:BB:CC:DD:EE:FF
# DAQ-E over Ethernet (the sensor's IP)
python record_daq.py e --host 192.168.1.50Common options:
| Option | Meaning | Default |
|---|---|---|
--integration-time MS |
integration time per reading (ms) | 32 |
--frame-avg N |
frames averaged per reading | 3 |
--no-ae |
disable auto-exposure (use fixed integration time) | AE on |
--frames N |
stop after N readings | until Ctrl-C |
--duration S |
stop after S seconds | until Ctrl-C |
--output PATH |
output .daq path |
<kind>_<timestamp>.daq |
Press Ctrl-C to stop. The script records continuously; mount the sensor upward-facing (downwelling) and run it for the whole flight.
Plenty of setups are just a light sensor: no camera, no imagery, no reflectance — you want spectral irradiance, PPFD or lux over time, calibrated. That is a first-class workflow, and a project with no images in it is a valid project. Capture raw in the field, then let Chloros calibrate:
python record_daq.py u --port COM7 --duration 3600Then take the .daq files to any one of:
- Desktop app — make a project, drag the
.daqfiles in, press Process. Nothing camera-specific to configure. - CLI —
chloros-cli process ./my_daq_files -o ./my_project - SDK — see below.
Chloros fetches each sensor's factory calibration by serial (local cache
first, then the MAPIR cloud), applies it, and writes two products per recording
into a Light Sensor folder inside the project:
<project>/
└── Light Sensor/
├── u_20260826_143012_calibrated.daq # reprocessable archive
└── u_20260826_143012_calibrated.csv # W/m²/nm + photometrics
The .csv is one row per reading:
| Columns | |
|---|---|
timestamp_utc, timestamp_ns |
absolute UTC epoch of the reading |
integration_time_ms, calibrated |
acquisition + per-frame calibration flag |
total_power_W_m2 |
integrated irradiance |
photopic_lux, scotopic_lux |
photometric illuminance |
ppfd_umol_m2_s, ppfd_blue, ppfd_green, ppfd_red |
PAR photon flux, total and split |
peak_wavelength_nm |
spectral peak |
340.0 … 1010.0 |
the full spectrum, W/m²/nm, 135 points at 5 nm |
The .daq beside it is the same SQLite format these scripts write, now carrying
calibrated spectra and declaring the bundle that produced them — so re-importing
it does not calibrate it a second time.
From the SDK:
import chloros_sdk
with chloros_sdk.ChlorosLocal() as cl:
cl.create_project("DAQ-U_2026-08-26")
cl.import_images("./my_daq_files") # .daq files; no imagery needed
result = cl.export_light_sensor()
for rec in result["exported"]:
print(rec["csv"])
for rec in result["skipped"]:
print("skipped", rec["source"], "--", rec["reason"])The CLI and SDK need a paid Chloros+ plan (enforced server-side). The desktop app route does not — it works on the free tier.
A recording whose calibration can't be fetched is skipped, not faked. If you are offline, or that serial has no calibration on file, Chloros reports the recording under
skippedwith the reason the file itself gives — read off the recording rather than assumed — and writes nothing for it, rather than emitting a file named*_calibrated.csvthat holds raw counts. Reconnect, re-run, and it completes.
A family with no bundle at all exports as raw, under a _raw name. The
DAQ-A predates the DAQ-U/M/E bundle system and there is no bundle to fetch
for one, ever — but it is still flown with a reflectance target and a Survey3,
and Chloros calibrates with it, because only relative response matters there.
So those recordings export as what they are: <stem>_raw.daq / <stem>_raw.csv
rather than _calibrated (a different filename, not a flag inside the file, so
the claim survives being emailed on as a bare name), a CSV header reading raw spectral sensor counts (NOT irradiance), and a run summary saying exported (N calibrated, M as RAW COUNTS). The power-dependent photometrics stay NULL and
calibration_applied stays 0, so re-importing one cannot double-apply
anything. An unknown or blank model is not assumed bundle-less — erring
that way would turn a real fetch failure into a silent raw export.
Legacy recordings export too. A .daq on the old v1.01 / v1.02 schema has no
precise_timestamp column, only the row write time. The downwelling matcher
still refuses those, correctly — matching imagery needs a per-exposure epoch —
but the exporter reads them on the created_on axis and says so in the product,
via clock=daq_created_on in the CSV header.
Your original raw .daq is never modified; the products are written alongside
it. Keep the raw one — it is the master, and stays re-calibratable against a
future coefficient revision.
A DAQ-E on firmware 1.7.0+ emits two spectral streams on separate multicast groups: raw counts (always) and calibrated W/m²/nm (once the device carries coefficients). Older firmware emits raw only.
Raw is always the reprocessable one: it is the sensor's firmware output byte for byte, so a recording made from it can be re-calibrated later against a revised bundle. Prefer it for anything you intend to keep.
| Channel | Wire | Content | These scripts |
|---|---|---|---|
| Raw, unicast | TCP 5000 |
raw counts, one client at a time | ✅ record_daq.py |
| Raw, multicast | UDP 239.10.10.10:5002 |
raw counts, any number of listeners | ✅ daq_stream.py |
| Calibrated, multicast | UDP 239.10.10.11:5003 |
W/m²/nm, when the device carries coefficients | ✅ daq_stream.py --calibrated |
| Control | TCP 5001 |
JSON: config, status, bundle/profile/cert | ✅ daq_cal.py |
Full datagram layout in PROTOCOL.md.
What you can record, and to what.
| Stream | .daq |
.csv |
How |
|---|---|---|---|
| Raw spectra | ✅ | ✅ | record_daq.py <k> --csv (any model) or daq_stream.py --daq --csv (DAQ-E) |
| Calibrated spectra | ✅ | ✅ | daq_stream.py --calibrated --daq --csv, or record_daq.py e --calibrate bake/csv |
A calibrated recording is stamped as calibrated (calibration_applied = 1)
from the frame's own flag bit, not from which group you joined — so Chloros
imports it as-is instead of applying its bundle a second time.
Raw remains the reprocessable master either way: a coefficient revision reaches every raw recording you kept and none of the device-calibrated ones.
Validate on the declared length, not an exact size. Firmware is free to
append bytes after the CRC, and a reader that insists on one exact datagram size
rejects every frame such a unit sends, counts them as malformed, and shows the
sensor as absent while it streams perfectly. daq_stream.py tolerates trailing
bytes; the layout lives in
PROTOCOL.md.
Chloros reads the raw stream, not the calibrated one. It subscribes only to
239.10.10.10:5002 and applies the bundle host-side, from its own cloud
cache — and it drops a calibrated frame arriving on the raw group as a firmware
bug rather than feeding W/m²/nm into a path expecting counts. Two consequences
worth knowing:
- The calibrated stream is for third-party consumers — anything that can't
carry the calibration machinery, which is what
daq_stream.py --calibratedis for. It is not the path Chloros processes, so a difference between it and a Chloros product is a stale on-device profile, not a Chloros bug. - Chloros always re-derives from raw, which is why raw is the reprocessable one: a coefficient revision reaches every raw recording you kept, and none of the device-calibrated ones.
python daq_stream.py # every sensor on the network
python daq_stream.py --calibrated # W/m²/nm straight off the device
python daq_stream.py --serial 11-22-33-44-55 --csv out.csvMulticast, so any number of consumers can read the same sensor and any number
of sensors can share a group. The TCP raw channel record_daq.py uses is
exclusive — one client — so this is the path for multi-sensor work and for
running alongside Chloros.
Sensor separation. Datagram v2 (firmware 1.7.0+) carries the sender's MAC, serial, model and per-frame integration time, so frames are self-describing and the script demultiplexes on identity.
Older firmware emits v1, which carries none of that: two v1 units on one group
are separable only by UDP source address, and a receiver that doesn't filter
reads a ~50/50 blend of both while looking perfectly healthy. That is not
hypothetical — it happened on hardware on 2026-07-14. daq_stream.py keys v1
frames on source IP and prints a warning, but the real fix is updating the
firmware.
Timestamps. timestamp_us is latched on the ESP32 as the sensor's last
byte arrives, so it excludes network and OS jitter. When a frame reports PTP
sync, clocks across sensors are disciplined to a common grandmaster (~50 µs on
this hardware) and frames from different units are directly comparable — that's
what makes multi-sensor and sensor-to-LATTICE alignment meaningful.
Every frame says which stream it came from. Raw counts and calibrated
W/m²/nm differ by roughly four orders of magnitude, and nothing about the
numbers themselves announces which you are holding. So the CSV carries a
calibrated column and a units column per row — taken from the frame's own
flag bit, not from the group the script joined — plus a # provenance line
naming the group and units at the top. If a frame's flag ever contradicts its
group (a firmware bug: the two groups are meant to be exclusive), the script
warns on stderr and records what actually arrived.
Two meanings of "calibrated". The --calibrated stream above is computed
by the device. --calibrate in record_daq.py (below) applies the bundle
locally in Python. Both run the same arithmetic — the device runs a
pre-folded version of it — so use whichever fits: the stream needs no
calibration machinery on your side, the local path works on any firmware.
They agree provided the profile document on the unit is current. The device
folds in whatever bundle and cap profile Chloros last pushed to it, so a unit
carrying a stale or wrong-cap document emits a plausible number that is out by
the whole geometry correction — up to ~11× for a sunshine cap. daq_cal.py <host> prints exactly what is aboard; see Caps.
A DAQ-E carries its own factory calibration bundle in flash. --calibrate
pulls it down over ethernet and applies it locally, so you get spectral
irradiance in W/m²/nm without an account, an internet connection, or a
Chloros install.
This is the air-gapped route, and it is DAQ-E only — the bundle has to be on the device. If you have Chloros, you do not need it: importing a raw
.daqproduces the same calibrated.csvfor any DAQ model, including DAQ-U and DAQ-M, which have no onboard bundle to read. See Light sensor only.
# .daq stays RAW; calibrated irradiance goes to a sibling .csv
python record_daq.py e --host 192.168.1.50 --calibrate csv
# just look at what the device is carrying (records nothing)
python daq_cal.py 192.168.1.50--calibrate |
.daq contents |
When to use |
|---|---|---|
off (default) |
raw counts | Recommended. Chloros calibrates at import and writes the .csv for you |
csv |
raw counts, plus a calibrated .csv |
you need numbers in the field, with no Chloros and no internet |
bake |
calibrated W/m²/nm, stamped with the bundle SHA | the recording must stand completely alone |
Prefer a raw .daq over bake in every case where you have a choice. A raw
recording can be re-calibrated later if a coefficient revision lands; a baked
one is frozen at whatever the bundle said the day you recorded, and no amount
of reprocessing gets that back.
csv and bake both still import fine. Chloros reads a baked recording as-is
rather than calibrating it twice, and re-exports it into Light Sensor/
alongside everything else — so a baked file loses the ability to be
re-calibrated, not the ability to be used.
A cosine corrector or FOV cone has its own per-wavelength correction, and even a bare DAQ-E has a geometry correction (the bare diffuser over-reads directional light ~2×, while a sunshine cap is near-ideal). None of that is in the calibration bundle — it lives in Chloros and is versioned separately.
Every recording declares a cap, and says who decided it. On DAQ-U /
DAQ-M / DAQ-E the sunshine corrector is removable, and nothing on the
sensor can sense whether it is fitted — so somebody has to say. record_daq.py
assumes sunshine_cosine, because that is how the large majority of units fly
and it is the same assumption Chloros makes. An assumption is fine; an
assumption that cannot be recognised later is not.
So the file records who decided the cap, in als_meta.cap_id_source:
| value | meaning |
|---|---|
auto_default |
assumed — nobody said, the fleet default was used |
operator |
you stated it with --cap-id |
device |
read back from the unit's own profile store |
model |
settled by the hardware — optics that are not removable leave no choice to make |
That distinction is what makes an assumed cap undoable. Chloros warns on
auto_default — naming the file — and an operator can override the cap per
project and get the corrected number back, because a raw recording has not had
the cap multiplied in yet. A file that could not say whether anyone checked
would leave a 20-30× error looking exactly like a verified one.
The cap is recorded as provenance — cap_applied = 0 — and Chloros applies
it at import.
Pass --cap-id none only for a sensor you have physically stripped.
Declaring bare on a capped unit is not "uncorrected", it is wrong by the whole
correction, and nothing downstream can detect it:
| Model | sunshine_cosine |
declaring none instead |
|---|---|---|
| DAQ-U | ×30.6 | ~30× low |
| DAQ-M | ×23.1 | ~23× low |
| DAQ-E | ×11.0 | ~22.6× low — on a DAQ-E none is an active ×0.49 bare-geometry profile, not a no-op |
record_daq.py prints the cap it declared on every run, and warns when that is
none.
A DAQ-E stores one resolved cap profile and folds it into the calibrated stream it publishes, so that store is what decides whether an offline consumer of that stream gets the truth. You can write it:
# see what the unit is currently applying
python daq_cal.py 192.168.1.50
# save the document it holds (the curve, verbatim)
python daq_cal.py 192.168.1.50 --save-profiles aboard.json
# apply NO per-wavelength profile at all -- needs no curve
python daq_cal.py 192.168.1.50 --set-cap as_recorded
# fit a different cap: supply that cap's curve, which Chloros ships
python daq_cal.py 192.168.1.50 --set-cap fov_45 --cap-profile /path/to/chloros/daq/cap_profiles/e/fov_45.jsonThe curve has to come from somewhere — the device applies what it is given and
carries no library to look one up in, so every id except as_recorded needs
its --cap-profile JSON. Chloros ships them at
daq/cap_profiles/<kind>/<cap_id>.json; copy the one you need. Asking for a
real cap without its curve is refused rather than written as bare.
Requires firmware 1.6.0+ (set_profiles). The document written is
byte-identical to the one Chloros builds for the same cap, so setting a cap
here does not make Chloros rewrite the unit's flash on its next connect.
Chloros still has the last word. It re-pushes its own resolved cap whenever it connects and finds a different document. If the cap must survive, set it in Chloros as well — this is for units that never meet a Chloros install, or for checking what one is carrying.
The profile curves themselves are authored in Chloros, not here. daq_cal.py <host> prints which cap is aboard.
| Situation | Result |
|---|---|
| Device carries the right profile | correct output, matches Chloros exactly |
| Device carries no profile | bare-uncorrected — ~2× off bare, ~11× off under a sunshine cap |
| Physical cap changed | re-push from Chloros; the scripts cannot substitute another cap's curve |
--cap-id means two different things depending on whether the run reads the
device:
- With
--calibrate(DAQ-E) it is an override of the profile the device carries, and the device carries exactly one — so the only accepted values areas_recorded(skip every per-wavelength profile) or that same cap. Naming a different one is refused: there is no local copy of its curve, and applying the stored one under another name is silently wrong by the sunshine-vs-bare factor. Validated at startup, not mid-recording. - Without
--calibrate(every raw recording, and the only mode DAQ-U / DAQ-M have) nothing is applied locally, so--cap-idsimply declares what is fitted, for Chloros to apply at import. Any id Chloros knows for that device kind is accepted —sunshine_cosine(the default),none,fov_15/45/90, andfov_30/60on DAQ-U.
The two flags Chloros reads are calibration_applied (are these W/m²/nm or
counts?) and cap_applied (is a cap correction already multiplied in?).
Between them they make every combination unambiguous, so nothing is ever
calibrated or capped twice:
| Run | calibration_applied |
cap_id |
cap_applied |
Chloros applies |
|---|---|---|---|---|
record_daq.py u/m/e (default) |
0 | sunshine_cosine |
0 | bundle + that cap |
... --cap-id none (stripped) |
0 | none |
0 | bundle + bare geometry (DAQ-E) or nothing (U/M) |
--calibrate csv |
0 | the device's cap | 0 | bundle + that cap — and the sibling .csv already has both |
--calibrate bake |
1 | the device's cap | 1 | nothing — imported as-is |
--calibrate bake --cap-id as_recorded |
1 | as_recorded |
0 | nothing; a later cap override can still be applied |
The last row is why cap_applied follows the cap actually handed to the
calibrator rather than merely whether the device had profiles: as_recorded
skips every profile, so stamping it as applied would claim a correction that
is not in the data — and Chloros would then refuse a later operator override
(it cannot undo a curve that was never applied) instead of correcting the file.
Use --require-profiles to refuse outright rather than log a plausible wrong
number when a cap is fitted but no profile is aboard:
python record_daq.py e --host 192.168.1.50 --calibrate csv --require-profilesUse daq_cal.py <host> to print exactly which correction chain a unit will
run — dark model, whether π is applied at runtime or baked into the gain, and
which cap profile (if any) is aboard. Programmatically:
from daq_cal import DeviceCalibration
cal = DeviceCalibration.from_device("192.168.1.50")
print(cal.describe())
watts = cal.apply(raw_counts, integration_time_ms=50)Always pass this frame's own integration_time_ms — auto-exposure moves it
between 1 and 500 ms and the dark model is a function of it.
- Serial number is the key. Each
.daq(and each LATTICE TIFF) carries the device serial. Chloros looks up that exact device's factory calibration in the cloud and applies it at import. Get the device powered and discoverable so the scripts can read its real serial. - Reflectance needs downwelling + a synced clock. Chloros matches a DAQ recording to imagery by timestamp. Record a DAQ during the flight and keep the host clock reasonably accurate (the scripts stamp absolute UTC time). With no DAQ you still get radiance, not reflectance.
- You get the calibration back, not just its effect. Importing a
.daqwrites<project>/Light Sensor/<name>_calibrated.daqand.csv— the calibrated spectra as a file, rather than only as an intermediate on the way to reflectance. No camera required; a light-sensor-only project is a valid project. See Light sensor only. - Timezone is declared, not guessed. Naive wall-clock stamps are ambiguous,
so the TIFFs carry EXIF
OffsetTimeOriginal = +00:00and the.daqcarriesals_meta.utc_offset_minutes = 0(schema v1.23) — the scripts stamp UTC everywhere, and say so. Chloros reads the declarations, so image↔DAQ matching works on any processing host with no 'Light sensor timezone offset' setting (the same contract the MAPIR CM5 hub stamps). If you adapt the scripts to stamp local time, update both declarations (seemapir_metadata.py). - Raw means raw. Spectra are the sensor's raw firmware output (no
calibration);
calibration_applied = 0tells Chloros to calibrate on import. The one exception is--calibrate bake, which setscalibration_applied = 1and carries the bundle SHA — Chloros then imports those spectra as-is instead of calibrating them a second time. That flag is the only thing standing between a calibrated file and being calibrated twice, so if you adaptmapir_metadata.pyto write your own calibrated recordings, set it — and setcalibration_bundle_shawith it, whichDaqWriterenforces. - Your raw file is never modified. Chloros writes derived products
alongside it and leaves the recording alone, so the raw
.daqremains the master copy. Archive that one.
- DAQ-E uses the JSON control channel (TCP 5001) to read the serial and the raw spectral channel (TCP 5000) to acquire; timestamps are host wall-clock.
- Multi-camera sync is hardware, over the M8 sync cables — the master drives the array off one trigger line for sub-frame simultaneous exposure. (Syncing cameras that aren't cabled into a single chain is outside the scope of these scripts; a single cabled rig doesn't need it.)
- LATTICE TIFFs are written uncompressed (~6.3 MB per full-res frame) so the required EXIF survives on every platform. Compress at rest if storage is tight.
selftest.pychecks that everything these scripts write matches what Chloros reads on import, and that the DAQ wire codec and camera configuration are correct. Run it any time:python selftest.py.
MIT licensed (see LICENSE); provided as-is, without warranty. For help with MAPIR hardware or Chloros, contact MAPIR support.