diff --git a/changelog.md b/changelog.md index 331fb9cc2..7513cd359 100644 --- a/changelog.md +++ b/changelog.md @@ -9,7 +9,8 @@ * Calibration results and parameters are now accessible via properties which are listed when items are printed. See [calibration results](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.force_calibration.power_spectrum_calibration.CalibrationResults.html) and [calibration item](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.calibration.ForceCalibrationItem.html) API documentation for more information. * Added `applied_at` property to a [calibration item](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.calibration.ForceCalibrationItem.html) obtained from a force slice. This property returns the timestamp in nanoseconds at which the force calibration was applied. * Added improved printing of calibration items under `channel.calibration` providing a more convenient overview of the items associated with a `Slice`. -* Added parameter `titles` to customize title of each subplot in `[`Kymo.plot_with_channels()`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.kymo.Kymo.html#lumicks.pylake.kymo.Kymo.plot_with_channels). +* Added improved printing of calibrations performed with `Pylake`. +* Added parameter `titles` to customize title of each subplot in [`Kymo.plot_with_channels()`](https://lumicks-pylake.readthedocs.io/en/latest/_api/lumicks.pylake.kymo.Kymo.html#lumicks.pylake.kymo.Kymo.plot_with_channels). ## v1.5.1 | 2024-06-03 diff --git a/lumicks/pylake/calibration.py b/lumicks/pylake/calibration.py index f16017c59..7ddbe878a 100644 --- a/lumicks/pylake/calibration.py +++ b/lumicks/pylake/calibration.py @@ -1,4 +1,5 @@ import re +import datetime from functools import wraps from collections import UserDict @@ -49,14 +50,6 @@ def _sensor_type(self): else: return "unknown sensor" - @property - def kind(self): - kind = self.data.get("Kind", "Unknown") - if kind == "Full calibration": - return "Active calibration" if self.active_calibration else "Passive calibration" - else: - return kind - @_verify_full def power_spectrum_params(self): """Returns parameters with which the power spectrum was calculated @@ -199,27 +192,6 @@ def number_of_samples(self): """Number of fitted samples (-).""" return self.data.get("Number of samples") - @property - def active_calibration(self): - """Returns whether it was an active calibration or not - - Calibrations based on active calibration are less sensitive to assumptions about the - bead diameter, viscosity, distance of the bead to the flow cell surface and temperature. - During active calibration, the trap or nano-stage is oscillated sinusoidally. These - oscillations result in a driving peak in the force spectrum. Using power spectral analysis, - the force can then be calibrated without prior knowledge of the drag coefficient. - - While this results in improved accuracy, it may lead to an increase in the variability of - results. - - .. note:: - - When active calibration is performed using two beads, correction factors must be - computed which account for the reduced flow field that forms around the beads due to - the presence of a second bead. - """ - return self.data.get("driving_frequency (Hz)") is not None - @property def num_points_per_block(self): """Number of points per block used for spectral down-sampling""" @@ -367,11 +339,15 @@ def from_field(hdf5, force_channel, time_field="Stop time (ns)") -> "ForceCalibr return ForceCalibration(time_field=time_field, items=items) - def print_summary(self, tablefmt): + def _print_summary(self, tablefmt): + def format_timestamp(timestamp): + return datetime.datetime.fromtimestamp(int(timestamp)).strftime("%x %X") + return tabulate( ( ( idx, + format_timestamp(item.applied_at / 1e9) if item.applied_at else "-", item.kind, f"{item.stiffness:.2f}" if item.stiffness else "N/A", f"{item.force_sensitivity:.2f}" if item.force_sensitivity else "N/A", @@ -389,7 +365,8 @@ def print_summary(self, tablefmt): ), tablefmt=tablefmt, headers=( - "Index", + "#", + "Applied at", "Kind", "Stiffness (pN/nm)", "Force sens. (pN/V)", @@ -401,10 +378,10 @@ def print_summary(self, tablefmt): ) def _repr_html_(self): - return self.print_summary(tablefmt="html") + return self._print_summary(tablefmt="html") def __str__(self): - return self.print_summary(tablefmt="text") + return self._print_summary(tablefmt="text") @staticmethod def from_dataset(hdf5, n, xy, time_field="Stop time (ns)") -> "ForceCalibration": diff --git a/lumicks/pylake/force_calibration/power_spectrum_calibration.py b/lumicks/pylake/force_calibration/power_spectrum_calibration.py index 4743c7374..5677a4fc2 100644 --- a/lumicks/pylake/force_calibration/power_spectrum_calibration.py +++ b/lumicks/pylake/force_calibration/power_spectrum_calibration.py @@ -581,6 +581,35 @@ def offset(self): """Force offset (pN)""" return self._get_parameter("NA", "Offset") + @property + def active_calibration(self): + """Returns whether it was an active calibration or not + + Calibrations based on active calibration are less sensitive to assumptions about the + bead diameter, viscosity, distance of the bead to the flow cell surface and temperature. + During active calibration, the trap or nano-stage is oscillated sinusoidally. These + oscillations result in a driving peak in the force spectrum. Using power spectral analysis, + the force can then be calibrated without prior knowledge of the drag coefficient. + + While this results in improved accuracy, it may lead to an increase in the variability of + results. + + .. note:: + + When active calibration is performed using two beads, correction factors must be + computed which account for the reduced flow field that forms around the beads due to + the presence of a second bead. + """ + return self.driving_frequency is not None + + @property + def kind(self): + kind = self._get_parameter("Kind", "Kind") + if kind == "Full calibration": + return "Active" if self.active_calibration else "Passive" + else: + return kind if kind is not None else "Unknown" + class CalibrationResults(CalibrationPropertiesMixin): """Power spectrum calibration results. @@ -653,15 +682,6 @@ def _get_parameter(self, pylake_key, _): if pylake_key in self: return self[pylake_key].value - @property - def kind(self): - """Type of calibration performed""" - return ( - "active calibration" - if self.model.__class__.__name__ == "ActiveCalibrationModel" - else "passive calibration" - ) - @property def _fitted_diode(self): """Diode parameters were fitted""" @@ -1006,6 +1026,7 @@ def fit_power_spectrum( "Chi squared per degree of freedom", chi_squared_per_deg, "" ), "backing": CalibrationParameter("Statistical backing", backing, "%"), + "Kind": CalibrationParameter("Calibration type", "Full calibration", "-"), }, params={ **model.calibration_parameters(), diff --git a/lumicks/pylake/force_calibration/tests/test_active_calibration.py b/lumicks/pylake/force_calibration/tests/test_active_calibration.py index 091c0fdc1..9ddadf414 100644 --- a/lumicks/pylake/force_calibration/tests/test_active_calibration.py +++ b/lumicks/pylake/force_calibration/tests/test_active_calibration.py @@ -97,6 +97,9 @@ def test_integration_active_calibration( np.testing.assert_allclose(fit["Viscosity"].value, viscosity) np.testing.assert_allclose(fit["num_windows"].value, 5) + assert fit.active_calibration + assert fit.kind == "Active" + np.testing.assert_allclose( fit["driving_amplitude"].value, driving_sinusoid[0] * 1e-3, rtol=1e-5 ) diff --git a/lumicks/pylake/force_calibration/tests/test_hydro.py b/lumicks/pylake/force_calibration/tests/test_hydro.py index 793e849f8..2bb67fba4 100644 --- a/lumicks/pylake/force_calibration/tests/test_hydro.py +++ b/lumicks/pylake/force_calibration/tests/test_hydro.py @@ -380,6 +380,8 @@ def test_integration_passive_calibration_hydrodynamics(integration_test_paramete assert not fit.transferred_lateral_drag_coefficient # Only relevant for axial assert fit.fit_range == (100, 23000) assert fit.excluded_ranges == [] + assert not fit.active_calibration + assert fit.kind == "Passive" def test_integration_active_calibration_hydrodynamics_bulk(integration_test_parameters): diff --git a/lumicks/pylake/tests/test_file/test_file.py b/lumicks/pylake/tests/test_file/test_file.py index 2819def5f..9dd9f5463 100644 --- a/lumicks/pylake/tests/test_file/test_file.py +++ b/lumicks/pylake/tests/test_file/test_file.py @@ -75,21 +75,35 @@ def test_redirect_list(h5_file): assert f["Point Scan"]["PointScan1"].start == np.int64(20e9) -def test_calibration_str(h5_file): +def test_calibration_str(h5_file, monkeypatch): + # Representation of time is timezone and locale dependent, hence we monkeypatch it + class FakeDateTime: + @classmethod + def fromtimestamp(cls, timestamp, *args, **kwargs): + """Inject a timezone""" + return FakeDateTime() + + def strftime(self, str_format): + return str_format + f = pylake.File.from_h5py(h5_file) if f.format_version == 2: - print("") - print(str(f.force1x.calibration)) - assert str(f.force1x.calibration) == dedent( - ( - """\ - Index Kind Stiffness (pN/nm) Force sens. (pN/V) Disp. sens. (µm/V) Hydro Surface Data? - ------- ------------------- ------------------- -------------------- -------------------- ------- --------- ------- - 0 Unknown N/A N/A N/A False False False - 1 Reset offset 1.05 504.43 4.57 False True False - 2 Passive calibration 1.05 504.43 4.57 False False True""" + with monkeypatch.context() as m: + m.setattr( + "lumicks.pylake.calibration.datetime.datetime", + FakeDateTime, + ) + + assert str(f.force1x.calibration) == dedent( + ( + """\ + # Applied at Kind Stiffness (pN/nm) Force sens. (pN/V) Disp. sens. (µm/V) Hydro Surface Data? + --- ------------ ------------ ------------------- -------------------- -------------------- ------- --------- ------- + 0 - Unknown N/A N/A N/A False False False + 1 %x %X Reset offset 1.05 504.43 4.57 False True False + 2 %x %X Passive 1.05 504.43 4.57 False False True""" + ) ) - ) def test_repr_and_str(h5_file):