From 31010656779bd7a36ba9d1ff4937db5c5e8059b9 Mon Sep 17 00:00:00 2001 From: micah johnson Date: Thu, 20 Aug 2026 11:00:16 -0600 Subject: [PATCH] Give a measurement its own id Until now the only way to name a measurement was the pair (Serial Num., RECORDED). That collides when two are taken inside the same second, and RECORDED carries no timezone, so the same measurement on two machines could not reliably be recognised as one thing. Cloud backup needs it to be: an upload keys on the id, and a retry over a bad connection has to land on the file already stored rather than a second copy of it. Adds the key, a generator, and a tolerant reader. The tolerance is not politeness -- radicl, the Android app and the iOS app each write these files from their own implementation, and a stray underscore or a lowercased key should not orphan somebody's backup. find_measurement_id returns None for the files captured before the key existed, and LyteProfileV6.measurement_id does the same rather than following serial_number's habit of substituting a placeholder. An absent id is worth acting on: it is what tells a sync client to mint one and write it back. A value invented on read would look real and would differ every time. Co-Authored-By: Claude Opus 5 (1M context) --- study_lyte/io.py | 54 ++++++++++++++++++++++++++++++++++++- study_lyte/profile.py | 18 ++++++++++++- tests/test_io.py | 62 ++++++++++++++++++++++++++++++++++++++++++- tests/test_profile.py | 48 +++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/study_lyte/io.py b/study_lyte/io.py index e1ba82c..97377f7 100644 --- a/study_lyte/io.py +++ b/study_lyte/io.py @@ -1,8 +1,60 @@ from pathlib import Path -from typing import Tuple, Union +from typing import Optional, Tuple, Union +import uuid import pandas as pd import numpy as np +# Header key a measurement's own identifier is written under. +# +# Until this existed the only way to name a measurement was the pair +# (Serial Num., RECORDED), which collides when two are taken inside the same +# second and carries no timezone. The id is generated once, at capture, and +# travels inside the file, so the same measurement stays recognisable wherever +# it ends up: re-exported, backed up to the cloud, or pulled onto a different +# machine. +# +# Shared deliberately. radicl writes it, the apps write it, and the sync API +# keys on it, so it should be spelled in exactly one place. +MEASUREMENT_ID_KEY = 'MEASUREMENT ID' + + +def new_measurement_id() -> str: + """ + A fresh identifier for a measurement. + + Returns: + str: A uuid4 in the usual hyphenated form + """ + return str(uuid.uuid4()) + + +def find_measurement_id(metadata: dict) -> Optional[str]: + """ + The measurement id from a parsed header, if it carries one. + + Matched loosely on the key, since three clients write these files + independently and an underscore or a different case should not lose the + id. Files written before the key existed simply have none, which is why + this returns None rather than inventing one — a caller that needs an id + for such a file should mint it once and write it back, not derive a fresh + one on every read. + + Args: + metadata: Header dictionary, as returned by find_metadata + + Returns: + str: The id, or None when the header does not carry one + """ + wanted = MEASUREMENT_ID_KEY.replace(' ', '') + + for key, value in metadata.items(): + if str(key).strip().upper().replace('_', '').replace(' ', '') == wanted: + value = str(value).strip() + + return value or None + + return None + def find_metadata(f:str) -> [int, dict]: """Read just the metadata from the probe files""" diff --git a/study_lyte/profile.py b/study_lyte/profile.py index 31356c2..5ebbe76 100644 --- a/study_lyte/profile.py +++ b/study_lyte/profile.py @@ -5,7 +5,7 @@ from types import SimpleNamespace import numpy as np from functools import cached_property -from . io import read_data, find_metadata +from . io import read_data, find_metadata, find_measurement_id from .adjustments import get_neutral_bias_at_border, remove_ambient, apply_calibration, get_points_from_fraction, zfilter from .detect import get_acceleration_start, get_acceleration_stop, get_nir_surface, get_nir_stop, get_sensor_start, get_ground_strike from .depth import AccelerometerDepth, BarometerDepth @@ -56,6 +56,7 @@ def __init__(self, filename, surface_detection_offset=4.5, calibration=None, self._meta = None self._point = None self._serial_number = None + self._measurement_id = None self._calibration = calibration or None self.header_position = None @@ -88,6 +89,21 @@ def assign_event_depths(self, depth:pd.Series): event.depth = depth.iloc[event.index] self._surface = self.assign_surface_depths(depth) + @property + def measurement_id(self): + """ + The measurement's own identifier, or None for a file written before + the key existed. + + Unlike serial_number this does not fall back to a placeholder. An + absent id is a fact worth acting on — it is what tells a sync client + the file needs one minting and writing back — whereas an invented + value would look real and would differ on every read. + """ + if self._measurement_id is None: + self._measurement_id = find_measurement_id(self.metadata) + return self._measurement_id + @property def serial_number(self): if self._serial_number is None: diff --git a/tests/test_io.py b/tests/test_io.py index 6c76e1f..2a458bb 100644 --- a/tests/test_io.py +++ b/tests/test_io.py @@ -1,4 +1,6 @@ -from study_lyte.io import read_csv, write_csv +from study_lyte.io import (MEASUREMENT_ID_KEY, find_measurement_id, + new_measurement_id, read_csv, write_csv) +from uuid import UUID import pytest from os.path import join, isfile import os @@ -56,3 +58,61 @@ def test_write_csv(out_file): with open(out_file) as fp: txt = ''.join(fp.readlines()) assert txt == 'model = 10\ndata\n1\n2\n3\n' + + +class TestMeasurementId: + """ + The id that lets a measurement be named. + + Before it, the only key was (Serial Num., RECORDED), which collides inside + a second and has no timezone. Three clients write these files + independently, so the tolerance below is not politeness — it is what stops + a stray underscore silently orphaning a backup. + """ + + def test_new_ids_are_unique(self): + assert len({new_measurement_id() for _ in range(100)}) == 100 + + def test_new_ids_are_uuid4(self): + assert UUID(new_measurement_id()).version == 4 + + @pytest.mark.parametrize('key', [ + 'MEASUREMENT ID', + 'MEASUREMENT_ID', + 'measurement id', + 'measurement_id', + 'Measurement Id', + ]) + def test_key_spellings_all_resolve(self, key): + value = new_measurement_id() + + assert find_measurement_id({key: value}) == value + + @pytest.mark.parametrize('metadata', [ + {}, + {'RECORDED': '2026-07-20--21:44:55'}, + # Present but empty is the same as absent, not an id of '' + {'MEASUREMENT ID': ''}, + {'MEASUREMENT ID': ' '}, + ]) + def test_absent_id_is_none(self, metadata): + assert find_measurement_id(metadata) is None + + def test_value_is_stripped(self): + value = new_measurement_id() + + assert find_measurement_id({'MEASUREMENT ID': f' {value}\t'}) == value + + def test_survives_a_write_and_read(self, tmp_path): + """The id has to come back out of a real file, not just a dict.""" + value = new_measurement_id() + out = tmp_path / 'measurement.csv' + + write_csv(DataFrame({'depth': [0.0, -1.0], 'Sensor1': [1, 2]}), + {MEASUREMENT_ID_KEY: value, 'Serial Num.': 'ABCD100A0E010001'}, + str(out)) + + df, meta = read_csv(str(out)) + + assert find_measurement_id(meta) == value + assert sorted(df.columns) == ['Sensor1', 'depth'] diff --git a/tests/test_profile.py b/tests/test_profile.py index 0e5e38d..fb8d1a4 100644 --- a/tests/test_profile.py +++ b/tests/test_profile.py @@ -3,6 +3,9 @@ from pathlib import Path from study_lyte.calibrations import Calibrations from study_lyte.profile import ProcessedProfileV6, LyteProfileV6, Sensor, GISPoint +from study_lyte.io import (MEASUREMENT_ID_KEY, new_measurement_id, read_csv, + write_csv) +from pathlib import Path from operator import attrgetter @@ -287,3 +290,48 @@ def test_app(data_dir): fname = data_dir + '/ls_app.csv' profile = ProcessedProfileV6(fname) assert False # TODO: Add more detailed checking + + +class TestMeasurementId: + """ + A profile should surface its own id, and should be honest when it has none. + """ + + def test_reads_the_id_out_of_the_header(self, tmp_path, data_dir): + """Written into a real file, then read back through the profile.""" + value = new_measurement_id() + source = Path(data_dir) / 'kaslo.csv' + out = tmp_path / 'kaslo_with_id.csv' + + df, meta = read_csv(str(source)) + meta[MEASUREMENT_ID_KEY] = value + write_csv(df, meta, str(out)) + + profile = LyteProfileV6(str(out), calibration={'Sensor1': [-1, 4096]}) + + assert profile.measurement_id == value + + def test_older_files_report_none_rather_than_inventing_one(self, data_dir): + """ + Every file captured before the key existed lands here. Returning None + is what lets a sync client tell "needs an id minting and writing back" + apart from "already has one", which a generated value would hide. + """ + profile = LyteProfileV6(join(data_dir, 'kaslo.csv'), + calibration={'Sensor1': [-1, 4096]}) + + assert profile.measurement_id is None + + def test_is_stable_across_reads(self, tmp_path, data_dir): + """Two reads of one file must not disagree about what it is called.""" + value = new_measurement_id() + out = tmp_path / 'stable.csv' + + df, meta = read_csv(join(data_dir, 'kaslo.csv')) + meta[MEASUREMENT_ID_KEY] = value + write_csv(df, meta, str(out)) + + first = LyteProfileV6(str(out), calibration={'Sensor1': [-1, 4096]}) + second = LyteProfileV6(str(out), calibration={'Sensor1': [-1, 4096]}) + + assert first.measurement_id == second.measurement_id == value