qplotter 1.6.0b3__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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared rules for the dimensionality qPlot can display safely."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
|
|
5
|
+
MAX_SUPPORTED_PLOT_DIMENSIONS = 2
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class UnsupportedPlotDimensionError(ValueError):
|
|
9
|
+
"""Raised when a measurement cannot be represented without projection."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def normalise_axes(axes: Iterable[object] | None) -> tuple[str, ...]:
|
|
13
|
+
"""Return non-empty axis names in their declared order."""
|
|
14
|
+
|
|
15
|
+
items = () if axes is None else axes
|
|
16
|
+
return tuple(str(axis) for axis in items if str(axis))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unsupported_plot_message(parameter: object, axes: Iterable[object] | None) -> str:
|
|
20
|
+
"""Describe why a measurement is not safe to plot."""
|
|
21
|
+
|
|
22
|
+
axis_names = normalise_axes(axes)
|
|
23
|
+
name = str(parameter or "Measurement")
|
|
24
|
+
axis_text = ", ".join(axis_names)
|
|
25
|
+
return (
|
|
26
|
+
f'{name} has {len(axis_names)} independent axes ({axis_text}). '
|
|
27
|
+
"qPlot supports 1D and 2D measurements only; displaying this data "
|
|
28
|
+
"would require an explicit slice or projection."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def ensure_supported_plot_dimensions(
|
|
33
|
+
parameter: object,
|
|
34
|
+
axes: Iterable[object] | None,
|
|
35
|
+
) -> tuple[str, ...]:
|
|
36
|
+
"""Validate that a dependent parameter can be displayed without data loss."""
|
|
37
|
+
|
|
38
|
+
axis_names = normalise_axes(axes)
|
|
39
|
+
if len(axis_names) > MAX_SUPPORTED_PLOT_DIMENSIONS:
|
|
40
|
+
raise UnsupportedPlotDimensionError(
|
|
41
|
+
unsupported_plot_message(parameter, axis_names)
|
|
42
|
+
)
|
|
43
|
+
return axis_names
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Stable paths and identities for databases that may be replaced."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TypeAlias
|
|
8
|
+
|
|
9
|
+
DatabaseFileIdentity: TypeAlias = (
|
|
10
|
+
tuple[int, int] | tuple[str, int, int] | tuple[str, str, int]
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileIdentityHandleCloseError(OSError):
|
|
15
|
+
"""Raised when a trusted Windows identity handle cannot be closed."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DATABASE_PUBLICATION_GUARD_SUFFIX = ".qplot-publishing"
|
|
19
|
+
QPLOT_GENERATED_DATABASE_APPLICATION_ID = 0x51504C54
|
|
20
|
+
QPLOT_GENERATION_PROVENANCE_TABLE = "qplot_generation_provenance"
|
|
21
|
+
QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES = 32
|
|
22
|
+
QPLOT_GENERATION_LINEAGE_STATE_TABLE = "qplot_generation_lineage_state"
|
|
23
|
+
QPLOT_GENERATION_LINEAGE_RING_TABLE = "qplot_generation_lineage_ring"
|
|
24
|
+
QPLOT_GENERATION_LINEAGE_FORMAT_VERSION = 2
|
|
25
|
+
QPLOT_GENERATION_LINEAGE_NONCE_BYTES = 32
|
|
26
|
+
QPLOT_GENERATION_LINEAGE_WINDOW = 65_536
|
|
27
|
+
QPLOT_GENERATION_PROVENANCE_TRIGGER_PREFIX = "qplot_provenance_v2_"
|
|
28
|
+
_SQLITE_APPLICATION_ID_OFFSET = 68
|
|
29
|
+
SQLITE_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
|
|
30
|
+
MAX_DATABASE_SIDECAR_IDENTITIES = 2 * len(SQLITE_SIDECAR_SUFFIXES)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _quote_sqlite_identifier(identifier: str) -> str:
|
|
34
|
+
return '"' + identifier.replace('"', '""') + '"'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def generation_lineage_transition_statements() -> tuple[str, str, str]:
|
|
38
|
+
"""Return the statements for one bounded, nonce-linked lineage event."""
|
|
39
|
+
provenance = _quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)
|
|
40
|
+
state = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_STATE_TABLE)
|
|
41
|
+
ring = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_RING_TABLE)
|
|
42
|
+
insert_event = (
|
|
43
|
+
f"INSERT OR REPLACE INTO {ring} "
|
|
44
|
+
"(slot, sequence, parent_nonce, nonce) "
|
|
45
|
+
f"SELECT (head_sequence + 1) % {QPLOT_GENERATION_LINEAGE_WINDOW}, "
|
|
46
|
+
f"head_sequence + 1, head_nonce, "
|
|
47
|
+
f"randomblob({QPLOT_GENERATION_LINEAGE_NONCE_BYTES}) "
|
|
48
|
+
f"FROM {state} WHERE singleton = 1"
|
|
49
|
+
)
|
|
50
|
+
update_head = (
|
|
51
|
+
f"UPDATE {state} SET "
|
|
52
|
+
"head_sequence = head_sequence + 1, "
|
|
53
|
+
f"head_nonce = (SELECT nonce FROM {ring} "
|
|
54
|
+
f"WHERE sequence = {state}.head_sequence + 1) "
|
|
55
|
+
"WHERE singleton = 1"
|
|
56
|
+
)
|
|
57
|
+
update_legacy_epoch = (
|
|
58
|
+
f"UPDATE {provenance} SET write_epoch = "
|
|
59
|
+
f"(SELECT head_sequence FROM {state} WHERE singleton = 1) "
|
|
60
|
+
"WHERE singleton = 1"
|
|
61
|
+
)
|
|
62
|
+
return insert_event, update_head, update_legacy_epoch
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def generation_provenance_trigger(
|
|
66
|
+
table_name: str,
|
|
67
|
+
operation: str,
|
|
68
|
+
) -> tuple[str, str]:
|
|
69
|
+
"""Return the stable name and exact SQL for one lineage trigger."""
|
|
70
|
+
table_digest = hashlib.sha256(
|
|
71
|
+
table_name.encode("utf-8", errors="surrogatepass")
|
|
72
|
+
).hexdigest()
|
|
73
|
+
trigger_name = (
|
|
74
|
+
f"{QPLOT_GENERATION_PROVENANCE_TRIGGER_PREFIX}"
|
|
75
|
+
f"{table_digest}_{operation.lower()}"
|
|
76
|
+
)
|
|
77
|
+
statements = "; ".join(generation_lineage_transition_statements())
|
|
78
|
+
statement = (
|
|
79
|
+
f"CREATE TRIGGER {_quote_sqlite_identifier(trigger_name)} "
|
|
80
|
+
f"AFTER {operation} ON {_quote_sqlite_identifier(table_name)} "
|
|
81
|
+
f"BEGIN {statements}; END"
|
|
82
|
+
)
|
|
83
|
+
return trigger_name, statement
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def logical_database_path(database_path: str | os.PathLike[str]) -> str:
|
|
87
|
+
"""Return the normalised path selected by the user without resolving links."""
|
|
88
|
+
|
|
89
|
+
return os.path.normcase(os.path.abspath(os.fspath(database_path)))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def canonical_database_path(database_path: str | os.PathLike[str]) -> str:
|
|
93
|
+
"""Return the currently resolved, platform-normalised database path."""
|
|
94
|
+
return os.path.normcase(os.path.realpath(os.path.abspath(os.fspath(database_path))))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def database_publication_guard_path(
|
|
98
|
+
database_path: str | os.PathLike[str],
|
|
99
|
+
) -> Path:
|
|
100
|
+
"""Return qPlot's path-local guard for an in-flight main-file replacement."""
|
|
101
|
+
|
|
102
|
+
return Path(
|
|
103
|
+
f"{logical_database_path(database_path)}{DATABASE_PUBLICATION_GUARD_SUFFIX}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def database_has_qplot_generation_marker(
|
|
108
|
+
database_path: str | os.PathLike[str],
|
|
109
|
+
) -> bool:
|
|
110
|
+
"""Return whether the main header marks a qPlot-generated database."""
|
|
111
|
+
with open(database_path, "rb") as database_file:
|
|
112
|
+
database_file.seek(_SQLITE_APPLICATION_ID_OFFSET)
|
|
113
|
+
application_id = database_file.read(4)
|
|
114
|
+
return application_id == QPLOT_GENERATED_DATABASE_APPLICATION_ID.to_bytes(
|
|
115
|
+
4,
|
|
116
|
+
"big",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True, slots=True)
|
|
121
|
+
class DatabaseInstance:
|
|
122
|
+
"""One database instance accepted through a stable logical path."""
|
|
123
|
+
|
|
124
|
+
logical_path: str
|
|
125
|
+
resolved_path: str
|
|
126
|
+
identity: DatabaseFileIdentity | None
|
|
127
|
+
sidecar_identities: frozenset[DatabaseFileIdentity] = field(
|
|
128
|
+
default_factory=frozenset,
|
|
129
|
+
compare=False,
|
|
130
|
+
hash=False,
|
|
131
|
+
repr=False,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def database_instance(
|
|
136
|
+
database_path: str | os.PathLike[str],
|
|
137
|
+
) -> DatabaseInstance:
|
|
138
|
+
"""Capture the logical path, its resolved target, and that target's identity."""
|
|
139
|
+
|
|
140
|
+
logical_path = logical_database_path(database_path)
|
|
141
|
+
resolved_path = canonical_database_path(logical_path)
|
|
142
|
+
return DatabaseInstance(
|
|
143
|
+
logical_path=logical_path,
|
|
144
|
+
resolved_path=resolved_path,
|
|
145
|
+
identity=database_file_identity(resolved_path),
|
|
146
|
+
sidecar_identities=database_sidecar_identities(
|
|
147
|
+
logical_path,
|
|
148
|
+
resolved_path,
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def database_sidecar_identities(
|
|
154
|
+
database_path: str | os.PathLike[str],
|
|
155
|
+
resolved_database_path: str | os.PathLike[str] | None = None,
|
|
156
|
+
) -> frozenset[DatabaseFileIdentity]:
|
|
157
|
+
"""Capture existing SQLite sidecars without opening the database.
|
|
158
|
+
|
|
159
|
+
Both the user-selected path and its resolved target are checked because
|
|
160
|
+
SQLite sidecars may be associated with either spelling when the input path
|
|
161
|
+
contains symbolic links. Only filesystem metadata is inspected.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
logical_path = logical_database_path(database_path)
|
|
165
|
+
resolved_path = canonical_database_path(
|
|
166
|
+
resolved_database_path if resolved_database_path is not None else logical_path
|
|
167
|
+
)
|
|
168
|
+
identities: set[DatabaseFileIdentity] = set()
|
|
169
|
+
for base_path in {logical_path, resolved_path}:
|
|
170
|
+
for suffix in SQLITE_SIDECAR_SUFFIXES:
|
|
171
|
+
identity = database_file_identity(f"{base_path}{suffix}")
|
|
172
|
+
if identity is not None:
|
|
173
|
+
identities.add(identity)
|
|
174
|
+
return frozenset(identities)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def database_instances_differ(
|
|
178
|
+
first: DatabaseInstance | None,
|
|
179
|
+
second: DatabaseInstance | None,
|
|
180
|
+
) -> bool:
|
|
181
|
+
"""Return whether two observations prove that the file instance changed."""
|
|
182
|
+
|
|
183
|
+
if first is None or second is None:
|
|
184
|
+
return first is not second
|
|
185
|
+
if first.logical_path != second.logical_path:
|
|
186
|
+
return True
|
|
187
|
+
if first.resolved_path != second.resolved_path:
|
|
188
|
+
return True
|
|
189
|
+
if first.identity is None or second.identity is None:
|
|
190
|
+
return first.identity != second.identity
|
|
191
|
+
return first.identity != second.identity
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def database_file_identity(
|
|
195
|
+
database_path: str | os.PathLike[str],
|
|
196
|
+
) -> DatabaseFileIdentity | None:
|
|
197
|
+
"""Return an identity that changes only when the file instance changes.
|
|
198
|
+
|
|
199
|
+
Device and inode are the preferred cross-platform identity. Some Windows
|
|
200
|
+
and network filesystems do not expose a useful inode, so a creation-time
|
|
201
|
+
identity is used only when the platform exposes one. Size, modification
|
|
202
|
+
time, and POSIX change time are deliberately excluded because normal
|
|
203
|
+
database and WAL activity can change them without replacing the file.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
canonical_path = canonical_database_path(database_path)
|
|
207
|
+
try:
|
|
208
|
+
stat_result = os.stat(canonical_path)
|
|
209
|
+
except OSError:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
inode = int(getattr(stat_result, "st_ino", 0) or 0)
|
|
213
|
+
if inode:
|
|
214
|
+
device = int(getattr(stat_result, "st_dev", 0) or 0)
|
|
215
|
+
return (device, inode)
|
|
216
|
+
|
|
217
|
+
if os.name == "nt":
|
|
218
|
+
windows_identity = _windows_file_identity(canonical_path)
|
|
219
|
+
if windows_identity is not None:
|
|
220
|
+
return windows_identity
|
|
221
|
+
|
|
222
|
+
birthtime_ns = getattr(stat_result, "st_birthtime_ns", None)
|
|
223
|
+
if birthtime_ns is None:
|
|
224
|
+
birthtime = getattr(stat_result, "st_birthtime", None)
|
|
225
|
+
if birthtime is not None:
|
|
226
|
+
birthtime_ns = round(float(birthtime) * 1_000_000_000)
|
|
227
|
+
if birthtime_ns is None and os.name == "nt":
|
|
228
|
+
# On supported Python versions Windows st_ctime is file creation time,
|
|
229
|
+
# unlike POSIX st_ctime. Prefer st_birthtime above when it is available.
|
|
230
|
+
birthtime_ns = getattr(stat_result, "st_ctime_ns", None)
|
|
231
|
+
if birthtime_ns is None:
|
|
232
|
+
return None
|
|
233
|
+
return ("birthtime", canonical_path, int(birthtime_ns))
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def path_bound_file_identity(
|
|
237
|
+
database_path: str | os.PathLike[str],
|
|
238
|
+
) -> DatabaseFileIdentity | None:
|
|
239
|
+
"""Return an identity comparable with an already-open file descriptor."""
|
|
240
|
+
|
|
241
|
+
canonical_path = canonical_database_path(database_path)
|
|
242
|
+
if os.name == "nt":
|
|
243
|
+
windows_identity = _windows_file_identity(canonical_path)
|
|
244
|
+
if windows_identity is not None:
|
|
245
|
+
return windows_identity
|
|
246
|
+
return database_file_identity(canonical_path)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def checked_path_bound_file_identity(
|
|
250
|
+
database_path: str | os.PathLike[str],
|
|
251
|
+
) -> DatabaseFileIdentity | None:
|
|
252
|
+
"""Return a path-bound identity and prove temporary handle cleanup.
|
|
253
|
+
|
|
254
|
+
The ordinary identity helpers deliberately remain best-effort because UI
|
|
255
|
+
refresh and export code treats an unavailable identity as a normal race.
|
|
256
|
+
The trusted live-reader boundary has a stronger lifecycle requirement: on
|
|
257
|
+
Windows it must surface both identity-inspection failures and an uncertain
|
|
258
|
+
``CloseHandle`` result so the process can quarantine that reader session.
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
canonical_path = canonical_database_path(database_path)
|
|
262
|
+
if os.name == "nt":
|
|
263
|
+
return _checked_windows_file_identity(canonical_path)
|
|
264
|
+
return path_bound_file_identity(canonical_path)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def open_file_identity(file_descriptor: int) -> DatabaseFileIdentity | None:
|
|
268
|
+
"""Return the stable identity of the exact file held by a descriptor."""
|
|
269
|
+
|
|
270
|
+
if os.name == "nt":
|
|
271
|
+
try:
|
|
272
|
+
import msvcrt
|
|
273
|
+
|
|
274
|
+
get_osfhandle = getattr(msvcrt, "get_osfhandle", None)
|
|
275
|
+
if get_osfhandle is None:
|
|
276
|
+
return None
|
|
277
|
+
handle = get_osfhandle(file_descriptor)
|
|
278
|
+
except (ImportError, OSError):
|
|
279
|
+
return None
|
|
280
|
+
return _windows_handle_identity(handle)
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
stat_result = os.fstat(file_descriptor)
|
|
284
|
+
except OSError:
|
|
285
|
+
return None
|
|
286
|
+
inode = int(getattr(stat_result, "st_ino", 0) or 0)
|
|
287
|
+
if not inode:
|
|
288
|
+
return None
|
|
289
|
+
device = int(getattr(stat_result, "st_dev", 0) or 0)
|
|
290
|
+
return (device, inode)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _windows_file_identity(canonical_path: str) -> DatabaseFileIdentity | None:
|
|
294
|
+
"""Return Windows' volume/file-index identity without opening for writes."""
|
|
295
|
+
|
|
296
|
+
try:
|
|
297
|
+
import ctypes
|
|
298
|
+
from ctypes import wintypes
|
|
299
|
+
except (ImportError, AttributeError):
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
win_dll = getattr(ctypes, "WinDLL", None)
|
|
303
|
+
if win_dll is None:
|
|
304
|
+
return None
|
|
305
|
+
kernel32 = win_dll("kernel32", use_last_error=True)
|
|
306
|
+
create_file = kernel32.CreateFileW
|
|
307
|
+
create_file.argtypes = [
|
|
308
|
+
wintypes.LPCWSTR,
|
|
309
|
+
wintypes.DWORD,
|
|
310
|
+
wintypes.DWORD,
|
|
311
|
+
wintypes.LPVOID,
|
|
312
|
+
wintypes.DWORD,
|
|
313
|
+
wintypes.DWORD,
|
|
314
|
+
wintypes.HANDLE,
|
|
315
|
+
]
|
|
316
|
+
create_file.restype = wintypes.HANDLE
|
|
317
|
+
close_handle = kernel32.CloseHandle
|
|
318
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
319
|
+
close_handle.restype = wintypes.BOOL
|
|
320
|
+
|
|
321
|
+
file_read_attributes = 0x0080
|
|
322
|
+
share_read_write_delete = 0x0001 | 0x0002 | 0x0004
|
|
323
|
+
open_existing = 3
|
|
324
|
+
handle = create_file(
|
|
325
|
+
canonical_path,
|
|
326
|
+
file_read_attributes,
|
|
327
|
+
share_read_write_delete,
|
|
328
|
+
None,
|
|
329
|
+
open_existing,
|
|
330
|
+
0,
|
|
331
|
+
None,
|
|
332
|
+
)
|
|
333
|
+
invalid_handle = wintypes.HANDLE(-1).value
|
|
334
|
+
if handle in (None, invalid_handle):
|
|
335
|
+
return None
|
|
336
|
+
try:
|
|
337
|
+
return _windows_handle_identity(handle)
|
|
338
|
+
finally:
|
|
339
|
+
close_handle(handle)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _checked_windows_file_identity(
|
|
343
|
+
canonical_path: str,
|
|
344
|
+
) -> DatabaseFileIdentity | None:
|
|
345
|
+
"""Return Windows identity while treating every HANDLE close as fallible."""
|
|
346
|
+
|
|
347
|
+
try:
|
|
348
|
+
import ctypes
|
|
349
|
+
from ctypes import wintypes
|
|
350
|
+
except (ImportError, AttributeError):
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
win_dll = getattr(ctypes, "WinDLL", None)
|
|
354
|
+
get_last_error = getattr(ctypes, "get_last_error", None)
|
|
355
|
+
if win_dll is None or get_last_error is None:
|
|
356
|
+
return None
|
|
357
|
+
kernel32 = win_dll("kernel32", use_last_error=True)
|
|
358
|
+
create_file = kernel32.CreateFileW
|
|
359
|
+
create_file.argtypes = [
|
|
360
|
+
wintypes.LPCWSTR,
|
|
361
|
+
wintypes.DWORD,
|
|
362
|
+
wintypes.DWORD,
|
|
363
|
+
wintypes.LPVOID,
|
|
364
|
+
wintypes.DWORD,
|
|
365
|
+
wintypes.DWORD,
|
|
366
|
+
wintypes.HANDLE,
|
|
367
|
+
]
|
|
368
|
+
create_file.restype = wintypes.HANDLE
|
|
369
|
+
close_handle = kernel32.CloseHandle
|
|
370
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
371
|
+
close_handle.restype = wintypes.BOOL
|
|
372
|
+
|
|
373
|
+
file_read_attributes = 0x0080
|
|
374
|
+
share_read_write_delete = 0x0001 | 0x0002 | 0x0004
|
|
375
|
+
open_existing = 3
|
|
376
|
+
handle = create_file(
|
|
377
|
+
canonical_path,
|
|
378
|
+
file_read_attributes,
|
|
379
|
+
share_read_write_delete,
|
|
380
|
+
None,
|
|
381
|
+
open_existing,
|
|
382
|
+
0,
|
|
383
|
+
None,
|
|
384
|
+
)
|
|
385
|
+
invalid_handle = wintypes.HANDLE(-1).value
|
|
386
|
+
if handle in (None, invalid_handle):
|
|
387
|
+
error_code = get_last_error()
|
|
388
|
+
raise OSError(
|
|
389
|
+
error_code,
|
|
390
|
+
f"CreateFileW could not inspect {canonical_path}",
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
identity: DatabaseFileIdentity | None = None
|
|
394
|
+
identity_error_code = 0
|
|
395
|
+
try:
|
|
396
|
+
identity = _windows_handle_identity(handle)
|
|
397
|
+
if identity is None:
|
|
398
|
+
identity_error_code = get_last_error()
|
|
399
|
+
finally:
|
|
400
|
+
if not close_handle(handle):
|
|
401
|
+
error_code = get_last_error()
|
|
402
|
+
raise FileIdentityHandleCloseError(
|
|
403
|
+
error_code,
|
|
404
|
+
f"CloseHandle could not release the identity handle for "
|
|
405
|
+
f"{canonical_path}",
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if identity is None:
|
|
409
|
+
raise OSError(
|
|
410
|
+
identity_error_code,
|
|
411
|
+
f"GetFileInformationByHandle could not identify {canonical_path}",
|
|
412
|
+
)
|
|
413
|
+
return identity
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _windows_handle_identity(handle) -> DatabaseFileIdentity | None:
|
|
417
|
+
"""Return Windows' volume/file-index identity for an existing handle."""
|
|
418
|
+
|
|
419
|
+
try:
|
|
420
|
+
import ctypes
|
|
421
|
+
from ctypes import wintypes
|
|
422
|
+
except (ImportError, AttributeError):
|
|
423
|
+
return None
|
|
424
|
+
|
|
425
|
+
class ByHandleFileInformation(ctypes.Structure):
|
|
426
|
+
_fields_ = [
|
|
427
|
+
("file_attributes", wintypes.DWORD),
|
|
428
|
+
("creation_time", wintypes.FILETIME),
|
|
429
|
+
("last_access_time", wintypes.FILETIME),
|
|
430
|
+
("last_write_time", wintypes.FILETIME),
|
|
431
|
+
("volume_serial_number", wintypes.DWORD),
|
|
432
|
+
("file_size_high", wintypes.DWORD),
|
|
433
|
+
("file_size_low", wintypes.DWORD),
|
|
434
|
+
("number_of_links", wintypes.DWORD),
|
|
435
|
+
("file_index_high", wintypes.DWORD),
|
|
436
|
+
("file_index_low", wintypes.DWORD),
|
|
437
|
+
]
|
|
438
|
+
|
|
439
|
+
win_dll = getattr(ctypes, "WinDLL", None)
|
|
440
|
+
if win_dll is None:
|
|
441
|
+
return None
|
|
442
|
+
kernel32 = win_dll("kernel32", use_last_error=True)
|
|
443
|
+
get_information = kernel32.GetFileInformationByHandle
|
|
444
|
+
get_information.argtypes = [
|
|
445
|
+
wintypes.HANDLE,
|
|
446
|
+
ctypes.POINTER(ByHandleFileInformation),
|
|
447
|
+
]
|
|
448
|
+
get_information.restype = wintypes.BOOL
|
|
449
|
+
information = ByHandleFileInformation()
|
|
450
|
+
if not get_information(handle, ctypes.byref(information)):
|
|
451
|
+
return None
|
|
452
|
+
file_index = int(information.file_index_high) << 32 | int(
|
|
453
|
+
information.file_index_low
|
|
454
|
+
)
|
|
455
|
+
if file_index == 0:
|
|
456
|
+
return None
|
|
457
|
+
return (
|
|
458
|
+
"windows-file-id",
|
|
459
|
+
int(information.volume_serial_number),
|
|
460
|
+
file_index,
|
|
461
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""QCoDeS parameter-tree decoding shared by plotting and raw export.
|
|
2
|
+
|
|
3
|
+
Keep QCoDeS' row selection and scalar expansion, but collect unequal array
|
|
4
|
+
shapes explicitly. NumPy's ``array(records, dtype=object)`` still attempts to
|
|
5
|
+
broadcast nested dimensions and cannot represent all valid QCoDeS records.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from qcodes.dataset.data_set import DataSet
|
|
12
|
+
from qcodes.dataset.sqlite.queries import (
|
|
13
|
+
_expand_data_to_arrays,
|
|
14
|
+
_get_data_for_one_param_tree,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_noop():
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def integer_preserving_dtype(dtypes):
|
|
23
|
+
"""Use object cells when common-dtype promotion would convert integers.
|
|
24
|
+
|
|
25
|
+
In particular, NumPy combines uint64 and int64 as float64, which cannot
|
|
26
|
+
represent every stored integer. Mixed integer/float or integer/complex
|
|
27
|
+
records need the same protection. Homogeneous numeric arrays stay dense.
|
|
28
|
+
"""
|
|
29
|
+
dtypes = set(dtypes)
|
|
30
|
+
common = np.result_type(*dtypes) if dtypes else np.dtype(float)
|
|
31
|
+
if common.kind not in "iuO" and any(dtype.kind in "iu" for dtype in dtypes):
|
|
32
|
+
return np.dtype(object)
|
|
33
|
+
return common
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def concatenate_record_samples(parts):
|
|
37
|
+
"""Join flattened records without rounding any original integer samples."""
|
|
38
|
+
dtype = integer_preserving_dtype(part.dtype for part in parts)
|
|
39
|
+
return np.concatenate(parts, dtype=dtype) if parts else np.array([], dtype=dtype)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def numeric_isfinite(values):
|
|
43
|
+
"""Inspect numeric object cells without changing the stored sample array."""
|
|
44
|
+
values = np.asarray(values)
|
|
45
|
+
return np.isfinite(values.astype(float) if values.dtype.kind == "O" else values)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def numeric_isnan(values):
|
|
49
|
+
"""Identify missing samples in dense arrays or exact numeric object cells."""
|
|
50
|
+
values = np.asarray(values)
|
|
51
|
+
return np.isnan(values.astype(float) if values.dtype.kind == "O" else values)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _record_column(values, dtype, check_cancelled, *, preserve_integers=True):
|
|
55
|
+
"""Keep equal shapes dense and unequal shapes in separate object cells."""
|
|
56
|
+
shapes = set()
|
|
57
|
+
dtypes = set()
|
|
58
|
+
arrays = []
|
|
59
|
+
for value in values:
|
|
60
|
+
check_cancelled()
|
|
61
|
+
array = np.asarray(value, dtype=dtype)
|
|
62
|
+
shapes.add(array.shape)
|
|
63
|
+
dtypes.add(array.dtype)
|
|
64
|
+
arrays.append(array)
|
|
65
|
+
if len(shapes) <= 1:
|
|
66
|
+
check_cancelled()
|
|
67
|
+
if preserve_integers:
|
|
68
|
+
dtype = integer_preserving_dtype(dtypes)
|
|
69
|
+
return np.array(arrays, dtype=dtype)
|
|
70
|
+
|
|
71
|
+
column = np.empty(len(values), dtype=object)
|
|
72
|
+
for index, array in enumerate(arrays):
|
|
73
|
+
check_cancelled()
|
|
74
|
+
# Assign one cell at a time: slice assignment also tries broadcasting.
|
|
75
|
+
column[index] = array
|
|
76
|
+
return column
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_parameter_data_for_one_paramtree(
|
|
80
|
+
conn, table_name, rundescriber, output_param, start=None, end=None,
|
|
81
|
+
*, check_cancelled: Callable[[], None] = _check_noop,
|
|
82
|
+
preserve_integers=True,
|
|
83
|
+
):
|
|
84
|
+
"""Decode one tree on an existing read-only connection.
|
|
85
|
+
|
|
86
|
+
start/end and the returned count refer to non-NULL storage records, not
|
|
87
|
+
flattened samples. No connections, transactions or cache writes are added.
|
|
88
|
+
Errors from reading, expansion or dtype conversion propagate unchanged.
|
|
89
|
+
Plotting and CSV both retain the precision of stored array samples.
|
|
90
|
+
"""
|
|
91
|
+
check_cancelled()
|
|
92
|
+
records, specs, count = _get_data_for_one_param_tree(
|
|
93
|
+
conn, table_name, rundescriber.interdeps, output_param, start, end, None,
|
|
94
|
+
)
|
|
95
|
+
check_cancelled()
|
|
96
|
+
if specs[0].name != output_param:
|
|
97
|
+
raise ValueError("The output parameter must be first in its parameter tree.")
|
|
98
|
+
|
|
99
|
+
# Expand within each acquisition record, never across record boundaries.
|
|
100
|
+
for index, record in enumerate(records):
|
|
101
|
+
check_cancelled()
|
|
102
|
+
expanded = [record]
|
|
103
|
+
_expand_data_to_arrays(expanded, specs)
|
|
104
|
+
records[index] = expanded[0]
|
|
105
|
+
|
|
106
|
+
data = {}
|
|
107
|
+
for index, spec in enumerate(specs):
|
|
108
|
+
check_cancelled()
|
|
109
|
+
values = [record[index] for record in records]
|
|
110
|
+
# Match QCoDeS' numeric SQL-column convention. Array dtypes retain
|
|
111
|
+
# their precision, including integers and complex values for export.
|
|
112
|
+
dtype = np.float64 if spec.type == "numeric" else None
|
|
113
|
+
data[spec.name] = _record_column(
|
|
114
|
+
values, dtype, check_cancelled, preserve_integers=preserve_integers,
|
|
115
|
+
)
|
|
116
|
+
check_cancelled()
|
|
117
|
+
return data, count
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parameter_data_for_export(dataset, parameter_name):
|
|
121
|
+
"""Read DB-backed trees locally; in-memory datasets provide their own data."""
|
|
122
|
+
if isinstance(dataset, DataSet):
|
|
123
|
+
data, _ = get_parameter_data_for_one_paramtree(
|
|
124
|
+
dataset.conn, dataset.table_name, dataset.description, parameter_name,
|
|
125
|
+
preserve_integers=True,
|
|
126
|
+
)
|
|
127
|
+
return data
|
|
128
|
+
return dataset.get_parameter_data(parameter_name).get(parameter_name, {})
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def flatten_record_columns(data, check_cancelled=_check_noop):
|
|
132
|
+
"""Flatten aligned record cells before inserting into a planned scan shape."""
|
|
133
|
+
for record in zip(*data.values(), strict=True):
|
|
134
|
+
check_cancelled()
|
|
135
|
+
if len({np.size(value) for value in record}) > 1:
|
|
136
|
+
raise ValueError("Setpoint and measurement shapes do not match.")
|
|
137
|
+
flattened = {}
|
|
138
|
+
for name, column in data.items():
|
|
139
|
+
check_cancelled()
|
|
140
|
+
if column.dtype.kind != "O":
|
|
141
|
+
flattened[name] = column.reshape(-1)
|
|
142
|
+
continue
|
|
143
|
+
parts = []
|
|
144
|
+
for record in column:
|
|
145
|
+
check_cancelled()
|
|
146
|
+
parts.append(np.asarray(record).ravel())
|
|
147
|
+
flattened[name] = concatenate_record_samples(parts)
|
|
148
|
+
check_cancelled()
|
|
149
|
+
return flattened
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Immutable, bounded progress observations; no callbacks or Qt dependencies."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class PlotProgress:
|
|
8
|
+
phase: str
|
|
9
|
+
completed: int = 0
|
|
10
|
+
total: int | None = None
|
|
11
|
+
unit: str = "scan"
|
|
12
|
+
stage: int = 1
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def title(self) -> str:
|
|
16
|
+
return f"Stage {self.stage}/2: {self.phase}"
|
|
17
|
+
|