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