etiket-sync-agent-qualibrate 0.0.1__tar.gz
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.
- etiket_sync_agent_qualibrate-0.0.1/LICENCE +34 -0
- etiket_sync_agent_qualibrate-0.0.1/PKG-INFO +136 -0
- etiket_sync_agent_qualibrate-0.0.1/README.md +103 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate/__init__.py +5 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate/_qualibrate_loader.py +226 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate/qualibrate_config_class.py +74 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate/qualibrate_sync_class.py +266 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate.egg-info/PKG-INFO +136 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate.egg-info/SOURCES.txt +13 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate.egg-info/dependency_links.txt +1 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate.egg-info/entry_points.txt +2 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate.egg-info/requires.txt +5 -0
- etiket_sync_agent_qualibrate-0.0.1/etiket_sync_agent_qualibrate.egg-info/top_level.txt +1 -0
- etiket_sync_agent_qualibrate-0.0.1/pyproject.toml +60 -0
- etiket_sync_agent_qualibrate-0.0.1/setup.cfg +4 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
All Rights Reserved License
|
|
2
|
+
Copyright ©️ 2026 Quantum Machines Ltd. 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 Quantum Machines Ltd. 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 Quantum Machines Ltd., the terms of that agreement shall supersede the restrictions listed below.
|
|
12
|
+
Testing Permission: If you have obtained written permission for testing/evaluation from Quantum Machines Ltd., 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,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etiket_sync_agent_qualibrate
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: QUAlibrate connector 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,connector,qualibrate,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: numpy
|
|
29
|
+
Requires-Dist: xarray
|
|
30
|
+
Requires-Dist: h5netcdf
|
|
31
|
+
Requires-Dist: Pillow
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# eTiKeT Sync Agent - QUAlibrate Connector
|
|
35
|
+
|
|
36
|
+
Connector for synchronizing [QUAlibrate](https://qua-platform.github.io/qualibrate/) calibration data with the eTiKeT platform. This connector scans a QUAlibrate data directory for node/workflow snapshots and syncs them to the cloud.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Install the QUAlibrate connector using the eTiKeT Sync SDK:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from etiket_sdk.sync import Connectors
|
|
44
|
+
|
|
45
|
+
# Install the latest version
|
|
46
|
+
Connectors.install_from_pypi("etiket-sync-agent-qualibrate")
|
|
47
|
+
```
|
|
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 connectors
|
|
53
|
+
print(Connectors.list())
|
|
54
|
+
|
|
55
|
+
# Get details for the QUAlibrate connector
|
|
56
|
+
connector = Connectors.get("etiket_sync_agent_qualibrate")
|
|
57
|
+
print(connector)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Updating the Connector
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# Update to the latest version
|
|
64
|
+
Connectors.update_from_pypi("etiket-sync-agent-qualibrate")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## What Gets Synchronized
|
|
68
|
+
|
|
69
|
+
Each QUAlibrate snapshot is a directory containing a `node.json` (metadata) and a `data.json` (results, with references to `.npz`/`.h5`/`.png`/`.json` files). When a snapshot is synced, the following data is extracted and uploaded:
|
|
70
|
+
|
|
71
|
+
| QUAlibrate Data | eTiKeT Field | Description |
|
|
72
|
+
|-----------------|--------------|-------------|
|
|
73
|
+
| Snapshot folder name | `alt_uid` | Unique identifier for the snapshot |
|
|
74
|
+
| `metadata.name` (or folder name) | `name` | Name of the dataset |
|
|
75
|
+
| `created_at` / `metadata.run_start` | `collected` | When the snapshot was created |
|
|
76
|
+
| `metadata.type_of_execution` | `keywords` | `node` or `workflow` |
|
|
77
|
+
| `metadata.description` | `description` | Snapshot description |
|
|
78
|
+
| `type_of_execution`, `status` | `attributes` | Small, searchable scalar metadata |
|
|
79
|
+
| `data.json` → `raw_data` | HDF5 file | Raw measurement arrays combined into a single netCDF (`raw_data.h5`) |
|
|
80
|
+
|
|
81
|
+
### Data Processing
|
|
82
|
+
|
|
83
|
+
- **Reference resolution**: `data.json` reference strings (`./arrays.npz#a.b.c`) are resolved into real numpy arrays, xarray datasets, and images via the vendored loader.
|
|
84
|
+
- **Raw-data extraction**: Only the `raw_data` entry of the results tree is converted to netCDF. Other entries (e.g. `fit_results`) are intentionally excluded.
|
|
85
|
+
- **Array-to-dataset convention** (npz format): this is a best-effort reconstruction. Within a group of sibling numpy arrays, the leading 1-D arrays are treated as the coordinate axes in order (x, y, z, ...) and the remaining arrays as the measured values over the grid those axes span. If shapes don't fit this convention, arrays fall back to standalone variables with independent dimensions.
|
|
86
|
+
- **Combining**: per-measurement datasets are merged into one flat netCDF. Data variables are prefixed by their results-path; shared sweep axes stay shared (`join="exact"`). On conflict, every dataset is fully namespaced (lossless, collision-proof).
|
|
87
|
+
- **Workflows**: workflow snapshots typically carry no array data, in which case no netCDF file is uploaded.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
The QUAlibrate connector requires a `QualibrateConfigData` configuration with the following fields:
|
|
94
|
+
|
|
95
|
+
| Field | Type | Required | Description |
|
|
96
|
+
|-------|------|----------|-------------|
|
|
97
|
+
| `data_directory` | `Path` or `str` | Yes | Path to the QUAlibrate data directory (the root containing snapshot folders) |
|
|
98
|
+
| `is_server_folder` | `bool` | Yes | Whether this is a server folder (e.g. on a network drive of the university) |
|
|
99
|
+
|
|
100
|
+
A scope is **required** for this connector (`scope_requirement = REQUIRED`); scope mapping is not supported.
|
|
101
|
+
|
|
102
|
+
The configuration is validated on creation: `data_directory` must exist and be a directory, and it must not overlap (be identical to, a parent of, or a subdirectory of) the path of an existing QUAlibrate sync source.
|
|
103
|
+
|
|
104
|
+
### Example Configuration
|
|
105
|
+
|
|
106
|
+
Example using the `etiket-sdk` package:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from etiket_sdk.sync import SyncSources
|
|
110
|
+
|
|
111
|
+
SyncSources.create(
|
|
112
|
+
name="my_qualibrate_source",
|
|
113
|
+
connector_identifier="etiket_sync_agent_qualibrate",
|
|
114
|
+
config_data={
|
|
115
|
+
"data_directory": "~/.qualibrate/user_storage",
|
|
116
|
+
"is_server_folder": False
|
|
117
|
+
},
|
|
118
|
+
default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Live Sync
|
|
123
|
+
|
|
124
|
+
Live synchronization is **not** supported by this connector. Snapshots are synced once they are complete (i.e. once a `node.json` is present in the snapshot directory).
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- Python >= 3.10
|
|
129
|
+
- numpy
|
|
130
|
+
- xarray
|
|
131
|
+
- h5netcdf
|
|
132
|
+
- Pillow
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# eTiKeT Sync Agent - QUAlibrate Connector
|
|
2
|
+
|
|
3
|
+
Connector for synchronizing [QUAlibrate](https://qua-platform.github.io/qualibrate/) calibration data with the eTiKeT platform. This connector scans a QUAlibrate data directory for node/workflow snapshots and syncs them to the cloud.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the QUAlibrate connector using the eTiKeT Sync SDK:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from etiket_sdk.sync import Connectors
|
|
11
|
+
|
|
12
|
+
# Install the latest version
|
|
13
|
+
Connectors.install_from_pypi("etiket-sync-agent-qualibrate")
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The package is automatically discovered by `etiket_sync_agent` through the entry-point system. Once installed, you can verify with:
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
# List installed connectors
|
|
20
|
+
print(Connectors.list())
|
|
21
|
+
|
|
22
|
+
# Get details for the QUAlibrate connector
|
|
23
|
+
connector = Connectors.get("etiket_sync_agent_qualibrate")
|
|
24
|
+
print(connector)
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### Updating the Connector
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
# Update to the latest version
|
|
31
|
+
Connectors.update_from_pypi("etiket-sync-agent-qualibrate")
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What Gets Synchronized
|
|
35
|
+
|
|
36
|
+
Each QUAlibrate snapshot is a directory containing a `node.json` (metadata) and a `data.json` (results, with references to `.npz`/`.h5`/`.png`/`.json` files). When a snapshot is synced, the following data is extracted and uploaded:
|
|
37
|
+
|
|
38
|
+
| QUAlibrate Data | eTiKeT Field | Description |
|
|
39
|
+
|-----------------|--------------|-------------|
|
|
40
|
+
| Snapshot folder name | `alt_uid` | Unique identifier for the snapshot |
|
|
41
|
+
| `metadata.name` (or folder name) | `name` | Name of the dataset |
|
|
42
|
+
| `created_at` / `metadata.run_start` | `collected` | When the snapshot was created |
|
|
43
|
+
| `metadata.type_of_execution` | `keywords` | `node` or `workflow` |
|
|
44
|
+
| `metadata.description` | `description` | Snapshot description |
|
|
45
|
+
| `type_of_execution`, `status` | `attributes` | Small, searchable scalar metadata |
|
|
46
|
+
| `data.json` → `raw_data` | HDF5 file | Raw measurement arrays combined into a single netCDF (`raw_data.h5`) |
|
|
47
|
+
|
|
48
|
+
### Data Processing
|
|
49
|
+
|
|
50
|
+
- **Reference resolution**: `data.json` reference strings (`./arrays.npz#a.b.c`) are resolved into real numpy arrays, xarray datasets, and images via the vendored loader.
|
|
51
|
+
- **Raw-data extraction**: Only the `raw_data` entry of the results tree is converted to netCDF. Other entries (e.g. `fit_results`) are intentionally excluded.
|
|
52
|
+
- **Array-to-dataset convention** (npz format): this is a best-effort reconstruction. Within a group of sibling numpy arrays, the leading 1-D arrays are treated as the coordinate axes in order (x, y, z, ...) and the remaining arrays as the measured values over the grid those axes span. If shapes don't fit this convention, arrays fall back to standalone variables with independent dimensions.
|
|
53
|
+
- **Combining**: per-measurement datasets are merged into one flat netCDF. Data variables are prefixed by their results-path; shared sweep axes stay shared (`join="exact"`). On conflict, every dataset is fully namespaced (lossless, collision-proof).
|
|
54
|
+
- **Workflows**: workflow snapshots typically carry no array data, in which case no netCDF file is uploaded.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
The QUAlibrate connector requires a `QualibrateConfigData` configuration with the following fields:
|
|
61
|
+
|
|
62
|
+
| Field | Type | Required | Description |
|
|
63
|
+
|-------|------|----------|-------------|
|
|
64
|
+
| `data_directory` | `Path` or `str` | Yes | Path to the QUAlibrate data directory (the root containing snapshot folders) |
|
|
65
|
+
| `is_server_folder` | `bool` | Yes | Whether this is a server folder (e.g. on a network drive of the university) |
|
|
66
|
+
|
|
67
|
+
A scope is **required** for this connector (`scope_requirement = REQUIRED`); scope mapping is not supported.
|
|
68
|
+
|
|
69
|
+
The configuration is validated on creation: `data_directory` must exist and be a directory, and it must not overlap (be identical to, a parent of, or a subdirectory of) the path of an existing QUAlibrate sync source.
|
|
70
|
+
|
|
71
|
+
### Example Configuration
|
|
72
|
+
|
|
73
|
+
Example using the `etiket-sdk` package:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from etiket_sdk.sync import SyncSources
|
|
77
|
+
|
|
78
|
+
SyncSources.create(
|
|
79
|
+
name="my_qualibrate_source",
|
|
80
|
+
connector_identifier="etiket_sync_agent_qualibrate",
|
|
81
|
+
config_data={
|
|
82
|
+
"data_directory": "~/.qualibrate/user_storage",
|
|
83
|
+
"is_server_folder": False
|
|
84
|
+
},
|
|
85
|
+
default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
86
|
+
)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Live Sync
|
|
90
|
+
|
|
91
|
+
Live synchronization is **not** supported by this connector. Snapshots are synced once they are complete (i.e. once a `node.json` is present in the snapshot directory).
|
|
92
|
+
|
|
93
|
+
## Requirements
|
|
94
|
+
|
|
95
|
+
- Python >= 3.10
|
|
96
|
+
- numpy
|
|
97
|
+
- xarray
|
|
98
|
+
- h5netcdf
|
|
99
|
+
- Pillow
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Vendored, dependency-light copy of qualibrate's node data loader.
|
|
2
|
+
|
|
3
|
+
We only need qualibrate's reference-resolution logic (turning the
|
|
4
|
+
``./arrays.npz#a.b.c`` style references in ``data.json`` back into real
|
|
5
|
+
numpy / xarray / image objects). Installing the full ``qualibrate`` package to
|
|
6
|
+
get this drags in the whole web stack (fastapi, uvicorn, sqlalchemy, ...), which
|
|
7
|
+
conflicts with the sync-agent environment, so the relevant pieces are vendored
|
|
8
|
+
here instead.
|
|
9
|
+
|
|
10
|
+
Ported from ``qualibrate.core.utils.node.{content,path_solver,loaders}``
|
|
11
|
+
(qualibrate 1.4.0). Only ``read_node_data`` and its dependencies are included;
|
|
12
|
+
the parameter/pydantic machinery from the original ``content.py`` is omitted.
|
|
13
|
+
|
|
14
|
+
Runtime deps: numpy (required), xarray + Pillow (optional, used if installed).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
from abc import ABC, abstractmethod
|
|
20
|
+
from importlib.util import find_spec
|
|
21
|
+
from itertools import chain
|
|
22
|
+
from os import PathLike
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Optional, Sequence
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
from numpy.lib.npyio import NpzFile
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
__all__ = ["read_node_data"]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Loaders (extension -> object)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
class BaseLoader(ABC):
|
|
39
|
+
file_extensions: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def is_loader_support_extension(cls, extension: str) -> bool:
|
|
43
|
+
return extension.lower() in cls.file_extensions
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def validate_file_exists(cls, file_path: Path) -> None:
|
|
47
|
+
if not file_path.is_file():
|
|
48
|
+
raise FileNotFoundError(f"Can't load file '{file_path}' by loader {cls.__name__}")
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def load(self, path: Path, **kwargs: Any) -> Any:
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class JSONLoader(BaseLoader):
|
|
56
|
+
file_extensions = (".json",)
|
|
57
|
+
|
|
58
|
+
def load(self, path: Path, **kwargs: Any) -> Any:
|
|
59
|
+
self.__class__.validate_file_exists(path)
|
|
60
|
+
with open(path) as f:
|
|
61
|
+
return json.load(f)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class NumpyArrayLoader(BaseLoader):
|
|
65
|
+
file_extensions = (".npy", ".npz")
|
|
66
|
+
|
|
67
|
+
def __init__(self) -> None:
|
|
68
|
+
self.filepath_to_array: dict[Path, Any] = {}
|
|
69
|
+
|
|
70
|
+
def load(self, path: Path, **kwargs: Any) -> Any:
|
|
71
|
+
if path in self.filepath_to_array:
|
|
72
|
+
file_content = self.filepath_to_array[path]
|
|
73
|
+
else:
|
|
74
|
+
self.__class__.validate_file_exists(path)
|
|
75
|
+
file_content = np.load(path)
|
|
76
|
+
self.filepath_to_array[path] = file_content
|
|
77
|
+
subref = kwargs.get("subref")
|
|
78
|
+
if subref is None:
|
|
79
|
+
return file_content
|
|
80
|
+
if not isinstance(file_content, NpzFile):
|
|
81
|
+
raise ValueError(f"Loaded file {path} is not representation of multiple NumPy arrays")
|
|
82
|
+
return file_content.get(subref)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class XarrayLoader(BaseLoader):
|
|
86
|
+
file_extensions: tuple[str, ...] = (".h5",)
|
|
87
|
+
|
|
88
|
+
def load(self, path: Path, **kwargs: Any) -> Any:
|
|
89
|
+
# Faithful to qualibrate: opens the dataset and ignores any "#group"
|
|
90
|
+
# subref (the default save mode writes one file per dataset).
|
|
91
|
+
if find_spec("xarray") is None:
|
|
92
|
+
return None
|
|
93
|
+
from xarray import open_dataset
|
|
94
|
+
|
|
95
|
+
self.__class__.validate_file_exists(path)
|
|
96
|
+
return open_dataset(path)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ImageLoader(BaseLoader):
|
|
100
|
+
file_extensions: tuple[str, ...] = (".png",)
|
|
101
|
+
|
|
102
|
+
def load(self, path: Path, **kwargs: Any) -> Any:
|
|
103
|
+
if find_spec("PIL") is None:
|
|
104
|
+
return None
|
|
105
|
+
from PIL import Image
|
|
106
|
+
|
|
107
|
+
self.__class__.validate_file_exists(path)
|
|
108
|
+
return Image.open(path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# QuamLoader is intentionally omitted: quam state is referenced from node.json,
|
|
112
|
+
# not from data.json, so read_node_data never needs it.
|
|
113
|
+
DEFAULT_LOADERS: list[type[BaseLoader]] = [
|
|
114
|
+
JSONLoader,
|
|
115
|
+
NumpyArrayLoader,
|
|
116
|
+
ImageLoader,
|
|
117
|
+
XarrayLoader,
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
SUPPORTED_EXTENSIONS = set(chain.from_iterable(loader.file_extensions for loader in DEFAULT_LOADERS))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
# Path helpers
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
def get_data_filepath(node_path: Path) -> Path:
|
|
128
|
+
return node_path / "data.json"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_and_check_relative(base_path: Path, subpath: "PathLike[str]") -> Path:
|
|
132
|
+
"""Build a full path from base + subpath; raise if it escapes base_path."""
|
|
133
|
+
full = (base_path / Path(subpath)).resolve()
|
|
134
|
+
base_path_resolved = base_path.resolve()
|
|
135
|
+
if not full.is_relative_to(base_path_resolved):
|
|
136
|
+
raise FileNotFoundError(f"Subpath {subpath} isn't relative to base {base_path}.")
|
|
137
|
+
return full
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# Reference resolution
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _read_raw_node_file(node_filepath: Path) -> dict[str, Any]:
|
|
145
|
+
try:
|
|
146
|
+
with node_filepath.open("r") as f:
|
|
147
|
+
return dict(json.load(f))
|
|
148
|
+
except json.JSONDecodeError as ex:
|
|
149
|
+
logger.exception("Can't read json by path %s", node_filepath, exc_info=ex)
|
|
150
|
+
raise
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _get_filename_and_subreference(filepath: "str | Path") -> tuple[Path, Optional[str]]:
|
|
154
|
+
"""``"./arrays.npz#a.b.c"`` -> ``(Path("./arrays.npz"), "a.b.c")``."""
|
|
155
|
+
filepath = Path(filepath)
|
|
156
|
+
name, *subref = filepath.name.split("#")
|
|
157
|
+
clear_filepath = filepath.with_name(name)
|
|
158
|
+
return clear_filepath, "#".join(subref) if subref else None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _check_supported_reference(filename: Any, supported_extensions: set[str]) -> bool:
|
|
162
|
+
if not isinstance(filename, str) or "." not in filename:
|
|
163
|
+
return False
|
|
164
|
+
filepath, _ = _get_filename_and_subreference(filename)
|
|
165
|
+
return filepath.suffix in supported_extensions
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _read_reference(reference: str, loaders: Sequence[BaseLoader], node_dir: Path) -> Any:
|
|
169
|
+
filepath, subreference = _get_filename_and_subreference(reference)
|
|
170
|
+
try:
|
|
171
|
+
filepath = resolve_and_check_relative(node_dir, filepath)
|
|
172
|
+
except FileNotFoundError as ex:
|
|
173
|
+
logger.exception("File %s can't be located inside node directory.", filepath, exc_info=ex)
|
|
174
|
+
return reference
|
|
175
|
+
supported_loaders = [ldr for ldr in loaders if ldr.is_loader_support_extension(filepath.suffix)]
|
|
176
|
+
for loader in supported_loaders:
|
|
177
|
+
try:
|
|
178
|
+
result = loader.load(filepath, subref=subreference)
|
|
179
|
+
if result is None:
|
|
180
|
+
continue
|
|
181
|
+
return result
|
|
182
|
+
except Exception as ex:
|
|
183
|
+
logger.exception("Can't load reference (%s) by loader %s", reference, loader, exc_info=ex)
|
|
184
|
+
logger.warning("Reference %s was not loaded. Kept reference value.", reference)
|
|
185
|
+
return reference
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _resolve_references(
|
|
189
|
+
raw_data: dict[str, Any],
|
|
190
|
+
loaders: Sequence[BaseLoader],
|
|
191
|
+
supported_extensions: set[str],
|
|
192
|
+
node_dir: Path,
|
|
193
|
+
) -> None:
|
|
194
|
+
for key, value in raw_data.items():
|
|
195
|
+
if isinstance(value, dict):
|
|
196
|
+
_resolve_references(value, loaders, supported_extensions, node_dir)
|
|
197
|
+
raw_data[key] = value
|
|
198
|
+
if not _check_supported_reference(value, supported_extensions):
|
|
199
|
+
continue
|
|
200
|
+
raw_data[key] = _read_reference(value, loaders, node_dir)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def read_node_data(
|
|
204
|
+
node_dir: Path,
|
|
205
|
+
node_id: int,
|
|
206
|
+
base_path: Path,
|
|
207
|
+
custom_loaders: Optional[Sequence[type[BaseLoader]]] = None,
|
|
208
|
+
) -> Optional[dict[str, Any]]:
|
|
209
|
+
"""Read ``data.json`` and resolve its references into real objects.
|
|
210
|
+
|
|
211
|
+
Returns the results dict (with numpy/xarray/image objects in place of the
|
|
212
|
+
reference strings), or None if there is no data.json.
|
|
213
|
+
"""
|
|
214
|
+
data_filepath = get_data_filepath(node_dir)
|
|
215
|
+
if not data_filepath.is_file():
|
|
216
|
+
logger.error("Data file for node %s wasn't found in %s", node_id, base_path)
|
|
217
|
+
return None
|
|
218
|
+
results = _read_raw_node_file(data_filepath)
|
|
219
|
+
loader_classes: list[type[BaseLoader]] = [*custom_loaders] if custom_loaders is not None else []
|
|
220
|
+
loader_classes.extend(DEFAULT_LOADERS)
|
|
221
|
+
loaders_instances = [loader() for loader in loader_classes]
|
|
222
|
+
supported_extensions = set(
|
|
223
|
+
chain.from_iterable(loader.file_extensions for loader in DEFAULT_LOADERS)
|
|
224
|
+
) | SUPPORTED_EXTENSIONS
|
|
225
|
+
_resolve_references(results, loaders_instances, supported_extensions, node_dir)
|
|
226
|
+
return results
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import pathlib
|
|
3
|
+
import etiket_sync_agent_qualibrate
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
# Imports that you will likely need
|
|
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 QualibrateConfigData:
|
|
13
|
+
"""Template config dataclass for a sync connector.
|
|
14
|
+
|
|
15
|
+
Add your configuration fields here and implement the validate method
|
|
16
|
+
to perform sanity checks and cross-check with existing sources.
|
|
17
|
+
"""
|
|
18
|
+
data_directory: pathlib.Path
|
|
19
|
+
is_server_folder : bool
|
|
20
|
+
|
|
21
|
+
def __post_init__(self):
|
|
22
|
+
# Ensure that data_directory is a pathlib.Path object
|
|
23
|
+
if not isinstance(self.data_directory, pathlib.Path):
|
|
24
|
+
self.data_directory = pathlib.Path(self.data_directory)
|
|
25
|
+
|
|
26
|
+
async def validate(self, current_sync_source: Optional[SyncSources] = None):
|
|
27
|
+
"""
|
|
28
|
+
Validates the qualibrate configuration.
|
|
29
|
+
|
|
30
|
+
Checks:
|
|
31
|
+
1. If the data_directory exists and is a directory.
|
|
32
|
+
2. If the data_directory conflicts with an existing qualibrate sync source
|
|
33
|
+
(i.e., it's identical, a subdirectory, or a parent directory).
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ValueError: If any validation check fails.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
True if all checks pass.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
abs_root_dir = self.data_directory.expanduser().resolve(strict=True)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise ValueError(f"The specified root directory does not exist: {self.data_directory}")
|
|
45
|
+
|
|
46
|
+
# check if the root directory exists and is a directory.
|
|
47
|
+
if not abs_root_dir.is_dir():
|
|
48
|
+
raise ValueError(f"The specified path is not a directory: {abs_root_dir}")
|
|
49
|
+
|
|
50
|
+
# check if the folder is not yet added/is part of a folder that is already added.
|
|
51
|
+
async with get_db_session_context() as session:
|
|
52
|
+
sync_sources = await crud_sync_sources.list_sync_sources(session)
|
|
53
|
+
for sync_source in sync_sources:
|
|
54
|
+
if sync_source.connector == etiket_sync_agent_qualibrate.__name__ and (current_sync_source is None or sync_source.id != current_sync_source.id):
|
|
55
|
+
# Assuming config_data stores the path as a string and is always present/valid.
|
|
56
|
+
existing_path_str = sync_source.config_data['data_directory']
|
|
57
|
+
existing_path = pathlib.Path(existing_path_str).expanduser().resolve()
|
|
58
|
+
|
|
59
|
+
# Check for conflicts (using samefile for case-insensitive filesystem support)
|
|
60
|
+
try:
|
|
61
|
+
is_same = abs_root_dir.samefile(existing_path)
|
|
62
|
+
except FileNotFoundError:
|
|
63
|
+
is_same = False # existing path was deleted
|
|
64
|
+
|
|
65
|
+
if is_same:
|
|
66
|
+
raise ValueError(f"The directory '{abs_root_dir}' is already added as sync source '{sync_source.name}'.")
|
|
67
|
+
# Check if the new path is inside an existing path
|
|
68
|
+
if abs_root_dir.is_relative_to(existing_path):
|
|
69
|
+
raise ValueError(f"The directory '{abs_root_dir}' is inside the directory '{existing_path}' added by sync source '{sync_source.name}'.")
|
|
70
|
+
# Check if an existing path is inside the new path
|
|
71
|
+
if existing_path.is_relative_to(abs_root_dir):
|
|
72
|
+
raise ValueError(f"The directory '{existing_path}' added by sync source '{sync_source.name}' is inside the specified directory '{abs_root_dir}'.")
|
|
73
|
+
|
|
74
|
+
return True
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typing
|
|
3
|
+
import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import xarray as xr
|
|
8
|
+
|
|
9
|
+
from etiket_sync_agent.connectors.sync_source_abstract import SyncSourceFileBase, ScopeRequirement
|
|
10
|
+
from etiket_sync_agent.sync.sync_records.manager import SyncRecordManager
|
|
11
|
+
from etiket_sync_agent.schemas import SyncItemSchema
|
|
12
|
+
from etiket_sync_agent.sync.sync_utilities import sync_utilities, dataset_info, file_info, FileType
|
|
13
|
+
|
|
14
|
+
# vendored qualibrate loader (resolves `./file.npz#a.b.c` references) — avoids
|
|
15
|
+
# depending on the full qualibrate package (which drags in fastapi/uvicorn/etc.)
|
|
16
|
+
from ._qualibrate_loader import read_node_data
|
|
17
|
+
|
|
18
|
+
from .qualibrate_config_class import QualibrateConfigData
|
|
19
|
+
|
|
20
|
+
FILE_GENERATOR = "QUAlibrate"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class QualibrateSync(SyncSourceFileBase):
|
|
24
|
+
sync_agent_name: typing.ClassVar[str] = "QUAlibrate"
|
|
25
|
+
config_data_class: typing.ClassVar[typing.Type[QualibrateConfigData]] = QualibrateConfigData
|
|
26
|
+
scope_requirement: typing.ClassVar[ScopeRequirement] = ScopeRequirement.REQUIRED
|
|
27
|
+
live_sync_implemented: typing.ClassVar[bool] = False
|
|
28
|
+
supports_scope_mapping = False
|
|
29
|
+
level: typing.ClassVar[int] = 2
|
|
30
|
+
has_owner: typing.ClassVar[bool] = True
|
|
31
|
+
is_single_file = False
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def rootPath(config_data: QualibrateConfigData) -> Path:
|
|
35
|
+
return Path(config_data.data_directory)
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
async def checkLiveDataset(config_data: QualibrateConfigData, sync_item: SyncItemSchema, max_priority: bool) -> bool:
|
|
39
|
+
"""
|
|
40
|
+
Always false since we don't have a mechanism for live datasets.
|
|
41
|
+
"""
|
|
42
|
+
return False
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
async def syncDatasetNormal(config_data: QualibrateConfigData, sync_item: SyncItemSchema, sync_record: SyncRecordManager):
|
|
46
|
+
root = Path(config_data.data_directory)
|
|
47
|
+
node_dir = (root / sync_item.dataIdentifier).resolve()
|
|
48
|
+
|
|
49
|
+
# scope_requirement is REQUIRED, so a scope must be present.
|
|
50
|
+
scope_uuid = sync_item.scopeUUID
|
|
51
|
+
if scope_uuid is None:
|
|
52
|
+
raise ValueError("A scope is required for the qualibrate sync source.")
|
|
53
|
+
|
|
54
|
+
# --- 1. Collect dataset information (node.json) ------------------------
|
|
55
|
+
node_json_path = node_dir / "node.json"
|
|
56
|
+
if not node_json_path.is_file():
|
|
57
|
+
sync_record.add_log(f"No node.json in {node_dir}; not a qualibrate snapshot (yet), skipping.")
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
with sync_record.task("Reading qualibrate snapshot (node.json)"):
|
|
61
|
+
node_info = json.loads(node_json_path.read_text())
|
|
62
|
+
|
|
63
|
+
metadata = node_info.get("metadata", {}) or {}
|
|
64
|
+
snapshot_id = node_info.get("id")
|
|
65
|
+
exec_type = metadata.get("type_of_execution", "node") # "node" | "workflow"
|
|
66
|
+
created = _parse_dt(node_info.get("created_at") or metadata.get("run_start"))
|
|
67
|
+
|
|
68
|
+
# --- 2. Create / update the dataset record -----------------------------
|
|
69
|
+
ds_info = dataset_info(
|
|
70
|
+
name=metadata.get("name") or node_dir.name,
|
|
71
|
+
datasetUUID=sync_item.datasetUUID,
|
|
72
|
+
scopeUUID=scope_uuid,
|
|
73
|
+
created=created,
|
|
74
|
+
alt_uid=node_dir.name,
|
|
75
|
+
description=metadata.get("description"),
|
|
76
|
+
keywords=[exec_type],
|
|
77
|
+
attributes=_build_attributes(node_info, metadata, exec_type),
|
|
78
|
+
)
|
|
79
|
+
await sync_utilities.create_or_update_dataset(False, sync_item, ds_info, sync_record)
|
|
80
|
+
|
|
81
|
+
# --- 3. Upload every raw file ------------------------------------------
|
|
82
|
+
with sync_record.task("Uploading raw snapshot files"):
|
|
83
|
+
await _upload_raw_files(node_dir, sync_item, created, sync_record)
|
|
84
|
+
|
|
85
|
+
# --- 4. Build the raw-data netCDF for the plotting system -------------
|
|
86
|
+
# data.json -> load all arrays -> extract the relevant data ->
|
|
87
|
+
# arrays as vars/coords -> merge into one struct (single netCDF).
|
|
88
|
+
with sync_record.task("Loading results via qualibrate loaders"):
|
|
89
|
+
try:
|
|
90
|
+
results = read_node_data(node_dir, snapshot_id or 0, root) or {}
|
|
91
|
+
except Exception as e: # never let a bad results tree kill the sync
|
|
92
|
+
sync_record.add_error("Failed to load results via qualibrate loaders", e)
|
|
93
|
+
results = {}
|
|
94
|
+
|
|
95
|
+
datasets = _split_results(results)
|
|
96
|
+
|
|
97
|
+
if datasets:
|
|
98
|
+
with sync_record.task("Combining raw data into a single netCDF dataset"):
|
|
99
|
+
combined = _combine_raw(datasets, sync_record)
|
|
100
|
+
if combined is not None:
|
|
101
|
+
await sync_utilities.upload_xarray(
|
|
102
|
+
combined, sync_item,
|
|
103
|
+
file_info(name="raw_data", fileName="raw_data.h5",
|
|
104
|
+
created=created, fileType=FileType.HDF5_NETCDF,
|
|
105
|
+
file_generator=FILE_GENERATOR),
|
|
106
|
+
sync_record,
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
sync_record.add_log("No array/xarray raw data found (expected for workflows).")
|
|
110
|
+
|
|
111
|
+
sync_record.add_log(f"Synchronized qualibrate {exec_type} snapshot {snapshot_id}.")
|
|
112
|
+
|
|
113
|
+
@staticmethod
|
|
114
|
+
async def syncDatasetLive(config_data: QualibrateConfigData, sync_item: SyncItemSchema, sync_record: SyncRecordManager):
|
|
115
|
+
# Implement the live synchronization flow if applicable.
|
|
116
|
+
raise NotImplementedError
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# Helpers
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def _filetype_for_suffix(path: Path) -> FileType:
|
|
124
|
+
return {
|
|
125
|
+
".h5": FileType.HDF5_NETCDF,
|
|
126
|
+
".nc": FileType.HDF5_NETCDF,
|
|
127
|
+
".hdf5": FileType.HDF5_NETCDF,
|
|
128
|
+
".json": FileType.JSON,
|
|
129
|
+
".npy": FileType.NDARRAY,
|
|
130
|
+
".npz": FileType.NDARRAY,
|
|
131
|
+
".txt": FileType.TEXT,
|
|
132
|
+
}.get(path.suffix.lower(), FileType.UNKNOWN)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def _upload_raw_files(
|
|
136
|
+
node_dir: Path,
|
|
137
|
+
sync_item: SyncItemSchema,
|
|
138
|
+
created: datetime.datetime,
|
|
139
|
+
sync_record: SyncRecordManager,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Upload every file in the snapshot folder verbatim, under a "raw/" name.
|
|
142
|
+
|
|
143
|
+
Preserves the original bytes and folder structure so the complete snapshot
|
|
144
|
+
is always captured, even if the curated combine/JSON steps drop something.
|
|
145
|
+
"""
|
|
146
|
+
for path in sorted(node_dir.rglob("*")):
|
|
147
|
+
if not path.is_file() or path.name.startswith("."):
|
|
148
|
+
continue
|
|
149
|
+
rel = path.relative_to(node_dir).as_posix()
|
|
150
|
+
await sync_utilities.upload_file(
|
|
151
|
+
str(path), sync_item,
|
|
152
|
+
file_info(name=f"raw/{rel}", fileName=path.name,
|
|
153
|
+
created=created, fileType=_filetype_for_suffix(path),
|
|
154
|
+
file_generator=FILE_GENERATOR),
|
|
155
|
+
sync_record,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _parse_dt(value: typing.Optional[str]) -> datetime.datetime:
|
|
160
|
+
if value:
|
|
161
|
+
try:
|
|
162
|
+
return datetime.datetime.fromisoformat(value)
|
|
163
|
+
except ValueError:
|
|
164
|
+
pass
|
|
165
|
+
return datetime.datetime.now().astimezone()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _build_attributes(node_info: dict, metadata: dict, exec_type: str) -> dict:
|
|
169
|
+
"""Small, searchable scalar metadata. Values are stringified by dataset_info.
|
|
170
|
+
"""
|
|
171
|
+
attrs: dict = {"type_of_execution": exec_type}
|
|
172
|
+
for key in ("status"):
|
|
173
|
+
if metadata.get(key) is not None:
|
|
174
|
+
attrs[key] = metadata[key]
|
|
175
|
+
return attrs
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _split_results(tree: typing.Mapping, prefix: str = "") -> dict:
|
|
179
|
+
"""Walk the results tree and turn array-like leaves into xr.Datasets.
|
|
180
|
+
|
|
181
|
+
- xr.Dataset / xr.DataArray values are taken as-is.
|
|
182
|
+
- A dict whose direct children include numpy arrays is treated as one
|
|
183
|
+
measurement: by convention the FIRST array is the independent x-axis
|
|
184
|
+
(a coordinate) and the remaining arrays are values measured over it.
|
|
185
|
+
Everything else (scalars, figures, ...) is ignored here because it is
|
|
186
|
+
already captured by the verbatim raw-file upload.
|
|
187
|
+
|
|
188
|
+
Returns {dotted_path: xr.Dataset}.
|
|
189
|
+
"""
|
|
190
|
+
datasets: dict = {}
|
|
191
|
+
array_items = [(k, v) for k, v in tree.items() if isinstance(v, np.ndarray)]
|
|
192
|
+
for key, value in tree.items():
|
|
193
|
+
path = f"{prefix}{key}"
|
|
194
|
+
if isinstance(value, xr.Dataset):
|
|
195
|
+
datasets[path] = value
|
|
196
|
+
elif isinstance(value, xr.DataArray):
|
|
197
|
+
datasets[path] = value.to_dataset(name=str(key))
|
|
198
|
+
elif isinstance(value, dict):
|
|
199
|
+
datasets.update(_split_results(value, prefix=f"{path}."))
|
|
200
|
+
# numpy arrays are handled as a sibling group below
|
|
201
|
+
if array_items:
|
|
202
|
+
group_path = prefix.rstrip(".") or "raw_data"
|
|
203
|
+
datasets[group_path] = _arrays_to_dataset(array_items)
|
|
204
|
+
return datasets
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _arrays_to_dataset(array_items: list) -> xr.Dataset:
|
|
208
|
+
"""Build an xr.Dataset from a group of sibling numpy arrays.
|
|
209
|
+
|
|
210
|
+
Convention (npz format): the leading 1-D arrays are the coordinate axes in
|
|
211
|
+
order (x, y, z, ...) and the remaining arrays are the measured values over
|
|
212
|
+
the grid those axes span. The number of axes is taken from the highest-rank
|
|
213
|
+
array (1 for a line sweep, 2 for a chevron map, ...). Falls back to
|
|
214
|
+
standalone variables with independent dims if the shapes don't fit.
|
|
215
|
+
"""
|
|
216
|
+
arrays = [(name, np.asarray(v)) for name, v in array_items if v is not None]
|
|
217
|
+
if not arrays:
|
|
218
|
+
return xr.Dataset()
|
|
219
|
+
|
|
220
|
+
n_axes = max(v.ndim for _, v in arrays) # x, y, z, ...
|
|
221
|
+
axes, data = arrays[:n_axes], arrays[n_axes:]
|
|
222
|
+
expected_shape = tuple(v.shape[0] for _, v in axes)
|
|
223
|
+
axes_ok = len(axes) == n_axes and all(v.ndim == 1 for _, v in axes)
|
|
224
|
+
data_ok = bool(data) and all(v.shape == expected_shape for _, v in data)
|
|
225
|
+
|
|
226
|
+
if axes_ok and data_ok:
|
|
227
|
+
axis_names = [n for n, _ in axes]
|
|
228
|
+
return xr.Dataset(
|
|
229
|
+
{n: (axis_names, v) for n, v in data},
|
|
230
|
+
coords={n: v for n, v in axes},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
return xr.Dataset({n: ([f"{n}.dim_{i}" for i in range(v.ndim)], v) for n, v in arrays})
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _namespace_all(path: str, ds: xr.Dataset) -> xr.Dataset:
|
|
237
|
+
names = set(ds.dims) | set(ds.coords) | set(ds.data_vars)
|
|
238
|
+
return ds.rename({n: f"{path}.{n}" for n in names})
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _combine_raw(datasets: dict, sync_record: SyncRecordManager) -> typing.Optional[xr.Dataset]:
|
|
242
|
+
"""Combine the per-measurement datasets into one flat xr.Dataset.
|
|
243
|
+
|
|
244
|
+
Strategy ladder:
|
|
245
|
+
1. prefix only data_vars by their results-path, keep coords shared, merge
|
|
246
|
+
with join="exact" so a sweep axis shared by parallel measurements stays
|
|
247
|
+
shared, but mismatched same-named axes raise instead of silently
|
|
248
|
+
outer-joining (union + NaN padding);
|
|
249
|
+
2. on conflict, fully namespace every dataset (no shared axes, but lossless
|
|
250
|
+
and collision-proof).
|
|
251
|
+
"""
|
|
252
|
+
prepared = [ds.rename({v: f"{path}.{v}" for v in ds.data_vars}) for path, ds in datasets.items()]
|
|
253
|
+
|
|
254
|
+
if not prepared:
|
|
255
|
+
return None
|
|
256
|
+
if len(prepared) == 1:
|
|
257
|
+
return prepared[0]
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
return typing.cast(xr.Dataset, xr.merge(prepared, join="exact", compat="no_conflicts", combine_attrs="drop_conflicts"))
|
|
261
|
+
except Exception as e:
|
|
262
|
+
sync_record.add_log(f"Shared-coordinate merge failed ({e}); namespacing fully.")
|
|
263
|
+
fully = [_namespace_all(path, ds) for path, ds in datasets.items()]
|
|
264
|
+
return typing.cast(xr.Dataset, xr.merge(fully, combine_attrs="drop_conflicts"))
|
|
265
|
+
|
|
266
|
+
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: etiket_sync_agent_qualibrate
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: QUAlibrate connector 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,connector,qualibrate,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: numpy
|
|
29
|
+
Requires-Dist: xarray
|
|
30
|
+
Requires-Dist: h5netcdf
|
|
31
|
+
Requires-Dist: Pillow
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# eTiKeT Sync Agent - QUAlibrate Connector
|
|
35
|
+
|
|
36
|
+
Connector for synchronizing [QUAlibrate](https://qua-platform.github.io/qualibrate/) calibration data with the eTiKeT platform. This connector scans a QUAlibrate data directory for node/workflow snapshots and syncs them to the cloud.
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
Install the QUAlibrate connector using the eTiKeT Sync SDK:
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from etiket_sdk.sync import Connectors
|
|
44
|
+
|
|
45
|
+
# Install the latest version
|
|
46
|
+
Connectors.install_from_pypi("etiket-sync-agent-qualibrate")
|
|
47
|
+
```
|
|
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 connectors
|
|
53
|
+
print(Connectors.list())
|
|
54
|
+
|
|
55
|
+
# Get details for the QUAlibrate connector
|
|
56
|
+
connector = Connectors.get("etiket_sync_agent_qualibrate")
|
|
57
|
+
print(connector)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Updating the Connector
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
# Update to the latest version
|
|
64
|
+
Connectors.update_from_pypi("etiket-sync-agent-qualibrate")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## What Gets Synchronized
|
|
68
|
+
|
|
69
|
+
Each QUAlibrate snapshot is a directory containing a `node.json` (metadata) and a `data.json` (results, with references to `.npz`/`.h5`/`.png`/`.json` files). When a snapshot is synced, the following data is extracted and uploaded:
|
|
70
|
+
|
|
71
|
+
| QUAlibrate Data | eTiKeT Field | Description |
|
|
72
|
+
|-----------------|--------------|-------------|
|
|
73
|
+
| Snapshot folder name | `alt_uid` | Unique identifier for the snapshot |
|
|
74
|
+
| `metadata.name` (or folder name) | `name` | Name of the dataset |
|
|
75
|
+
| `created_at` / `metadata.run_start` | `collected` | When the snapshot was created |
|
|
76
|
+
| `metadata.type_of_execution` | `keywords` | `node` or `workflow` |
|
|
77
|
+
| `metadata.description` | `description` | Snapshot description |
|
|
78
|
+
| `type_of_execution`, `status` | `attributes` | Small, searchable scalar metadata |
|
|
79
|
+
| `data.json` → `raw_data` | HDF5 file | Raw measurement arrays combined into a single netCDF (`raw_data.h5`) |
|
|
80
|
+
|
|
81
|
+
### Data Processing
|
|
82
|
+
|
|
83
|
+
- **Reference resolution**: `data.json` reference strings (`./arrays.npz#a.b.c`) are resolved into real numpy arrays, xarray datasets, and images via the vendored loader.
|
|
84
|
+
- **Raw-data extraction**: Only the `raw_data` entry of the results tree is converted to netCDF. Other entries (e.g. `fit_results`) are intentionally excluded.
|
|
85
|
+
- **Array-to-dataset convention** (npz format): this is a best-effort reconstruction. Within a group of sibling numpy arrays, the leading 1-D arrays are treated as the coordinate axes in order (x, y, z, ...) and the remaining arrays as the measured values over the grid those axes span. If shapes don't fit this convention, arrays fall back to standalone variables with independent dimensions.
|
|
86
|
+
- **Combining**: per-measurement datasets are merged into one flat netCDF. Data variables are prefixed by their results-path; shared sweep axes stay shared (`join="exact"`). On conflict, every dataset is fully namespaced (lossless, collision-proof).
|
|
87
|
+
- **Workflows**: workflow snapshots typically carry no array data, in which case no netCDF file is uploaded.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
The QUAlibrate connector requires a `QualibrateConfigData` configuration with the following fields:
|
|
94
|
+
|
|
95
|
+
| Field | Type | Required | Description |
|
|
96
|
+
|-------|------|----------|-------------|
|
|
97
|
+
| `data_directory` | `Path` or `str` | Yes | Path to the QUAlibrate data directory (the root containing snapshot folders) |
|
|
98
|
+
| `is_server_folder` | `bool` | Yes | Whether this is a server folder (e.g. on a network drive of the university) |
|
|
99
|
+
|
|
100
|
+
A scope is **required** for this connector (`scope_requirement = REQUIRED`); scope mapping is not supported.
|
|
101
|
+
|
|
102
|
+
The configuration is validated on creation: `data_directory` must exist and be a directory, and it must not overlap (be identical to, a parent of, or a subdirectory of) the path of an existing QUAlibrate sync source.
|
|
103
|
+
|
|
104
|
+
### Example Configuration
|
|
105
|
+
|
|
106
|
+
Example using the `etiket-sdk` package:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from etiket_sdk.sync import SyncSources
|
|
110
|
+
|
|
111
|
+
SyncSources.create(
|
|
112
|
+
name="my_qualibrate_source",
|
|
113
|
+
connector_identifier="etiket_sync_agent_qualibrate",
|
|
114
|
+
config_data={
|
|
115
|
+
"data_directory": "~/.qualibrate/user_storage",
|
|
116
|
+
"is_server_folder": False
|
|
117
|
+
},
|
|
118
|
+
default_scope="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
|
119
|
+
)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Live Sync
|
|
123
|
+
|
|
124
|
+
Live synchronization is **not** supported by this connector. Snapshots are synced once they are complete (i.e. once a `node.json` is present in the snapshot directory).
|
|
125
|
+
|
|
126
|
+
## Requirements
|
|
127
|
+
|
|
128
|
+
- Python >= 3.10
|
|
129
|
+
- numpy
|
|
130
|
+
- xarray
|
|
131
|
+
- h5netcdf
|
|
132
|
+
- Pillow
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
Copyright © 2025 QHarbor. All Rights Reserved. See [LICENCE](LICENCE) for details.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENCE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
etiket_sync_agent_qualibrate/__init__.py
|
|
5
|
+
etiket_sync_agent_qualibrate/_qualibrate_loader.py
|
|
6
|
+
etiket_sync_agent_qualibrate/qualibrate_config_class.py
|
|
7
|
+
etiket_sync_agent_qualibrate/qualibrate_sync_class.py
|
|
8
|
+
etiket_sync_agent_qualibrate.egg-info/PKG-INFO
|
|
9
|
+
etiket_sync_agent_qualibrate.egg-info/SOURCES.txt
|
|
10
|
+
etiket_sync_agent_qualibrate.egg-info/dependency_links.txt
|
|
11
|
+
etiket_sync_agent_qualibrate.egg-info/entry_points.txt
|
|
12
|
+
etiket_sync_agent_qualibrate.egg-info/requires.txt
|
|
13
|
+
etiket_sync_agent_qualibrate.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
etiket_sync_agent_qualibrate
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69.0.0", "setuptools-scm"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "etiket_sync_agent_qualibrate"
|
|
7
|
+
description = "QUAlibrate connector for eTiKeT sync agent"
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [{name = "QHarbor team"}]
|
|
10
|
+
license = "LicenseRef-Proprietary"
|
|
11
|
+
license-files = ["LICENCE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
dynamic = ["version"]
|
|
14
|
+
keywords = [
|
|
15
|
+
"etiket",
|
|
16
|
+
"sync",
|
|
17
|
+
"connector",
|
|
18
|
+
"qualibrate",
|
|
19
|
+
"quantum",
|
|
20
|
+
]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Intended Audience :: Developers",
|
|
24
|
+
"Intended Audience :: Science/Research",
|
|
25
|
+
"Operating System :: POSIX :: Linux",
|
|
26
|
+
"Operating System :: MacOS :: MacOS X",
|
|
27
|
+
"Operating System :: Microsoft :: Windows",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3.10",
|
|
30
|
+
"Programming Language :: Python :: 3.11",
|
|
31
|
+
"Programming Language :: Python :: 3.12",
|
|
32
|
+
"Programming Language :: Python :: 3.13",
|
|
33
|
+
"Programming Language :: Python :: 3.14",
|
|
34
|
+
"Topic :: Scientific/Engineering",
|
|
35
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
36
|
+
]
|
|
37
|
+
dependencies = [
|
|
38
|
+
"etiket_sync_agent>=0.3.0b1",
|
|
39
|
+
"numpy",
|
|
40
|
+
"xarray",
|
|
41
|
+
"h5netcdf",
|
|
42
|
+
"Pillow",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.entry-points."etiket_sync_agent.connectors"]
|
|
46
|
+
qualibrate_sync_agent = "etiket_sync_agent_qualibrate:QualibrateSync"
|
|
47
|
+
|
|
48
|
+
[project.urls]
|
|
49
|
+
Homepage = "https://qharbor.nl"
|
|
50
|
+
Documentation = "https://docs.qharbor.nl"
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
|
|
55
|
+
[tool.setuptools.packages.find]
|
|
56
|
+
where = ["."]
|
|
57
|
+
include = ["etiket_sync_agent_qualibrate*"]
|
|
58
|
+
|
|
59
|
+
[tool.setuptools.dynamic]
|
|
60
|
+
version = {attr = "etiket_sync_agent_qualibrate.__version__"}
|