diff --git a/docs/changes/newsfragments/8501.new b/docs/changes/newsfragments/8501.new new file mode 100644 index 000000000000..39dba51c61be --- /dev/null +++ b/docs/changes/newsfragments/8501.new @@ -0,0 +1,18 @@ +In addition to the snapshot taken when a measurement starts, QCoDeS now also +takes a snapshot of the station when the measurement ends. The end snapshot is +taken with the same settings as the start snapshot (``update="Only_invalid"``) +and is stored in the metadata of the dataset under the ``end_snapshot`` key. It +is available as the new ``end_snapshot`` property of the dataset. Snapshotting +at the end can be disabled for a single measurement by passing +``snapshot_at_end=False`` to ``Measurement.run``, or globally via the new +``snapshot_at_end`` key in the ``dataset`` section of the QCoDeS config. + +Two new functions, ``qcodes.dataset.diff_start_end_snapshot`` and +``qcodes.dataset.diff_start_end_snapshot_by_id``, return the differences +between the parameter values of the start and the end snapshot of a dataset as +a ``ParameterDiff``, complementing the existing +``qcodes.dataset.diff_param_snapshots`` and +``qcodes.dataset.diff_param_values_by_id`` which compare two datasets and which +are now exported from ``qcodes.dataset``. A ``ParameterDiff`` can be rendered in +a human-readable form with the new ``qcodes.utils.format_parameter_diff``, which +is also used when printing a ``ParameterDiff``. diff --git a/docs/examples/DataSet/Working with snapshots.ipynb b/docs/examples/DataSet/Working with snapshots.ipynb index 49d42c62d4e6..b3583a328e74 100644 --- a/docs/examples/DataSet/Working with snapshots.ipynb +++ b/docs/examples/DataSet/Working with snapshots.ipynb @@ -884,12 +884,109 @@ "diff_param_values(dataset.snapshot, bad_dataset.snapshot).changed" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Snapshot at the end of a measurement\n", + "\n", + "In addition to the snapshot taken when a measurement starts, QCoDeS takes a second snapshot when the measurement ends. This makes it possible to see how the state of the setup evolved during the measurement itself.\n", + "\n", + "The end snapshot is taken with the same settings as the start snapshot (`update=\"Only_invalid\"`) and is stored in the metadata of the dataset under the `end_snapshot` key. It is available as the `end_snapshot` property of the dataset, which returns a python dictionary (or `None` if no end snapshot was taken).\n", + "\n", + "Snapshotting at the end can be disabled for a single measurement by passing `snapshot_at_end=False` to `Measurement.run`, or globally via the `snapshot_at_end` key in the `dataset` section of the QCoDeS config." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "measurement = Measurement(experiment, station)\n", + "\n", + "measurement.register_parameter(instr.input)\n", + "measurement.register_parameter(instr.output, setpoints=[instr.input])\n", + "\n", + "instr.gain(11)\n", + "\n", + "with measurement.run() as data_saver:\n", + " input_value = 111\n", + " instr.input.set(input_value)\n", + " instr.output.set(222)\n", + " data_saver.add_result((instr.input, input_value), (instr.output, instr.output()))\n", + " # the gain drifts (or is changed) while the measurement is running\n", + " instr.gain(42)\n", + "\n", + "dataset_with_end_snapshot = data_saver.dataset" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "pprint(\n", + " dataset_with_end_snapshot.end_snapshot[\"station\"][\"instruments\"][\"instr\"][\n", + " \"parameters\"\n", + " ][\"gain\"]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Diffing the start and the end snapshot\n", + "\n", + "`diff_start_end_snapshot` returns the differences between the parameter values of the two snapshots of a single dataset as a `ParameterDiff`, in the same way as `diff_param_values` does for two separate snapshots. `diff_start_end_snapshot_by_id` does the same given the run id of a dataset." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from qcodes.dataset import diff_start_end_snapshot, diff_start_end_snapshot_by_id\n", + "\n", + "diff = diff_start_end_snapshot(dataset_with_end_snapshot)\n", + "diff.changed" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A `ParameterDiff` can be rendered in a human-readable form with `format_parameter_diff`, which also allows naming the two sides of the diff. Printing a `ParameterDiff` directly gives the same rendering with the default names." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from qcodes.utils import format_parameter_diff\n", + "\n", + "print(format_parameter_diff(diff, \"start\", \"end\"))" + ] + }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "print(\n", + " format_parameter_diff(\n", + " diff_start_end_snapshot_by_id(dataset_with_end_snapshot.run_id),\n", + " \"start\",\n", + " \"end\",\n", + " )\n", + ")" + ] } ], "metadata": { diff --git a/src/qcodes/configuration/qcodesrc.json b/src/qcodes/configuration/qcodesrc.json index 583f36bf8326..37cc8c7c0569 100644 --- a/src/qcodes/configuration/qcodesrc.json +++ b/src/qcodes/configuration/qcodesrc.json @@ -79,7 +79,8 @@ "export_chunked_export_of_large_files_enabled": false, "export_chunked_threshold": 1000, "in_memory_cache": true, - "load_from_exported_file": false + "load_from_exported_file": false, + "snapshot_at_end": true }, "telemetry": { diff --git a/src/qcodes/configuration/qcodesrc_schema.json b/src/qcodes/configuration/qcodesrc_schema.json index ddd4929be22c..184dfee7a24c 100644 --- a/src/qcodes/configuration/qcodesrc_schema.json +++ b/src/qcodes/configuration/qcodesrc_schema.json @@ -382,6 +382,11 @@ "type": "boolean", "default": true, "description": "Should the data be cached in memory as it is measured. Useful to disable for large datasets to save on memory consumption." + }, + "snapshot_at_end": { + "type": "boolean", + "default": true, + "description": "Should a snapshot of the station be taken at the end of a measurement in addition to the one taken at the start. The end snapshot is stored in the dataset metadata under the 'end_snapshot' key." } }, "description": "Settings related to the DataSet and Measurement Context manager", diff --git a/src/qcodes/dataset/__init__.py b/src/qcodes/dataset/__init__.py index 0494069ab0b5..49caec6e41b9 100644 --- a/src/qcodes/dataset/__init__.py +++ b/src/qcodes/dataset/__init__.py @@ -47,6 +47,12 @@ ) from .measurements import Measurement from .plotting import plot_by_id, plot_dataset +from .snapshot_utils import ( + diff_param_snapshots, + diff_param_values_by_id, + diff_start_end_snapshot, + diff_start_end_snapshot_by_id, +) from .sqlite.connection import ( AtomicConnection, ) @@ -88,6 +94,10 @@ "call_params_threaded", "connect", "datasaver_builder", + "diff_param_snapshots", + "diff_param_values_by_id", + "diff_start_end_snapshot", + "diff_start_end_snapshot_by_id", "do0d", "do1d", "do2d", diff --git a/src/qcodes/dataset/data_set_protocol.py b/src/qcodes/dataset/data_set_protocol.py index cd31082e8809..20d48fada688 100644 --- a/src/qcodes/dataset/data_set_protocol.py +++ b/src/qcodes/dataset/data_set_protocol.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import os import warnings @@ -69,6 +70,11 @@ LOG = logging.getLogger(__name__) +# TODO(jenshnielsen): Consider adding a dedicated ``end_snapshot`` column to the +# runs table via a database upgrade rather than storing the end snapshot as +# metadata in a dynamic column. +END_SNAPSHOT_METADATA_KEY = "end_snapshot" + class CompletedError(RuntimeError): pass @@ -168,6 +174,14 @@ def add_snapshot(self, snapshot: str, overwrite: bool = False) -> None: ... @property def _snapshot_raw(self) -> str | None: ... + @property + def end_snapshot(self) -> dict[str, Any] | None: ... + + def add_end_snapshot(self, snapshot: str, overwrite: bool = False) -> None: ... + + @property + def _end_snapshot_raw(self) -> str | None: ... + def add_metadata(self, tag: str, metadata: Any) -> None: ... @property @@ -547,6 +561,51 @@ def dependent_parameters(self) -> tuple[ParamSpecBase, ...]: """ return tuple(self.description.interdeps.dependencies.keys()) + @property + def end_snapshot(self) -> dict[str, Any] | None: + """ + Snapshot taken at the end of the run as a dictionary (or None if no + such snapshot was taken). + """ + snapshot_json = self._end_snapshot_raw + if snapshot_json is not None: + return json.loads(snapshot_json) + else: + return None + + @property + def _end_snapshot_raw(self) -> str | None: + """ + Snapshot taken at the end of the run as a JSON-formatted string + (or None). + """ + snapshot_raw = self.metadata.get(END_SNAPSHOT_METADATA_KEY) + if snapshot_raw is None: + return None + if not isinstance(snapshot_raw, str): + raise TypeError( + f"Expected the end snapshot of run {self.guid} to be a string " + f"but got {type(snapshot_raw)}." + ) + return snapshot_raw + + def add_end_snapshot(self, snapshot: str, overwrite: bool = False) -> None: + """ + Add a snapshot taken at the end of the run to this dataset. + + Args: + snapshot: the raw JSON dump of the snapshot + overwrite: force overwrite an existing end snapshot + + """ + if self._end_snapshot_raw is None or overwrite: + self.add_metadata(END_SNAPSHOT_METADATA_KEY, snapshot) + else: + LOG.warning( + "This dataset already has an end snapshot. " + "Use overwrite=True to overwrite that" + ) + class DataSetType(StrEnum): DataSet = "DataSet" diff --git a/src/qcodes/dataset/measurements.py b/src/qcodes/dataset/measurements.py index 1d31ce4f5895..e20b98c9c888 100644 --- a/src/qcodes/dataset/measurements.py +++ b/src/qcodes/dataset/measurements.py @@ -8,6 +8,7 @@ import collections import io +import json import logging import traceback as tb_module import warnings @@ -54,7 +55,7 @@ ParamSpecBase, ) from qcodes.station import Station -from qcodes.utils import DelayedKeyboardInterrupt +from qcodes.utils import DelayedKeyboardInterrupt, NumpyJSONEncoder if TYPE_CHECKING: from types import TracebackType @@ -550,10 +551,13 @@ def __init__( dataset_class: DataSetType = DataSetType.DataSet, parent_span: trace.Span | None = None, registered_parameters: Sequence[ParameterBase] = (), + snapshot_at_end: bool | None = None, ) -> None: if in_memory_cache is None: in_memory_cache = qc.config.dataset.in_memory_cache in_memory_cache = cast("bool", in_memory_cache) + if snapshot_at_end is None: + snapshot_at_end = cast("bool", qc.config.dataset.snapshot_at_end) if interdeps is None: interdeps = InterDependencies_() @@ -581,6 +585,7 @@ def __init__( self._parent_span = parent_span self.ds: DataSetProtocol self._registered_parameters = registered_parameters + self._snapshot_at_end = snapshot_at_end @staticmethod def _calculate_write_period( @@ -601,8 +606,51 @@ def _calculate_write_period( write_period = cast("float", qc.config.dataset.write_period) return float(write_period) - def __enter__(self) -> DataSaver: - # multiple runners can be active at the same time. + def _build_snapshot(self) -> dict[str, Any]: + """ + Build a snapshot of the station (if any) and of the parameters + registered with this measurement. + """ + if self.station is None: + station = Station.default + else: + station = self.station + + snapshot: dict[str, Any] = {} + if station is not None: + snapshot["station"] = station.snapshot(update="Only_invalid") + if self._registered_parameters is not None: + parameter_snapshot = { + param.short_name: param.snapshot(update="Never") + for param in self._registered_parameters + } + parameter_snapshot.update( + { + param.register_name: param.snapshot(update="Never") + for param in self._registered_parameters + } + ) + snapshot["parameters"] = parameter_snapshot + return snapshot + + def _add_end_snapshot(self) -> None: + """ + Snapshot the station at the end of the measurement and store it on the + dataset. Failures are logged but never abort the measurement. + """ + try: + snapshot = self._build_snapshot() + self.ds.add_end_snapshot(json.dumps(snapshot, cls=NumpyJSONEncoder)) + except Exception: + log.exception( + "Could not create a snapshot at the end of the measurement " + "with guid: %s", + self.ds.guid, + ) + + def __enter__( + self, + ) -> DataSaver: # multiple runners can be active at the same time. # If we just activate them in order the first one # would be the parent of the next one but that is wrong # since they are siblings that should coexist with the @@ -659,27 +707,7 @@ def __enter__(self) -> DataSaver: raise RuntimeError("Does not support any other dataset classes") # .. and give the dataset a snapshot as metadata - if self.station is None: - station = Station.default - else: - station = self.station - - if station is not None: - snapshot = {"station": station.snapshot(update="Only_invalid")} - else: - snapshot = {} - if self._registered_parameters is not None: - parameter_snapshot = { - param.short_name: param.snapshot(update="Never") - for param in self._registered_parameters - } - parameter_snapshot.update( - { - param.register_name: param.snapshot(update="Never") - for param in self._registered_parameters - } - ) - snapshot["parameters"] = parameter_snapshot + snapshot = self._build_snapshot() self.ds.prepare( snapshot=snapshot, @@ -749,6 +777,9 @@ def __exit__( for func, args in self.exitactions: func(*args) + if self._snapshot_at_end: + self._add_end_snapshot() + if exception_type: # if an exception happened during the measurement, # log the exception @@ -1509,6 +1540,7 @@ def run( in_memory_cache: bool | None = True, dataset_class: DataSetType = DataSetType.DataSet, parent_span: trace.Span | None = None, + snapshot_at_end: bool | None = None, ) -> Runner: """ Returns the context manager for the experimental run @@ -1526,6 +1558,10 @@ def run( with. parent_span: An optional opentelemetry span that this should be registered a a child of if using opentelemetry. + snapshot_at_end: Should a snapshot of the station be taken at the end + of the measurement in addition to the one taken at the start. + By default the setting is read from the ``snapshot_at_end`` key + in the ``dataset`` section of the ``qcodesrc.json`` config file. """ if write_in_background is None: @@ -1547,6 +1583,7 @@ def run( dataset_class=dataset_class, parent_span=parent_span, registered_parameters=tuple(self._registered_parameters), + snapshot_at_end=snapshot_at_end, ) diff --git a/src/qcodes/dataset/snapshot_utils.py b/src/qcodes/dataset/snapshot_utils.py index 86600c3e3f2e..92472afbbee7 100644 --- a/src/qcodes/dataset/snapshot_utils.py +++ b/src/qcodes/dataset/snapshot_utils.py @@ -40,3 +40,53 @@ def diff_param_values_by_id(left_id: int, right_id: int) -> ParameterDiff: parameter values in each of their snapshots. """ return diff_param_snapshots(load_by_id(left_id), load_by_id(right_id)) + + +def diff_start_end_snapshot(dataset: DataSetProtocol) -> ParameterDiff: + """ + Given a dataset, returns the differences between the parameter values in + the snapshot taken at the start of the measurement and the snapshot taken + at the end of the measurement. + + Note that the snapshot at the end of a measurement is only taken if + snapshotting at the end is enabled. See the ``snapshot_at_end`` key in the + ``dataset`` section of the QCoDeS config. + + Args: + dataset: the dataset to compare the start and end snapshots of. + + Returns: + The differences between the start and the end snapshot where the start + snapshot is the left hand side and the end snapshot the right hand side. + + Raises: + RuntimeError: if the dataset does not contain both a start and an end + snapshot. + + """ + start_snapshot = dataset.snapshot + end_snapshot = dataset.end_snapshot + + if start_snapshot is None: + raise RuntimeError( + f"Tried to compare the start and end snapshot of run " + f"{dataset.run_id} but the snapshot taken at the start of the " + f"measurement is empty." + ) + if end_snapshot is None: + raise RuntimeError( + f"Tried to compare the start and end snapshot of run " + f"{dataset.run_id} but the snapshot taken at the end of the " + f"measurement is empty." + ) + + return diff_param_values(start_snapshot, end_snapshot) + + +def diff_start_end_snapshot_by_id(run_id: int) -> ParameterDiff: + """ + Given the ID of a dataset, returns the differences between the parameter + values in the snapshot taken at the start of the measurement and the + snapshot taken at the end of the measurement. + """ + return diff_start_end_snapshot(load_by_id(run_id)) diff --git a/src/qcodes/utils/__init__.py b/src/qcodes/utils/__init__.py index 62444140d418..1168ca8a6e70 100644 --- a/src/qcodes/utils/__init__.py +++ b/src/qcodes/utils/__init__.py @@ -21,7 +21,12 @@ from .numpy_utils import list_of_data_to_maybe_ragged_nd_array from .partial_utils import partial_with_docstring from .path_helpers import get_qcodes_path, get_qcodes_user_path -from .snapshot_helpers import ParameterDiff, diff_param_values, extract_param_values +from .snapshot_helpers import ( + ParameterDiff, + diff_param_values, + extract_param_values, + format_parameter_diff, +) from .threading_utils import RespondingThread, thread_map __all__ = [ @@ -38,6 +43,7 @@ "deep_update", "diff_param_values", "extract_param_values", + "format_parameter_diff", "full_class", "get_all_installed_package_versions", "get_qcodes_path", diff --git a/src/qcodes/utils/snapshot_helpers.py b/src/qcodes/utils/snapshot_helpers.py index 72299a5bf651..fda74ec54dce 100644 --- a/src/qcodes/utils/snapshot_helpers.py +++ b/src/qcodes/utils/snapshot_helpers.py @@ -14,6 +14,65 @@ class ParameterDiff(NamedTuple): right_only: ParameterDict[Any] changed: ParameterDict[tuple[Any, Any]] + def __str__(self) -> str: + return format_parameter_diff(self) + + +def _format_parameter_key(key: ParameterKey) -> str: + if isinstance(key, tuple): + return ".".join(key) + return key + + +def format_parameter_diff( + diff: ParameterDiff, + left_name: str = "left", + right_name: str = "right", +) -> str: + """ + Render a :class:`ParameterDiff` as a human-readable multi-line string. + + Args: + diff: the difference to render. + left_name: name used to refer to the left hand side snapshot. + right_name: name used to refer to the right hand side snapshot. + + Returns: + A human-readable representation of the differences. + + """ + lines: list[str] = [] + + if diff.changed: + lines.append(f"Changed parameters ({left_name} -> {right_name}):") + lines.extend( + f" {_format_parameter_key(key)}: {left!r} -> {right!r}" + for key, (left, right) in sorted( + diff.changed.items(), key=lambda item: _format_parameter_key(item[0]) + ) + ) + if diff.left_only: + lines.append(f"Parameters only in {left_name}:") + lines.extend( + f" {_format_parameter_key(key)}: {value!r}" + for key, value in sorted( + diff.left_only.items(), key=lambda item: _format_parameter_key(item[0]) + ) + ) + if diff.right_only: + lines.append(f"Parameters only in {right_name}:") + lines.extend( + f" {_format_parameter_key(key)}: {value!r}" + for key, value in sorted( + diff.right_only.items(), key=lambda item: _format_parameter_key(item[0]) + ) + ) + + if not lines: + return "No differences between the two snapshots." + + return "\n".join(lines) + def extract_param_values(snapshot: Snapshot) -> dict[ParameterKey, Any]: """ diff --git a/tests/dataset/test_measurement_extensions.py b/tests/dataset/test_measurement_extensions.py index 2d88f6819b94..24500fd9ace6 100644 --- a/tests/dataset/test_measurement_extensions.py +++ b/tests/dataset/test_measurement_extensions.py @@ -144,7 +144,11 @@ def test_context(default_params, default_database_and_experiment): }, data_vars=(meas1.name,), ) - assert datasets[0].metadata == metadata_dict + assert { + key: value + for key, value in datasets[0].metadata.items() + if key != "end_snapshot" + } == metadata_dict assert_dataset_as_expected( datasets[1], @@ -155,7 +159,11 @@ def test_context(default_params, default_database_and_experiment): }, data_vars=(meas2.name, meas3.name), ) - assert datasets[1].metadata == {} + assert { + key: value + for key, value in datasets[1].metadata.items() + if key != "end_snapshot" + } == {} def test_dond_into(default_params, default_database_and_experiment): diff --git a/tests/dataset/test_snapshot.py b/tests/dataset/test_snapshot.py index b723033574e0..b799646ba70f 100644 --- a/tests/dataset/test_snapshot.py +++ b/tests/dataset/test_snapshot.py @@ -3,10 +3,16 @@ import numpy import pytest +import qcodes as qc from qcodes.dataset.measurements import Measurement +from qcodes.dataset.snapshot_utils import ( + diff_start_end_snapshot, + diff_start_end_snapshot_by_id, +) from qcodes.instrument_drivers.mock_instruments import DummyInstrument from qcodes.parameters import ManualParameter, Parameter from qcodes.station import Station +from qcodes.utils import ParameterDiff, format_parameter_diff @pytest.fixture # scope is "function" per default @@ -171,3 +177,158 @@ def valid_getter() -> int: # valid cache -> not gotten, cached value used assert valid_calls["n"] == 0 assert params["p_valid"]["value"] == 7 + + +def test_end_snapshot_taken_by_default(experiment, dac, dmm) -> None: + station = Station() + station.add_component(dac) + station.add_component(dmm) + + dac.ch1(1) + + measurement = Measurement(experiment, station) + measurement.register_parameter(dac.ch1) + measurement.register_parameter(dmm.v1, setpoints=[dac.ch1]) + + with measurement.run() as data_saver: + data_saver.add_result((dac.ch1, 7), (dmm.v1, 5)) + dac.ch1(10) + + dataset = data_saver.dataset + + start_snapshot = dataset.snapshot + end_snapshot = dataset.end_snapshot + assert start_snapshot is not None + assert end_snapshot is not None + + assert ( + start_snapshot["station"]["instruments"]["dummy_dac"]["parameters"]["ch1"][ + "value" + ] + == 1 + ) + assert ( + end_snapshot["station"]["instruments"]["dummy_dac"]["parameters"]["ch1"][ + "value" + ] + == 10 + ) + + # the end snapshot is stored as metadata + assert dataset.metadata["end_snapshot"] == json.dumps(end_snapshot) + + +def test_end_snapshot_can_be_disabled(experiment, dac, dmm) -> None: + station = Station() + station.add_component(dac) + station.add_component(dmm) + + measurement = Measurement(experiment, station) + measurement.register_parameter(dac.ch1) + + with measurement.run(snapshot_at_end=False) as data_saver: + data_saver.add_result((dac.ch1, 7)) + + assert data_saver.dataset.snapshot is not None + assert data_saver.dataset.end_snapshot is None + assert "end_snapshot" not in data_saver.dataset.metadata + + +def test_end_snapshot_can_be_disabled_by_config(experiment, dac, dmm) -> None: + station = Station() + station.add_component(dac) + + measurement = Measurement(experiment, station) + measurement.register_parameter(dac.ch1) + + original = qc.config.dataset.snapshot_at_end + qc.config.dataset.snapshot_at_end = False + try: + with measurement.run() as data_saver: + data_saver.add_result((dac.ch1, 7)) + finally: + qc.config.dataset.snapshot_at_end = original + + assert data_saver.dataset.end_snapshot is None + + +def test_add_end_snapshot_does_not_overwrite(experiment, dac) -> None: + measurement = Measurement(experiment) + measurement.register_parameter(dac.ch1) + + with measurement.run() as data_saver: + data_saver.add_result((dac.ch1, 7)) + + dataset = data_saver.dataset + original = dataset._end_snapshot_raw + assert original is not None + + dataset.add_end_snapshot(json.dumps({"station": {"parameters": {}}})) + assert dataset._end_snapshot_raw == original + + dataset.add_end_snapshot( + json.dumps({"station": {"parameters": {}}}), overwrite=True + ) + assert dataset.end_snapshot == {"station": {"parameters": {}}} + + +def test_diff_start_end_snapshot(experiment, dac, dmm) -> None: + station = Station() + station.add_component(dac) + station.add_component(dmm) + + dac.ch1(1) + dac.ch2(2) + + measurement = Measurement(experiment, station) + measurement.register_parameter(dac.ch1) + + with measurement.run() as data_saver: + data_saver.add_result((dac.ch1, 7)) + dac.ch1(10) + + dataset = data_saver.dataset + + diff = diff_start_end_snapshot(dataset) + assert diff.changed[("dummy_dac", "ch1")] == (1, 10) + assert ("dummy_dac", "ch2") not in diff.changed + assert diff.left_only == {} + assert diff.right_only == {} + + # the same diff can be obtained from the run id + diff_by_id = diff_start_end_snapshot_by_id(dataset.run_id) + assert diff_by_id == diff + + +def test_diff_start_end_snapshot_raises_without_end_snapshot(experiment, dac) -> None: + measurement = Measurement(experiment) + measurement.register_parameter(dac.ch1) + + with measurement.run(snapshot_at_end=False) as data_saver: + data_saver.add_result((dac.ch1, 7)) + + with pytest.raises(RuntimeError, match="end of the measurement is empty"): + diff_start_end_snapshot(data_saver.dataset) + + +def test_format_parameter_diff() -> None: + diff = ParameterDiff( + left_only={"a": 1}, + right_only={("inst", "b"): 2}, + changed={("inst", "c"): (3, 4)}, + ) + + formatted = format_parameter_diff(diff, "start", "end") + assert formatted == ( + "Changed parameters (start -> end):\n" + " inst.c: 3 -> 4\n" + "Parameters only in start:\n" + " a: 1\n" + "Parameters only in end:\n" + " inst.b: 2" + ) + + assert str(diff) == format_parameter_diff(diff) + + empty = ParameterDiff(left_only={}, right_only={}, changed={}) + assert str(empty) == "No differences between the two snapshots."