Skip to content

Commit 4e71e24

Browse files
Calibration (#20)
* Added in functionality for calibrations via json * Adjusted interpolation scheme to use pandas > 2.0.0 * Calibration changes the answers for kaslo tests
1 parent 1f74680 commit 4e71e24

9 files changed

Lines changed: 188 additions & 52 deletions

File tree

Makefile

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,8 @@ release: dist ## package and upload a release
7979
twine upload dist/*
8080

8181
dist: clean ## builds source and wheel package
82-
python setup.py sdist
83-
python setup.py bdist_wheel
82+
python3 -m build --sdist
8483
ls -l dist
8584

8685
install: clean ## install the package to the active Python's site-packages
87-
python setup.py install
86+
pip install .

study_lyte/adjustments.py

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -91,28 +91,23 @@ def get_normalized_at_border(series: pd.Series, fractional_basis: float = 0.01,
9191
return border_norm
9292

9393
def merge_on_to_time(df_list, final_time):
94-
"""
95-
Reindex the df from the list onto a final time stamp
96-
"""
97-
# Build dummy result in case no data is passed
98-
result = pd.DataFrame()
94+
""""""
95+
result = None
96+
for df in df_list:
9997

100-
# Merge everything else to it
101-
for i, df in enumerate(df_list):
10298
time_df = df.copy()
10399
if df.index.name != 'time':
104100
time_df = time_df.set_index('time')
105-
else:
106-
time_df = df.copy()
107101

108-
data = time_df.reindex(time_df.index.union(final_time)).interpolate(method='cubic').reindex(final_time)
109-
if i == 0:
110-
result = data
102+
new = pd.DataFrame(columns=time_df.columns, index=final_time)
103+
for c in time_df.columns:
104+
new[c] = np.interp(final_time, # Target indices (100 Hz)
105+
time_df.index, # Original 75 Hz indices
106+
time_df[c])
107+
if result is None:
108+
result = new
111109
else:
112-
result = pd.merge_ordered(result, data, on='time')
113-
114-
# interpolate the nan's
115-
result = result.interpolate(method='nearest', limit_direction='both')
110+
result = result.join(new)
116111
return result
117112

118113

study_lyte/calibrations.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
from enum import Enum
2+
import json
3+
from pathlib import Path
4+
from .logging import setup_log
5+
import logging
6+
from dataclasses import dataclass
7+
from typing import List
8+
setup_log()
9+
10+
LOG = logging.getLogger('study_lyte.calibrations')
11+
12+
13+
@dataclass()
14+
class Calibration:
15+
"""Small class to make accessing calibration data a bit more convenient"""
16+
serial: str
17+
calibration: dict[str, List[float]]
18+
19+
20+
class Calibrations:
21+
"""
22+
Class to read in a json containing calibrations, keyed by serial number and
23+
valued by dictionary of sensor names containing cal values
24+
"""
25+
def __init__(self, filename:Path):
26+
with open(filename, mode='r') as fp:
27+
self._info = json.load(fp)
28+
29+
def from_serial(self, serial:str) -> Calibration:
30+
""" Build data object from the calibration result """
31+
cal = self._info.get(serial)
32+
if cal is None:
33+
LOG.warning(f"No Calibration found for serial {serial}, using default")
34+
cal = self._info['default']
35+
serial = 'UNKNOWN'
36+
37+
else:
38+
LOG.info(f"Calibration found ({serial})!")
39+
40+
result = Calibration(serial=serial, calibration=cal)
41+
return result

study_lyte/detect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ def get_acceleration_stop(acceleration, threshold=-0.2, max_threshold=0.1):
171171
n_points=n,
172172
search_direction='backward')
173173

174-
if acceleration_stop is None:
174+
if acceleration_stop is None or acceleration_stop == 0:
175175
acceleration_stop = len(acceleration) - 1
176176
else:
177177
acceleration_stop = acceleration_stop + search_start

study_lyte/profile.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
from .detect import get_acceleration_start, get_acceleration_stop, get_nir_surface, get_nir_stop, get_sensor_start, get_ground_strike
1212
from .depth import AccelerometerDepth, BarometerDepth
1313
from .logging import setup_log
14+
from .calibrations import Calibrations
1415
import logging
1516
setup_log()
1617

1718
LOG = logging.getLogger('study_lyte.profile')
19+
1820
@dataclass
1921
class Event:
2022
name: str
@@ -39,30 +41,32 @@ def __init__(self, filename, surface_detection_offset=4.5, calibration=None,
3941
"""
4042
self.filename = Path(filename)
4143
self.surface_detection_offset = surface_detection_offset
42-
self.calibration = calibration or {}
4344
self.tip_diameter_mm = tip_diameter_mm
4445

4546
# Properties
4647
self._raw = None
4748
self._meta = None
4849
self._point = None
50+
self._serial_number = None
51+
self._calibration = calibration or None
4952
self.header_position = None
5053

54+
# Dataframes
5155
self._depth = None # Final depth series used for analysis
5256
self._acceleration = None # No gravity acceleration
5357
self._cropped = None # Full dataframe cropped to surface and stop
5458
self._force = None
5559
self._nir = None
5660

57-
# Useful stats/info
61+
# Useful stats/info properties
5862
self._distance_traveled = None # distance travelled while moving
5963
self._distance_through_snow = None # Distance travelled while in snow
6064
self._avg_velocity = None # avg velocity of the probe while in the snow
6165
self._resolution = None # Vertical resolution of the profile in the snow
6266
self._datetime = None
6367
self._has_upward_motion = None # Flag for datasets containing upward motion
6468

65-
# Events
69+
# Time series events
6670
self._start = None
6771
self._stop = None
6872
self._surface = None
@@ -75,6 +79,27 @@ def assign_event_depths(self):
7579
for event in [self._start, self._stop, self._surface.nir, self._surface.force]:
7680
event.depth = self.depth.iloc[event.index]
7781

82+
@property
83+
def serial_number(self):
84+
if self._serial_number is None:
85+
self._serial_number = self.metadata.get('Serial Num.')
86+
if self._serial_number is None:
87+
self._serial_number = 'UNKNOWN'
88+
return self._serial_number
89+
90+
def set_calibration(self, ext_calibrations:Calibrations):
91+
"""
92+
Assign new calibration using a collection of calibrations
93+
Args:
94+
ext_calibrations: External collection of calibrations
95+
"""
96+
cal = ext_calibrations.from_serial(self.serial_number)
97+
self._calibration = cal.calibration
98+
99+
@property
100+
def calibration(self):
101+
return self._calibration
102+
78103
@classmethod
79104
def from_metadata(cls, filename, **kwargs):
80105
profile = cls(filename)
@@ -83,7 +108,6 @@ def from_metadata(cls, filename, **kwargs):
83108
else:
84109
return LyteProfileV6(filename)
85110

86-
87111
@property
88112
def raw(self):
89113
"""
@@ -144,7 +168,7 @@ def force(self):
144168
if self._force is None:
145169
if 'Sensor1' in self.calibration.keys():
146170
force = apply_calibration(self.raw['Sensor1'].values, self.calibration['Sensor1'], minimum=0, maximum=15000)
147-
# force = force - force[0]
171+
force = force - np.nanmean(force[0:20])
148172
else:
149173
force = self.raw['Sensor1'].values
150174

@@ -432,7 +456,7 @@ def stop(self):
432456
if idx is not None:
433457
self._stop = Event(name='stop', index=idx, depth=None, time=self.raw['time'].iloc[idx])
434458
else:
435-
self._stop = Event(name='stop', index=0, depth=None, time=self.raw['time'].iloc[0])
459+
self._stop = Event(name='stop', index=len(self.raw) - 1, depth=None, time=self.raw['time'].iloc[0])
436460

437461
return self._stop
438462

tests/data/calibrations.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"252813070A020004":{"Sensor1":[0, 0, -10, 409],
3+
"comment": "Test"},
4+
5+
"default":{"Sensor1":[0, 0, -1, 4096],
6+
"comment": "default"}
7+
}

tests/test_adjustments.py

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,23 @@ def test_get_points_from_fraction(n_samples, fraction, maximum, expected):
2929
([1, 1, 2, 2], 0.5, 'backward', 2),
3030
# fractional basis
3131
([1, 3, 4, 6], 0.25, 'forward', 1),
32-
([1, 3, 5, 6], 0.75, 'forward', 3)
32+
([1, 3, 5, 6], 0.75, 'forward', 3),
33+
34+
# Test for nans
35+
([1]*10 + [2] * 5 + [np.nan]*5, 0.5, 'backward', 2),
36+
([np.nan] * 10, 0.5, 'backward', np.nan)
37+
3338
])
3439
def test_directional_mean(data, fractional_basis, direction, expected):
3540
"""
3641
Test the directional mean function
3742
"""
3843
df = pd.DataFrame({'data': np.array(data)})
3944
value = get_directional_mean(df['data'], fractional_basis=fractional_basis, direction=direction)
40-
assert value == expected
45+
if np.isnan(expected): # Handle the NaN case
46+
assert np.isnan(value)
47+
else:
48+
assert value == expected
4149

4250

4351
@pytest.mark.parametrize('data, fractional_basis, direction, zero_bias_idx', [
@@ -70,20 +78,41 @@ def test_get_normalized_at_border(data, fractional_basis, direction, ideal_norm_
7078
result = get_normalized_at_border(df['data'], fractional_basis=fractional_basis, direction=direction)
7179
assert result.iloc[ideal_norm_index] == 1
7280

81+
def poly_function(elapsed, amplitude=4096, frequency=1):
82+
return amplitude * np.sin(2 * np.pi * frequency * elapsed)
83+
84+
7385
@pytest.mark.parametrize('data1_hz, data2_hz, desired_hz', [
74-
(10, 5, 20)
86+
(75, 100, 16000),
87+
(100, 75, 100),
88+
7589
])
7690
def test_merge_on_to_time(data1_hz, data2_hz, desired_hz):
77-
df1 = pd.DataFrame({'data1':np.arange(1,stop=data1_hz+1), 'time': np.arange(0, 1, 1 / data1_hz)})
78-
df2 = pd.DataFrame({'data2':np.arange(100,stop=data2_hz+100), 'time': np.arange(0, 1, 1 / data2_hz)})
79-
desired = np.arange(0, 1, 1 / desired_hz)
91+
"""
92+
Test merging multi sample rate timeseries into a single dataframe
93+
specifically focused on timing of the final product
94+
"""
95+
t1 = np.arange(0, 1, 1 / data1_hz)
96+
d1 = poly_function(t1)
97+
df1 = pd.DataFrame({'data1':d1, 'time': t1})
98+
df1 = df1.set_index('time')
8099

100+
t2 = np.arange(0, 1, 1 / data2_hz)
101+
d2 = poly_function(t2)
102+
df2 = pd.DataFrame({'data2':d2, 'time': t2})
103+
df2 = df2.set_index('time')
104+
105+
desired = np.arange(0, 1, 1 / desired_hz)
81106
final = merge_on_to_time([df1, df2], desired)
82-
# Ensure we have essentially the same timestep
83-
tsteps = np.unique(np.round(final['time'].diff(), 6))
84-
tsteps = tsteps[~np.isnan(tsteps)]
85-
# Assert only a nan and a real number exist for timesteps
86-
assert tsteps == np.round(1/desired_hz, 6)
107+
108+
# Check timing on both dataframes
109+
assert df1['data1'].idxmax() == pytest.approx(final['data1'].idxmax(), abs=3e-2)
110+
assert df1['data1'].idxmin() == pytest.approx(final['data1'].idxmin(), abs=3e-2)
111+
# Confirm the handling of multiple datasets
112+
assert len(final.columns) == 2
113+
# Confirm an exact match of length of data
114+
assert len(final['data1'][~np.isnan(final['data1'])]) == len(desired)
115+
87116

88117
@pytest.mark.parametrize('data_list, expected', [
89118
# Typical use, low sample to high res
@@ -125,7 +154,7 @@ def test_merge_time_series(data_list, expected):
125154
# Test normal situation with ambient present
126155
([200, 200, 400, 1000], [200, 200, 50, 50], 100, [1.0, 1.0, 275, 1000]),
127156
# Test no cleaning required
128-
# ([200, 200, 400, 400], [210, 210, 200, 200], 90, [200, 200, 400, 400])
157+
([200, 200, 400, 400], [210, 210, 200, 200], 90, [200, 200, 400, 400])
129158
])
130159
def test_remove_ambient(active, ambient, min_ambient_range, expected):
131160
"""
@@ -137,10 +166,6 @@ def test_remove_ambient(active, ambient, min_ambient_range, expected):
137166
result = remove_ambient(active, ambient, min_ambient_range=100)
138167
np.testing.assert_equal(result.values, expected)
139168

140-
# @pytest.mark.parametrize('fname', ['2024-01-31--104419.csv'])
141-
# def test_remove_ambient_real(raw_df, fname):
142-
# remove_ambient(raw_df['Sensor3'], raw_df['Sensor2'])
143-
144169
@pytest.mark.parametrize('data, coefficients, expected', [
145170
([1, 2, 3, 4], [2, 0], [2, 4, 6, 8])
146171
])
@@ -153,15 +178,13 @@ def test_apply_calibration(data, coefficients, expected):
153178

154179
@pytest.mark.parametrize("data, depth, new_depth, resolution, agg_method, expected_data", [
155180
# Test with negative depths
156-
#([[2, 4, 6, 8]], [-10, -20, -30, -40], [-20, -40], None, 'mean', [[3, 7]]),
181+
([[2, 4, 6, 8]], [-10, -20, -30, -40], [-20, -40], None, 'mean', [[3, 7]]),
157182
# Test with column specific agg methods
158-
#([[2, 4, 6, 8], [1, 1, 1, 1]], [-10, -20, -30, -40], [-20, -40], None, {'data0': 'mean','data1':'sum'}, [[3, 7], [2, 2]]),
183+
([[2, 4, 6, 8], [1, 1, 1, 1]], [-10, -20, -30, -40], [-20, -40], None, {'data0': 'mean','data1':'sum'}, [[3, 7], [2, 2]]),
159184
# Test with resolution
160185
([[2, 4, 6, 8]], [-10, -20, -30, -40], None, 20, 'mean', [[3, 7]]),
161186
])
162187
def test_aggregate_by_depth(data, depth, new_depth, resolution, agg_method, expected_data):
163-
"""
164-
"""
165188
data_dict = {f'data{i}':d for i,d in enumerate(data)}
166189
data_dict['depth'] = depth
167190
df = pd.DataFrame.from_dict(data_dict)

tests/test_calibration.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import pytest
2+
from os.path import join
3+
from pathlib import Path
4+
from study_lyte.calibrations import Calibrations
5+
6+
7+
class TestCalibrations:
8+
@pytest.fixture(scope='function')
9+
def calibration_json(self, data_dir):
10+
return Path(join(data_dir, 'calibrations.json'))
11+
12+
@pytest.fixture(scope='function')
13+
def calibrations(self, calibration_json):
14+
return Calibrations(calibration_json)
15+
16+
@pytest.mark.parametrize("serial, expected", [
17+
("252813070A020004", -10),
18+
("NONSENSE", -1),
19+
])
20+
def test_attributes(self, calibrations, serial, expected):
21+
""""""
22+
result = calibrations.from_serial(serial)
23+
assert result.calibration['Sensor1'][2] == expected
24+
25+

0 commit comments

Comments
 (0)