etiket-sync-agent-qcodes 0.3.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from etiket_sync_agent_qcodes.qcodes_sync_class import QCoDeSSync, QCoDeSConfigData
2
+
3
+ __all__ = ["QCoDeSSync", "QCoDeSConfigData"]
4
+ __version__ = "0.3.0b1"
@@ -0,0 +1,93 @@
1
+ import pathlib
2
+ import dataclasses
3
+ import sqlite3
4
+ from typing import Optional
5
+
6
+ import etiket_sync_agent_qcodes
7
+
8
+ from etiket_sync_agent.crud.sync_sources import crud_sync_sources, SyncSources
9
+ from etiket_sync_agent.db import get_db_session_context
10
+
11
+ @dataclasses.dataclass
12
+ class QCoDeSConfigData:
13
+ database_path: pathlib.Path
14
+ set_up : str
15
+ static_attributes : Optional[dict] = dataclasses.field(default_factory=dict)
16
+
17
+ def __post_init__(self):
18
+ # ensure the path is of the type pathlib.Path (str is converted to Path) and expand ~
19
+ self.database_path = pathlib.Path(self.database_path).expanduser()
20
+
21
+ async def validate(self, current_sync_source : Optional[SyncSources] = None):
22
+ """
23
+ Validates the QCoDeS database configuration.
24
+
25
+ Checks:
26
+ 1. If the database_path path points to an existing file.
27
+ 2. If the file has a .db extension.
28
+ 3. If the file can be opened as an SQLite database.
29
+ 4. If the database file has already been added as a QCoDeS sync source.
30
+
31
+ Args:
32
+ current_source (Optional[SyncSources]): in case of update, the current sync source is provided.
33
+
34
+ Raises:
35
+ ValueError: If any validation check fails.
36
+
37
+ Returns:
38
+ True if all checks pass.
39
+ """
40
+ try:
41
+ abs_db_path = pathlib.Path(self.database_path).expanduser().resolve(strict=True)
42
+ except FileNotFoundError:
43
+ raise ValueError(f"The specified database file does not exist: {self.database_path}")
44
+
45
+ if not abs_db_path.is_file():
46
+ raise ValueError(f"The specified path is not a file: {abs_db_path}")
47
+
48
+ if abs_db_path.suffix != ".db":
49
+ raise ValueError(f"A QCoDeS database file must have a .db extension, not '{abs_db_path.suffix}'. Provided path: {abs_db_path}")
50
+
51
+ # test basic sqlite3 connection and verify QCoDeS database structure
52
+ try:
53
+ conn = sqlite3.connect(f'file:{abs_db_path}?mode=ro', uri=True)
54
+ cursor = conn.cursor()
55
+
56
+ # Query sqlite_master to check for QCoDeS-specific tables
57
+ cursor.execute(
58
+ "SELECT name FROM sqlite_master WHERE type='table' AND name IN ('runs', 'experiments')"
59
+ )
60
+ tables = {row[0] for row in cursor.fetchall()}
61
+
62
+ conn.close()
63
+
64
+ # Verify required QCoDeS tables exist
65
+ required_tables = {'runs', 'experiments'}
66
+ missing_tables = required_tables - tables
67
+ if missing_tables:
68
+ raise ValueError(
69
+ f"The database '{abs_db_path}' does not appear to be a valid QCoDeS database. "
70
+ f"Missing required tables: {', '.join(sorted(missing_tables))}"
71
+ )
72
+ except sqlite3.Error as e:
73
+ raise ValueError(f"Error opening SQLite database file '{abs_db_path}': {e}") from e
74
+
75
+ # check if the database file is already added.
76
+ async with get_db_session_context() as session:
77
+ sync_sources = await crud_sync_sources.list_sync_sources(session)
78
+ for sync_source in sync_sources:
79
+ if sync_source.backend == etiket_sync_agent_qcodes.__name__:
80
+ try:
81
+ existing_path_str = sync_source.config_data['database_path']
82
+ existing_path = pathlib.Path(existing_path_str).resolve()
83
+ except Exception as e:
84
+ # If an existing path can't be resolved, log it but continue validation
85
+ print(f"Warning: Could not resolve existing database path '{existing_path_str}' for sync source '{sync_source.name}' ({sync_source.id}): {e}")
86
+ continue
87
+
88
+ if abs_db_path == existing_path:
89
+ if current_sync_source is not None and current_sync_source.id == sync_source.id:
90
+ continue
91
+ raise ValueError(f"The database file '{abs_db_path}' is already added as sync source '{sync_source.name}'.")
92
+
93
+ return True
@@ -0,0 +1,116 @@
1
+ from typing import List, Dict
2
+
3
+ from qcodes.instrument import Instrument
4
+ from qcodes.parameters import Parameter
5
+
6
+ class QHMetaData(Instrument):
7
+ """
8
+ A QCoDeS Instrument subclass that manages metadata for a dataset within the QHarbor framework.
9
+
10
+ This instrument stores tags and attributes, which the synchronization agent will recognize and add as such to the dataset.
11
+ """
12
+
13
+ def __init__(self, name : str, static_tags: List[str] = [],
14
+ static_attributes: Dict[str, str] = {}):
15
+ """
16
+ Initialize a QHMetaData instance.
17
+
18
+ Args:
19
+ name (str): The name of the instrument, this should be "qh_meta"
20
+ static_tags (List[str]): A list of static tags that are always included in
21
+ the metadata. These tags remain after the reset method is called.
22
+ static_attributes (Dict[str, str]): A dictionary of static attributes that are
23
+ always included in the metadata. These attributes remain after the reset method is called.
24
+ """
25
+ super().__init__(name)
26
+
27
+ self._static_tags = static_tags
28
+ self._static_attributes = static_attributes
29
+
30
+ self._dynamic_tags = []
31
+ self._dynamic_attributes = {}
32
+
33
+ self.add_parameter("tags", get_cmd=self.__get_tags)
34
+ self.add_parameter("attributes", get_cmd=self.__get_attributes)
35
+
36
+ def add_tags(self, tags: List[str]) -> None:
37
+ """
38
+ Add dynamic tags to the metadata. These tags will be cleared upon reset.
39
+
40
+ Args:
41
+ tags (List[str]): A list of tags to add.
42
+ """
43
+ self._dynamic_tags.extend(tags)
44
+
45
+ def add_attributes(self, attributes: Dict[str, str]) -> None:
46
+ """
47
+ Add dynamic attributes to the metadata. These attributes will be cleared upon reset.
48
+
49
+ Args:
50
+ attributes (Dict[str, str]): A dictionary of key-value pairs to add as attributes.
51
+ """
52
+ self._dynamic_attributes.update(attributes)
53
+
54
+ def reset(self) -> None:
55
+ """
56
+ Clear all dynamic tags and attributes.
57
+
58
+ This does not affect static tags or attributes, nor the list of important parameters.
59
+ """
60
+ self._dynamic_tags.clear()
61
+ self._dynamic_attributes.clear()
62
+
63
+ def __get_tags(self) -> List[str]:
64
+ """
65
+ Return a combined list of static and dynamic tags.
66
+
67
+ Returns:
68
+ List[str]: The current list of tags (static + dynamic).
69
+ """
70
+ return list(set(self._static_tags + self._dynamic_tags))
71
+
72
+ def __get_attributes(self) -> Dict[str, str]:
73
+ """
74
+ Return a combined dictionary containing static, dynamic attributes, and parameter values.
75
+
76
+ Returns:
77
+ Dict[str, str]: A dictionary with the static and dynamic attributes.
78
+ """
79
+ attr = {**self._static_attributes}
80
+ attr.update(self._dynamic_attributes)
81
+ return attr
82
+
83
+ def get_idn(self):
84
+ return {'vendor': 'QHarbor', 'model': 'QHarbor Metadata Manager', 'serial': None, 'firmware': None}
85
+
86
+ def validate_parameter(parameter: Parameter) -> Parameter:
87
+ # for when we add parameters to the metadata.
88
+ """
89
+ Validate that a parameter is an instance of qcodes.Parameter and its value is int or float.
90
+
91
+ Args:
92
+ parameter (Parameter): The qcodes.Parameter to validate.
93
+
94
+ Raises:
95
+ ValueError: If the parameter is not a qcodes.Parameter, or if its current value is not a float or int.
96
+ ValueError: If the parameter value cannot be retrieved (wrapped).
97
+
98
+ Returns:
99
+ Parameter: The original parameter if validation succeeds.
100
+ """
101
+ if not isinstance(parameter, Parameter):
102
+ raise ValueError(f"Invalid parameter type: {type(parameter)}, expected qcodes.Parameter.")
103
+
104
+ try:
105
+ value = parameter.get()
106
+ if not isinstance(value, (float, int)):
107
+ raise ValueError(
108
+ f"Invalid parameter value type: {type(value)}, "
109
+ f"expected float or int (Parameter: {parameter.name})."
110
+ )
111
+ except ValueError as e:
112
+ raise e
113
+ except Exception as e:
114
+ raise ValueError(f"Could not get value from parameter: {parameter.name}") from e
115
+
116
+ return parameter
@@ -0,0 +1,175 @@
1
+ import typing
2
+ import sqlite3
3
+ import qcodes as qc
4
+ import os
5
+ import logging
6
+ import xarray
7
+ import asyncio
8
+ from typing import Tuple
9
+
10
+ from datetime import datetime
11
+
12
+ from etiket_sync_agent.backends.sync_source_abstract import SyncSourceDatabaseBase, ScopeRequirement
13
+ from etiket_sync_agent.sync.sync_records.manager import SyncRecordManager
14
+ from etiket_sync_agent.sync.sync_utilities import file_info, sync_utilities,\
15
+ dataset_info, FileType
16
+ from etiket_sync_agent.schemas import SyncItemSchema, SyncItemCreate
17
+
18
+ from etiket_sync_agent_qcodes.real_time_sync import QCoDeS_live_sync
19
+
20
+ from etiket_sync_agent_qcodes.qcodes_config_class import QCoDeSConfigData
21
+ from etiket_sync_agent_qcodes.utility.extract_metadata_from_QCoDeS import extract_labels_and_attributes_from_snapshot, MetaDataExtractionError
22
+
23
+ from qcodes.dataset import load_by_guid
24
+ from qcodes.dataset.data_set import DataSet
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ class QCoDeSSync(SyncSourceDatabaseBase):
29
+ sync_agent_name: typing.ClassVar[str] = "QCoDeS"
30
+ config_data_class: typing.ClassVar[typing.Type[QCoDeSConfigData]] = QCoDeSConfigData
31
+ scope_requirement: typing.ClassVar[ScopeRequirement] = ScopeRequirement.REQUIRED
32
+ supports_scope_mapping: typing.ClassVar[bool] = False
33
+ live_sync_implemented: typing.ClassVar[bool] = True
34
+ has_owner: typing.ClassVar[bool] = True
35
+
36
+ @staticmethod
37
+ async def getNewDatasets(config_data: QCoDeSConfigData, last_sync_item: SyncItemSchema | None) -> typing.List[SyncItemCreate] | None:
38
+ if not os.path.exists(config_data.database_path):
39
+ raise FileNotFoundError(f"Database file not found at {config_data.database_path}")
40
+
41
+ qc.config.core.db_location = str(config_data.database_path)
42
+
43
+ newSyncIdentifiers = []
44
+
45
+ with sqlite3.connect(config_data.database_path) as conn:
46
+ conn.execute("PRAGMA journal_mode=WAL;")
47
+ last_run_id = -1
48
+ if last_sync_item is not None:
49
+ last_run_id = last_sync_item.syncPriority
50
+
51
+ get_newer_guid_query = """SELECT run_id, guid FROM runs WHERE run_id > ? ORDER BY run_id ASC"""
52
+
53
+ cursor = conn.cursor()
54
+ cursor.execute(get_newer_guid_query, (int(last_run_id),))
55
+ rows = cursor.fetchall()
56
+
57
+ newSyncIdentifiers += [SyncItemCreate(dataIdentifier=str(row[1]), syncPriority=row[0]) for row in rows]
58
+ return newSyncIdentifiers
59
+
60
+ @staticmethod
61
+ async def checkLiveDataset(configData: QCoDeSConfigData, syncIdentifier: SyncItemSchema, maxPriority: bool) -> bool:
62
+ if maxPriority is False:
63
+ return False
64
+
65
+ qc.config.core.db_location = str(configData.database_path)
66
+
67
+ ds_qc = load_by_guid(syncIdentifier.dataIdentifier)
68
+ return not ds_qc.completed
69
+
70
+ @staticmethod
71
+ async def syncDatasetNormal(configData: QCoDeSConfigData, syncIdentifier: SyncItemSchema, sync_record: SyncRecordManager):
72
+ qc.config.core.db_location = str(configData.database_path)
73
+
74
+ with sync_record.task("Creating dataset from QCoDeS dataset (not live)"):
75
+ ds_qc, ds_xr = await create_ds_from_qcodes(configData, syncIdentifier, False, sync_record)
76
+
77
+ with sync_record.task("Getting and uploading the measured data to the server"):
78
+ if ds_qc.run_timestamp_raw is not None:
79
+ created_time = datetime.fromtimestamp(ds_qc.run_timestamp_raw)
80
+ else:
81
+ raise ValueError("Run timestamp is None")
82
+
83
+ f_info = file_info(name = 'measurement', fileName = 'measured_data.hdf5',
84
+ fileType= FileType.HDF5_NETCDF,
85
+ created = created_time, file_generator = "QCoDeS")
86
+ await sync_utilities.upload_xarray(ds_xr, syncIdentifier, f_info, sync_record)
87
+
88
+ @staticmethod
89
+ async def syncDatasetLive(configData: QCoDeSConfigData, syncIdentifier: SyncItemSchema, sync_record: SyncRecordManager):
90
+ qc.config.core.db_location = str(configData.database_path)
91
+
92
+ with sync_record.task("Creating dataset from QCoDeS dataset (live)"):
93
+ await create_ds_from_qcodes(configData, syncIdentifier, True, sync_record)
94
+ with sync_record.task("Starting live feed to a local hdf5 file"):
95
+ await asyncio.get_event_loop().run_in_executor(
96
+ None, QCoDeS_live_sync,
97
+ syncIdentifier.dataIdentifier, str(configData.database_path), syncIdentifier.datasetUUID
98
+ )
99
+ sync_record.add_log("Live feed completed.")
100
+
101
+ async def create_ds_from_qcodes(configData: QCoDeSConfigData, syncIdentifier: SyncItemSchema, live : bool, sync_record: SyncRecordManager) -> Tuple[DataSet, xarray.Dataset]:
102
+ sync_record.add_log("Loading dataset and extracting metadata from QCoDeS with GUID: " + syncIdentifier.dataIdentifier)
103
+ ds_qc = load_by_guid(syncIdentifier.dataIdentifier)
104
+ sync_record.add_log("Dataset loaded from QCoDeS, will start extracting metadata")
105
+
106
+ if ds_qc.run_timestamp_raw is not None:
107
+ collected_time = datetime.fromtimestamp(ds_qc.run_timestamp_raw)
108
+ else:
109
+ raise ValueError("Run timestamp is None")
110
+
111
+ ranking = 0
112
+ if 'inspectr_tag' in ds_qc.metadata.keys():
113
+ if ds_qc.metadata['inspectr_tag'] == 'star':
114
+ ranking = 1
115
+ if ds_qc.metadata['inspectr_tag'] == 'cross':
116
+ ranking = -1
117
+
118
+ # get variable names in the dataset, this is handy for searching!
119
+ ds_xr = ds_qc.to_xarray_dataset()
120
+ keywords = set()
121
+ try:
122
+ for key in ds_xr.keys():
123
+ if 'long_name' in ds_xr[key].attrs.keys():
124
+ keywords.add(ds_xr[key].attrs['long_name'])
125
+ continue
126
+ if 'name' in ds_xr[key].attrs.keys():
127
+ keywords.add(ds_xr[key].attrs['name'])
128
+
129
+ for key in ds_xr.coords:
130
+ if 'long_name' in ds_xr[key].attrs.keys():
131
+ keywords.add(ds_xr[key].attrs['long_name'])
132
+ continue
133
+ if 'name' in ds_xr[key].attrs.keys():
134
+ keywords.add(ds_xr[key].attrs['name'])
135
+ except Exception:
136
+ pass
137
+
138
+ name_lines = ds_qc.name.splitlines()
139
+ name = name_lines[0] if name_lines else ""
140
+
141
+ additional_description = "\n".join(name_lines[1:]) if len(name_lines) > 1 else ""
142
+ description = f"database : {os.path.basename(configData.database_path)} | run ID : {ds_qc.run_id} | GUID : {ds_qc.guid} | exp name : {ds_qc.exp_name}"
143
+
144
+ if additional_description:
145
+ description += f"\n\n{additional_description}"
146
+
147
+ attributes = {"sample" : ds_qc.sample_name,
148
+ "set-up" : configData.set_up}
149
+ if configData.static_attributes is not None:
150
+ attributes.update(configData.static_attributes)
151
+
152
+ try: # experimental
153
+ if ds_qc.snapshot is not None:
154
+ extra_labels, extra_attributes = extract_labels_and_attributes_from_snapshot(ds_qc.snapshot)
155
+ else:
156
+ extra_labels = []
157
+ extra_attributes = {}
158
+ except MetaDataExtractionError:
159
+ logger.exception("Could not extract labels and attributes from snapshot.")
160
+ extra_labels = []
161
+ extra_attributes = {}
162
+
163
+ keywords.update(extra_labels)
164
+ attributes.update(extra_attributes)
165
+
166
+ sync_record.add_log("Creating dataset info")
167
+ ds_info = dataset_info(name = name, datasetUUID = syncIdentifier.datasetUUID,
168
+ alt_uid = ds_qc.guid, scopeUUID = syncIdentifier.scopeIdentifier,
169
+ created = collected_time, keywords = list(keywords), description = description,
170
+ ranking=ranking,
171
+ # exp_name not added, since some people use it to name their experiments ...
172
+ attributes = attributes)
173
+
174
+ await sync_utilities.create_or_update_dataset(live, syncIdentifier, ds_info, sync_record)
175
+ return ds_qc, ds_xr
@@ -0,0 +1,143 @@
1
+ import sqlite3
2
+ import logging
3
+ import time
4
+ import io
5
+ import os
6
+ import numpy as np
7
+
8
+ from qcodes import Parameter
9
+ from uuid import UUID
10
+
11
+ from etiket_client.remote.endpoints.models.types import FileType
12
+ from qdrive.measurement.data_collector import data_collector, from_QCoDeS_parameter
13
+ from qdrive.dataset.dataset import dataset
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # Timeout for live sync - if no new data is received within this time, sync exits
18
+ LIVE_SYNC_TIMEOUT_SECONDS = 10 * 60 # 10 minutes
19
+
20
+ # Polling interval when waiting for new data
21
+ LIVE_SYNC_POLL_INTERVAL_SECONDS = 0.1
22
+
23
+ def QCoDeS_live_sync(guid : str, database : str, datasetUUID : UUID):
24
+ print("Starting real time sync")
25
+ conn = sqlite3.connect(f'{database}')
26
+ conn.execute('pragma journal_mode=wal')
27
+
28
+ c = conn.cursor()
29
+
30
+ # Get run_id from guid
31
+ c.execute("SELECT run_id FROM runs WHERE guid = ?", (guid,))
32
+ result = c.fetchone()
33
+ if result is None:
34
+ raise ValueError(f"No run found with guid: {guid}")
35
+ run_id = result[0]
36
+
37
+ c.execute("SELECT * FROM layouts WHERE run_id = ?", (run_id,))
38
+ layout = c.fetchall()
39
+
40
+ parameters = {}
41
+ parameters_by_name = {}
42
+ for layout_item in layout:
43
+ parameters[layout_item[0]] = Parameter(layout_item[2], label=layout_item[3], unit=layout_item[4])
44
+ parameters_by_name[layout_item[2]] = parameters[layout_item[0]]
45
+
46
+ dependency_ids= list(parameters.keys())
47
+ c.execute(f"Select * from dependencies where dependent in ({','.join(['?']*len(dependency_ids))})",
48
+ dependency_ids)
49
+ dependencies = c.fetchall()
50
+
51
+ relationships = {}
52
+ for dep in dependencies:
53
+ if dep[0] not in relationships.keys():
54
+ relationships[dep[0]] = []
55
+ relationships[dep[0]].append(dep[1])
56
+
57
+ native_ds = dataset(datasetUUID)
58
+ dc = data_collector(native_ds, FileType.HDF5_CACHE)
59
+
60
+ try :
61
+ for k, v in relationships.items():
62
+ dep = [parameters[i] for i in v]
63
+ dc += from_QCoDeS_parameter(parameters[k], dep, dc)
64
+
65
+ sync_idx = 0
66
+ res = c.execute("Select result_table_name, snapshot from runs where run_id = ?", (run_id,)).fetchone()
67
+ table_name = res[0]
68
+ snapshot_json = res[1]
69
+
70
+ dc.set_attr('snapshot', snapshot_json)
71
+ dc._enable_swmr() # manually set this, sometimes it takes long for qCoDeS to start writing data ... .
72
+ except Exception as e:
73
+ if (hasattr(dc, 'lock_file')):
74
+ if (dc.lock_file is not None):
75
+ if os.path.isfile(dc.lock_file):
76
+ os.remove(dc.lock_file)
77
+ dc.complete()
78
+ raise e
79
+
80
+ t_last_fetch = time.time()
81
+
82
+ try:
83
+ while not (check_complete(c, run_id) and sync_idx == getMaxId(c, table_name)):
84
+ c.execute(f"SELECT * FROM '{table_name}' WHERE id > ?", (sync_idx,))
85
+ c_names = [description[0] for description in c.description]
86
+
87
+ data = c.fetchall()
88
+ if len(data) == 0:
89
+ if t_last_fetch + LIVE_SYNC_TIMEOUT_SECONDS < time.time():
90
+ logger.warning(f"No new data found in the last {LIVE_SYNC_TIMEOUT_SECONDS} seconds. Exiting real time sync.")
91
+ raise TimeoutError(f"No new data found in the last {LIVE_SYNC_TIMEOUT_SECONDS} seconds. Exiting real time sync. Expected that the measurement was set to complete.")
92
+
93
+ # check if a new run has already been started:
94
+ max_run_id = c.execute("SELECT MAX(run_id) FROM runs").fetchone()[0]
95
+ if max_run_id != run_id:
96
+ break
97
+
98
+ time.sleep(LIVE_SYNC_POLL_INTERVAL_SECONDS)
99
+ continue
100
+ else:
101
+ t_last_fetch = time.time()
102
+
103
+ for d in data:
104
+ array_size = None
105
+ data_to_write = {}
106
+ for i, name in enumerate(c_names[1:]):
107
+ value = d[i+1]
108
+ if value is not None:
109
+ try:
110
+ value = float(value)
111
+ except Exception as e:
112
+ # array parameters are stored as a blob :/
113
+ value = np.load(io.BytesIO(value))
114
+ if array_size is not None and array_size != value.size:
115
+ raise ValueError("Array size mismatch in the data") from e
116
+ array_size = value.size
117
+ data_to_write[parameters_by_name[name]] = value
118
+
119
+ if array_size is None:
120
+ dc.add_data(data_to_write)
121
+ else:
122
+ for i in range(array_size):
123
+ data_item_to_write = {}
124
+ for k, v in data_to_write.items():
125
+ if isinstance(v, np.ndarray):
126
+ data_item_to_write[k] = v[i]
127
+ else:
128
+ data_item_to_write[k] = v
129
+ dc.add_data(data_item_to_write)
130
+
131
+ sync_idx = data[-1][0]
132
+ except Exception as e:
133
+ logger.exception("Real time sync failed with error :: %s", e)
134
+ finally:
135
+ dc.complete()
136
+
137
+ def check_complete(cursor : sqlite3.Cursor, run_id : int):
138
+ cursor.execute("Select is_completed from runs where run_id = ?", (run_id,))
139
+ return cursor.fetchone()[0]
140
+
141
+ def getMaxId(cursor : sqlite3.Cursor, table_name : str):
142
+ cursor.execute(f"SELECT MAX(id) FROM '{table_name}'")
143
+ return cursor.fetchone()[0]
File without changes
@@ -0,0 +1,36 @@
1
+ from typing import List, Dict, Union, Tuple
2
+
3
+ class MetaDataExtractionError(Exception):
4
+ pass
5
+
6
+ def extract_labels_and_attributes_from_snapshot(snapshot : dict) -> Tuple[List[str], Dict[str, Union[Dict, str]]]:
7
+ """
8
+ Extract labels and attributes from a snapshot dictionary from the QH_MetadataManager.
9
+
10
+ Args:
11
+ snapshot (dict): The snapshot dictionary to extract metadata from.
12
+
13
+ Returns:
14
+ List[str]: A list of labels extracted from the snapshot.
15
+ Dict[str, Union[float, int, str]]: A dictionary of attributes extracted from the snapshot.
16
+ """
17
+ try:
18
+ station = snapshot.get("station", {})
19
+ instruments = station.get("instruments", {})
20
+ metadata = instruments.get("qh_meta", {})
21
+ params = metadata.get("parameters", {})
22
+ tags = params.get("tags", {}).get("value", [])
23
+ attributes = params.get("attributes", {}).get("value", {})
24
+
25
+ # fix attr (currently no good support for parameter on the server side)
26
+ attr = {}
27
+ for key, value in attributes.items():
28
+ if isinstance(value, dict):
29
+ if "value" in value:
30
+ attr[key] = str(value.get("value"))
31
+ else:
32
+ attr[key] = value
33
+
34
+ return tags, attr
35
+ except Exception as e:
36
+ raise MetaDataExtractionError("Could not extract labels and attributes from snapshot.") from e
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: etiket_sync_agent_qcodes
3
+ Version: 0.3.0b1
4
+ Summary: QCoDeS backend for eTiKeT sync agent
5
+ Author: QHarbor team
6
+ License-Expression: LicenseRef-Proprietary
7
+ Project-URL: Homepage, https://qharbor.nl
8
+ Project-URL: Documentation, https://docs.qharbor.nl
9
+ Keywords: etiket,sync,backend,qcodes,quantum
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENCE
27
+ Requires-Dist: etiket_sync_agent>=0.3.0b1
28
+ Requires-Dist: qcodes
29
+ Requires-Dist: xarray
30
+ Dynamic: license-file
31
+
32
+ # eTiKeT Sync Agent - QCoDeS Backend
33
+
34
+ Backend for synchronizing QCoDeS datasets with the eTiKeT platform. This backend reads measurements from QCoDeS SQLite databases and syncs them to the cloud.
35
+
36
+ ## Installation
37
+
38
+ Install the QCoDeS backend using the eTiKeT Sync SDK:
39
+
40
+ ```python
41
+ from etiket_sdk.sync import Backends
42
+
43
+ # Install the latest version
44
+ Backends.install_from_pypi("etiket-sync-agent-qcodes")
45
+ ```
46
+
47
+ > ⚠️ **Important:** This backend installs a specific version of QCoDeS. If the installed version is newer than what your main environment uses, it may upgrade your database schema, making it unreadable by older QCoDeS versions. See [Version Pinning](#version-pinning) below.
48
+
49
+ The package is automatically discovered by `etiket_sync_agent` through the entry-point system. Once installed, you can verify with:
50
+
51
+ ```python
52
+ # List installed backends
53
+ print(Backends.list())
54
+
55
+ # Get details for the QCoDeS backend
56
+ backend = Backends.get("etiket_sync_agent_qcodes")
57
+ print(backend)
58
+ ```
59
+
60
+ ### Version Pinning
61
+
62
+ By default, installing the backend also installs the latest version of `qcodes`. If you need a specific QCoDeS version to match your main working environment, you can install it separately:
63
+
64
+ **Why this matters:** Newer QCoDeS versions may upgrade your SQLite database schema. Once upgraded, older QCoDeS versions may not be able to read the database. This is primarily a concern for very old QCoDeS versions.
65
+
66
+ ```python
67
+ # Check your current QCoDeS version in your working environment
68
+ import qcodes
69
+ print(qcodes.__version__)
70
+
71
+ # Then install a specific QCoDeS version in the sync agent
72
+ Backends.install_from_pypi("qcodes", version="x.x.x")
73
+ ```
74
+
75
+ ### Updating the Backend
76
+
77
+ ```python
78
+ # Update to the latest version
79
+ Backends.update_from_pypi("etiket-sync-agent-qcodes")
80
+ ```
81
+
82
+ ## What Gets Synchronized
83
+
84
+ When a QCoDeS measurement is synced, the following data is extracted and uploaded:
85
+
86
+ | QCoDeS Data | eTiKeT Field | Description |
87
+ |-------------|--------------|-------------|
88
+ | `guid` | `alt_uid` | Unique identifier for the measurement |
89
+ | Measurement name | `name` | Name of the dataset |
90
+ | `run_timestamp` | `collected` | When the measurement was taken |
91
+ | Parameter labels | `tags` | Extracted from instrument/parameter metadata |
92
+ | Snapshot instruments | `attributes` | Instrument settings stored in the snapshot |
93
+ | xarray dataset | HDF5 file | The actual measurement data |
94
+
95
+
96
+ ### Metadata Sources
97
+
98
+ The backend extracts metadata from multiple sources:
99
+
100
+ 1. **QCoDeS Snapshot**: Instrument labels and parameter values used in the measurement are added as tags.
101
+ 2. **QHMetaData Instrument**: Custom tags and attributes (see below)
102
+ 3. **`inspectr_tag` metadata**: Ranking (`star` = +1, `cross` = -1)
103
+ 4. **Config `static_attributes`**: Static attributes defined in the sync source configuration
104
+
105
+ ## Configuration
106
+
107
+ The QCoDeS backend requires a `QCoDeSConfigData` configuration with the following fields:
108
+
109
+ | Field | Type | Required | Description |
110
+ |-------|------|----------|-------------|
111
+ | `database_path` | `Path` or `str` | Yes | Path to the QCoDeS SQLite database file (`.db`) |
112
+ | `set_up` | `str` | Yes | Name of the experimental setup (added as `set-up` attribute) |
113
+ | `static_attributes` | `dict[str, str]` | No | Optional static key-value pairs added to every dataset |
114
+
115
+ ### Example Configuration
116
+
117
+ Example using the `etiket-sdk` package:
118
+
119
+ ```python
120
+ from etiket_sdk.sync import SyncSources
121
+
122
+ SyncSources.create(
123
+ name="my_qcodes_source",
124
+ backend_identifier="etiket_sync_agent_qcodes",
125
+ config_data={
126
+ "database_path": "/path/to/experiments.db",
127
+ "set_up": "dilution_fridge_1",
128
+ "static_attributes": { # optional
129
+ "project": "qubit_calibration",
130
+ "lab": "Building A - Room 101"
131
+ }
132
+ },
133
+ default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
134
+ )
135
+ ```
136
+
137
+ ---
138
+
139
+ ## Adding QCoDeS Metadata to a Dataset
140
+
141
+ You can attach tags and attributes to a dataset by adding the `QHMetaData` instrument to your QCoDeS Station. Because the instrument is recorded in the QCoDeS snapshot, the sync agent extracts the tags and attributes and adds them to the QHarbor dataset.
142
+
143
+ You can add both **static** and **dynamic** metadata:
144
+
145
+ - **Static metadata**: Stays the same for all measurements (e.g., project, lab PC)
146
+ - **Dynamic metadata**: Changes per run (e.g., calibration type, measurement type)
147
+
148
+ ### Initializing the Instrument
149
+
150
+ There are two ways to define the instrument: directly in Python or via a Station YAML file.
151
+
152
+ #### Using Python
153
+
154
+ ```python
155
+ from etiket_sync_agent_qcodes.qcodes_metadata import QHMetaData
156
+ from qcodes import Station
157
+
158
+ station = Station()
159
+
160
+ # Create and register the instrument
161
+ qh_meta = QHMetaData(
162
+ "qh_meta",
163
+ static_tags=["cool_experiment"],
164
+ static_attributes={"project": "resonator project"},
165
+ )
166
+ station.add_component(qh_meta)
167
+ ```
168
+
169
+ > **Note**: The instrument name **must** be `qh_meta`. Do not change this.
170
+
171
+ #### Using a YAML Configuration File
172
+
173
+ Add the following to your Station YAML config:
174
+
175
+ ```yaml
176
+ instruments:
177
+ qh_meta:
178
+ type: etiket_sync_agent_qcodes.qcodes_metadata.QHMetaData
179
+ init:
180
+ static_tags:
181
+ - "cool_experiment"
182
+ static_attributes:
183
+ project: "resonator project"
184
+ measurement_computer: "LAB-A-PC-5"
185
+ ```
186
+
187
+ > **Note**: The key **must** be `qh_meta`. Do not change this.
188
+
189
+ Load the instrument from the config:
190
+
191
+ ```python
192
+ from qcodes import Station
193
+
194
+ # Replace with your config file name
195
+ station = Station(config_file='qc_config.yaml')
196
+ station.load_instrument("qh_meta")
197
+ ```
198
+
199
+ For more information, see the QCoDeS docs: [Configuring the Station by using a YAML configuration file](https://microsoft.github.io/Qcodes/examples/basic_examples/Station.html#Configuring-the-Station-by-using-a-YAML-configuration-file).
200
+
201
+ ### Runtime Usage
202
+
203
+ Access the instrument and set per-run metadata:
204
+
205
+ ```python
206
+ # If defined via Station YAML
207
+ qh_meta = station.components["qh_meta"]
208
+
209
+ # Start of run: clear dynamic values, then set new ones
210
+ qh_meta.reset()
211
+ qh_meta.add_tags(["testing", "calibration"])
212
+ qh_meta.add_attributes({
213
+ "calibration": "ALLXY",
214
+ "qubit": "Q1",
215
+ })
216
+
217
+ # ... measurement happens here ...
218
+ ```
219
+
220
+ ### Behavior Notes
221
+
222
+ | Aspect | Behavior |
223
+ |--------|----------|
224
+ | **Static vs Dynamic** | Static tags/attributes are set at construction and persist across `reset()`. Dynamic ones are cleared by `reset()`. |
225
+ | **Attribute Overrides** | Dynamic attributes with the same key as a static attribute will override the static value. |
226
+ | **Tag Duplicates** | Tags are deduplicated using a `set()`. |
227
+
228
+ ---
229
+
230
+ ## Features
231
+
232
+ - Direct integration with QCoDeS datasets
233
+ - Automatic metadata extraction from snapshots
234
+ - Live sync support for running measurements
235
+ - xarray dataset conversion
236
+ - Custom metadata via `QHMetaData` instrument
237
+
238
+ ## Requirements
239
+
240
+ - Python >= 3.10
241
+ - QCoDeS >= 0.52
242
+
243
+ ## License
244
+
245
+ Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
@@ -0,0 +1,13 @@
1
+ etiket_sync_agent_qcodes/__init__.py,sha256=sGDfPK9czy7e0IrJup6IIW9wAq3byIuJfqolWOSapPE,154
2
+ etiket_sync_agent_qcodes/qcodes_config_class.py,sha256=nNODFGd_qOKMY2ZlKwKuMV8-n0rz-lvQK6yKtgKFiH8,4171
3
+ etiket_sync_agent_qcodes/qcodes_metadata.py,sha256=mLHAUyHlvcDf3hT8wIvJMll7ZH6FLzcvSOj0hFJMQVU,4226
4
+ etiket_sync_agent_qcodes/qcodes_sync_class.py,sha256=wesnxBnLZAF4a0qNW7axNxq4Hp09R3Vwc9nSA6wPtDU,7933
5
+ etiket_sync_agent_qcodes/real_time_sync.py,sha256=OnMnzLfhdabHszeVEaFEeiVLVHu2KtCw2eJwhC0j_ic,5669
6
+ etiket_sync_agent_qcodes/utility/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ etiket_sync_agent_qcodes/utility/extract_metadata_from_QCoDeS.py,sha256=ue8B-Dc_WJUvugYSze4C-sDzmw8lZo_wtQ45RRvl1AU,1409
8
+ etiket_sync_agent_qcodes-0.3.0b1.dist-info/licenses/LICENCE,sha256=tdZwE43Th9efUgN8-4UpMUyh0kYQ4Dk678agSuhpnc0,2357
9
+ etiket_sync_agent_qcodes-0.3.0b1.dist-info/METADATA,sha256=ySUKAIkwj_BzBqlKMCE62wJtAE8tAGIvsqWQSzMiP3c,8114
10
+ etiket_sync_agent_qcodes-0.3.0b1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
11
+ etiket_sync_agent_qcodes-0.3.0b1.dist-info/entry_points.txt,sha256=u_Pnr-oNRlteD5NRFmWhv8c-w1gp9shK506jDkK9orQ,85
12
+ etiket_sync_agent_qcodes-0.3.0b1.dist-info/top_level.txt,sha256=6oQt56X8ztFEYdi6-us5iNDTKWpcl2e9uKmDGUzY3pU,25
13
+ etiket_sync_agent_qcodes-0.3.0b1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [etiket_sync_agent.backends]
2
+ qcodes_sync_agent = etiket_sync_agent_qcodes:QCoDeSSync
@@ -0,0 +1,34 @@
1
+ All Rights Reserved License
2
+ Copyright ©️ 2024-2026 QHarbor B.V. All Rights Reserved.
3
+
4
+ Agreement to Terms
5
+ By accessing, downloading, installing, or viewing this Software (including its source code, binaries, or application files), you acknowledge and agree to the terms outlined below.
6
+
7
+ Terms and Conditions
8
+ This software and its source code (the "Software") are the exclusive property of QHarbor B.V. and are protected by copyright and other intellectual property laws.
9
+
10
+ License and Testing Exemptions
11
+ Commercial License: If you have entered into a separate commercial license agreement with QHarbor B.V., the terms of that agreement shall supersede the restrictions listed below.
12
+ Testing Permission: If you have obtained written permission for testing/evaluation from QHarbor B.V., you are permitted to install and use the Software for evaluation purposes. However, this permission strictly excludes the right to modify, alter, create derivative works, or reverse engineer the Software.
13
+
14
+ Prohibited Actions
15
+ Unless explicitly authorized by the exemptions above, you are NOT permitted to:
16
+ • Copy, reproduce, or duplicate the Software in any form (except as reasonably necessary for viewing or authorized installation)
17
+ • Modify, alter, or create derivative works based on the Software
18
+ • Distribute, publish, or share the Software with others
19
+ • Reverse engineer, decompile, or disassemble the Software
20
+ • Use the Software for any commercial or non-commercial purposes
21
+ • Transfer, sell, lease, or sublicense the Software
22
+ • Remove or alter any copyright notices or proprietary markings
23
+
24
+ Viewing Only
25
+ For those without a commercial license or written testing permission, the Software is made available for viewing and reference purposes only. Any access to view the source code or application does not grant any rights to use, copy, or modify the Software.
26
+
27
+ No Implied Rights
28
+ No rights are granted by implication, estoppel, or otherwise. All rights not expressly granted are reserved by the copyright holder.
29
+
30
+ Governing Law
31
+ This license and any disputes arising from it shall be governed by the laws of the Netherlands.
32
+
33
+ Disclaimer
34
+ This Software is provided "AS IS" without warranty of any kind. The copyright holder disclaims all warranties and shall not be liable for any damages arising from the use or inability to use this Software.
@@ -0,0 +1 @@
1
+ etiket_sync_agent_qcodes