Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion study_lyte/io.py
Original file line number Diff line number Diff line change
@@ -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"""

Expand Down
18 changes: 17 additions & 1 deletion study_lyte/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
62 changes: 61 additions & 1 deletion tests/test_io.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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']
48 changes: 48 additions & 0 deletions tests/test_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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
Loading