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.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. 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
+