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
qplot/testdata.py ADDED
@@ -0,0 +1,1733 @@
1
+ """Generate synthetic QCoDeS databases from CSV specifications."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import errno
8
+ import math
9
+ import os
10
+ import re
11
+ import secrets
12
+ import sqlite3
13
+ import stat
14
+ import tempfile
15
+ from collections.abc import Sequence
16
+ from dataclasses import dataclass
17
+ from datetime import datetime
18
+ from importlib import resources
19
+ from pathlib import Path
20
+ from time import perf_counter
21
+
22
+ import numpy as np
23
+ from qcodes.dataset import Measurement, new_experiment
24
+ from qcodes.dataset.sqlite.connection import AtomicConnection
25
+ from qcodes.dataset.sqlite.database import connect
26
+ from qcodes.parameters import ManualParameter
27
+
28
+ from qplot.datahandling.file_identity import (
29
+ QPLOT_GENERATED_DATABASE_APPLICATION_ID,
30
+ QPLOT_GENERATION_LINEAGE_FORMAT_VERSION,
31
+ QPLOT_GENERATION_LINEAGE_NONCE_BYTES,
32
+ QPLOT_GENERATION_LINEAGE_RING_TABLE,
33
+ QPLOT_GENERATION_LINEAGE_STATE_TABLE,
34
+ QPLOT_GENERATION_LINEAGE_WINDOW,
35
+ QPLOT_GENERATION_PROVENANCE_TABLE,
36
+ QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES,
37
+ QPLOT_GENERATION_PROVENANCE_TRIGGER_PREFIX,
38
+ database_publication_guard_path,
39
+ generation_lineage_transition_statements,
40
+ generation_provenance_trigger,
41
+ )
42
+
43
+ CSV_COLUMNS = (
44
+ "dimensions",
45
+ "measured_name",
46
+ "measured_label",
47
+ "measured_unit",
48
+ "v_sd_start",
49
+ "v_sd_stop",
50
+ "v_sd_points",
51
+ "v_g_start",
52
+ "v_g_stop",
53
+ "v_g_points",
54
+ )
55
+
56
+ EXAMPLE_ROWS = (
57
+ ("1", "current", "Current", "nA", "-0.1", "0.1", "501", "", "", ""),
58
+ (
59
+ "1",
60
+ "conductance",
61
+ "Conductance",
62
+ "uS",
63
+ "-0.1",
64
+ "0.1",
65
+ "501",
66
+ "",
67
+ "",
68
+ "",
69
+ ),
70
+ (
71
+ "1",
72
+ "resistance",
73
+ "Resistance",
74
+ "kOhm",
75
+ "-0.1",
76
+ "0.1",
77
+ "501",
78
+ "",
79
+ "",
80
+ "",
81
+ ),
82
+ (
83
+ "1",
84
+ "transconductance",
85
+ "Transconductance",
86
+ "uS/V",
87
+ "-0.1",
88
+ "0.1",
89
+ "501",
90
+ "",
91
+ "",
92
+ "",
93
+ ),
94
+ (
95
+ "1",
96
+ "current",
97
+ "Current",
98
+ "nA",
99
+ "-0.1",
100
+ "0.1",
101
+ "501",
102
+ "",
103
+ "",
104
+ "",
105
+ ),
106
+ (
107
+ "2",
108
+ "current",
109
+ "Current",
110
+ "nA",
111
+ "-0.01",
112
+ "0.01",
113
+ "121",
114
+ "-1",
115
+ "1",
116
+ "81",
117
+ ),
118
+ (
119
+ "2",
120
+ "current",
121
+ "Current",
122
+ "nA",
123
+ "-0.01",
124
+ "0.01",
125
+ "121",
126
+ "-1",
127
+ "1",
128
+ "81",
129
+ ),
130
+ )
131
+
132
+ INSTRUCTION_FILE_NAMES = (
133
+ "qplot_test_db_01_10mb.csv",
134
+ "qplot_test_db_02_25mb.csv",
135
+ "qplot_test_db_03_50mb.csv",
136
+ "qplot_test_db_04_100mb.csv",
137
+ "qplot_test_db_05_250mb.csv",
138
+ "qplot_test_db_06_500mb.csv",
139
+ "qplot_test_db_07_1gb.csv",
140
+ "qplot_test_db_08_5gb.csv",
141
+ "qplot_test_db_09_10gb.csv",
142
+ "qplot_test_db_10_30gb.csv",
143
+ )
144
+
145
+ _PARAMETER_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
146
+ _MINIMUM_AMPLITUDE = 0.5
147
+ _MAXIMUM_AMPLITUDE = 1.5
148
+ _MINIMUM_FREQUENCY = 0.5
149
+ _MAXIMUM_FREQUENCY = 4.0
150
+ _SINUSOID_COMPONENT_COUNT = 2
151
+ _RESULT_CHUNK_POINTS = 10_000
152
+ _TEMPORARY_DATABASE_PREFIX = ".qplot-testdata-"
153
+ _PROVENANCE_TRIGGER_PREFIX = QPLOT_GENERATION_PROVENANCE_TRIGGER_PREFIX
154
+ _PROVENANCE_WRITER_ENABLED_ATTRIBUTE = "_qplot_provenance_writer_enabled"
155
+ _SQLITE_SIDECAR_SUFFIXES = ("-wal", "-shm", "-journal")
156
+ _SQLITE_HEADER = b"SQLite format 3\x00"
157
+ _SQLITE_ROLLBACK_JOURNAL_VERSIONS = b"\x01\x01"
158
+ _REPLACEMENT_BUSY_ERRNOS = frozenset(
159
+ value
160
+ for value in (
161
+ errno.EACCES,
162
+ errno.EBUSY,
163
+ errno.EPERM,
164
+ getattr(errno, "ETXTBSY", None),
165
+ )
166
+ if value is not None
167
+ )
168
+
169
+
170
+ class SpecificationError(ValueError):
171
+ """Raised when a test-database CSV specification is invalid."""
172
+
173
+
174
+ class GenerationCancelled(RuntimeError):
175
+ """Raised internally when test-database generation is cancelled."""
176
+
177
+
178
+ def _result_column_name_is_usable(name):
179
+ """Return whether SQLite accepts ``name`` as QCoDeS will emit it."""
180
+ connection = sqlite3.connect(":memory:")
181
+ try:
182
+ # Parameter names have already passed _PARAMETER_NAME_PATTERN, so this
183
+ # parser probe cannot introduce additional SQL tokens. Keeping the
184
+ # identifier unquoted deliberately mirrors QCoDeS result-table SQL.
185
+ connection.execute(
186
+ f"CREATE TABLE qplot_results (id INTEGER PRIMARY KEY, {name} numeric)"
187
+ )
188
+ except sqlite3.DatabaseError:
189
+ return False
190
+ finally:
191
+ connection.close()
192
+ return True
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class RunSpecification:
197
+ """Validated settings for one generated QCoDeS run."""
198
+
199
+ dimensions: int
200
+ measured_name: str
201
+ measured_label: str
202
+ measured_unit: str
203
+ v_sd_start: float
204
+ v_sd_stop: float
205
+ v_sd_points: int
206
+ v_g_start: float | None = None
207
+ v_g_stop: float | None = None
208
+ v_g_points: int | None = None
209
+
210
+ @property
211
+ def point_count(self):
212
+ """Return the number of measured points in the run."""
213
+ if self.dimensions == 1:
214
+ return self.v_sd_points
215
+ assert self.v_g_points is not None
216
+ return self.v_sd_points * self.v_g_points
217
+
218
+
219
+ def _row_error(line_number, message):
220
+ return SpecificationError(f"CSV row {line_number}: {message}")
221
+
222
+
223
+ def _required(row, column, line_number):
224
+ value = (row.get(column) or "").strip()
225
+ if not value:
226
+ raise _row_error(line_number, f"{column} is required")
227
+ return value
228
+
229
+
230
+ def _finite_float(row, column, line_number):
231
+ value = _required(row, column, line_number)
232
+ try:
233
+ number = float(value)
234
+ except ValueError as error:
235
+ raise _row_error(line_number, f"{column} must be a number") from error
236
+ if not math.isfinite(number):
237
+ raise _row_error(line_number, f"{column} must be finite")
238
+ return number
239
+
240
+
241
+ def _point_count(row, column, line_number):
242
+ value = _required(row, column, line_number)
243
+ try:
244
+ points = int(value)
245
+ except ValueError as error:
246
+ raise _row_error(line_number, f"{column} must be an integer") from error
247
+ if points < 2:
248
+ raise _row_error(line_number, f"{column} must be at least 2")
249
+ return points
250
+
251
+
252
+ def _parse_row(row, line_number):
253
+ dimensions_value = _required(row, "dimensions", line_number)
254
+ try:
255
+ dimensions = int(dimensions_value)
256
+ except ValueError as error:
257
+ raise _row_error(line_number, "dimensions must be 1 or 2") from error
258
+ if dimensions not in (1, 2):
259
+ raise _row_error(line_number, "dimensions must be 1 or 2")
260
+
261
+ measured_name = _required(row, "measured_name", line_number)
262
+ if not _PARAMETER_NAME_PATTERN.fullmatch(measured_name):
263
+ raise _row_error(
264
+ line_number,
265
+ "measured_name must start with a letter or underscore and contain "
266
+ "only letters, numbers, and underscores",
267
+ )
268
+ if measured_name.lower() in {"v_sd", "v_g"}:
269
+ raise _row_error(
270
+ line_number,
271
+ "measured_name cannot be V_SD or V_G because those names are swept",
272
+ )
273
+ if not _result_column_name_is_usable(measured_name):
274
+ raise _row_error(
275
+ line_number,
276
+ "measured_name cannot be used as a QCoDeS result column",
277
+ )
278
+
279
+ measured_label = _required(row, "measured_label", line_number)
280
+ measured_unit = (row.get("measured_unit") or "").strip()
281
+ v_sd_start = _finite_float(row, "v_sd_start", line_number)
282
+ v_sd_stop = _finite_float(row, "v_sd_stop", line_number)
283
+ if v_sd_start == v_sd_stop:
284
+ raise _row_error(line_number, "v_sd_start and v_sd_stop must differ")
285
+ v_sd_points = _point_count(row, "v_sd_points", line_number)
286
+
287
+ gate_values = tuple((row.get(column) or "").strip() for column in CSV_COLUMNS[7:])
288
+ if dimensions == 1:
289
+ if any(gate_values):
290
+ raise _row_error(
291
+ line_number,
292
+ "v_g_start, v_g_stop, and v_g_points must be blank for a 1D run",
293
+ )
294
+ return RunSpecification(
295
+ dimensions=dimensions,
296
+ measured_name=measured_name,
297
+ measured_label=measured_label,
298
+ measured_unit=measured_unit,
299
+ v_sd_start=v_sd_start,
300
+ v_sd_stop=v_sd_stop,
301
+ v_sd_points=v_sd_points,
302
+ )
303
+
304
+ v_g_start = _finite_float(row, "v_g_start", line_number)
305
+ v_g_stop = _finite_float(row, "v_g_stop", line_number)
306
+ if v_g_start == v_g_stop:
307
+ raise _row_error(line_number, "v_g_start and v_g_stop must differ")
308
+ v_g_points = _point_count(row, "v_g_points", line_number)
309
+ return RunSpecification(
310
+ dimensions=dimensions,
311
+ measured_name=measured_name,
312
+ measured_label=measured_label,
313
+ measured_unit=measured_unit,
314
+ v_sd_start=v_sd_start,
315
+ v_sd_stop=v_sd_stop,
316
+ v_sd_points=v_sd_points,
317
+ v_g_start=v_g_start,
318
+ v_g_stop=v_g_stop,
319
+ v_g_points=v_g_points,
320
+ )
321
+
322
+
323
+ def read_specifications(csv_path):
324
+ """Read and validate all nonblank rows in a generator CSV file."""
325
+ csv_path = Path(csv_path)
326
+ try:
327
+ handle = csv_path.open("r", encoding="utf-8-sig", newline="")
328
+ except OSError as error:
329
+ raise SpecificationError(f"Could not open {csv_path}: {error}") from error
330
+
331
+ with handle:
332
+ reader = csv.DictReader(handle)
333
+ fieldnames = reader.fieldnames
334
+ if fieldnames is None:
335
+ raise SpecificationError("CSV file is empty")
336
+ fieldnames = [name.strip() for name in fieldnames]
337
+ reader.fieldnames = fieldnames
338
+ if len(fieldnames) != len(set(fieldnames)):
339
+ raise SpecificationError("CSV header contains duplicate columns")
340
+ missing = [column for column in CSV_COLUMNS if column not in fieldnames]
341
+ extra = [column for column in fieldnames if column not in CSV_COLUMNS]
342
+ if missing or extra:
343
+ details = []
344
+ if missing:
345
+ details.append(f"missing columns: {', '.join(missing)}")
346
+ if extra:
347
+ details.append(f"unexpected columns: {', '.join(extra)}")
348
+ raise SpecificationError("Invalid CSV header (" + "; ".join(details) + ")")
349
+
350
+ specifications = []
351
+ for line_number, row in enumerate(reader, start=2):
352
+ if None in row and any(str(value).strip() for value in row[None]):
353
+ raise _row_error(line_number, "contains more values than the header")
354
+ if not any((row.get(column) or "").strip() for column in CSV_COLUMNS):
355
+ continue
356
+ specifications.append(_parse_row(row, line_number))
357
+
358
+ if not specifications:
359
+ raise SpecificationError("CSV file contains no run specifications")
360
+ return specifications
361
+
362
+
363
+ def write_example_csv(csv_path, overwrite=False):
364
+ """Write a ready-to-use example generator CSV and return its path."""
365
+ csv_path = Path(csv_path)
366
+ if csv_path.suffix.lower() != ".csv":
367
+ raise SpecificationError("Example output path must end in .csv")
368
+ csv_path.parent.mkdir(parents=True, exist_ok=True)
369
+ mode = "w" if overwrite else "x"
370
+ try:
371
+ with csv_path.open(mode, encoding="utf-8-sig", newline="") as handle:
372
+ writer = csv.writer(handle)
373
+ writer.writerow(CSV_COLUMNS)
374
+ writer.writerows(EXAMPLE_ROWS)
375
+ except FileExistsError as error:
376
+ raise SpecificationError(
377
+ f"{csv_path} already exists; use --overwrite to replace it"
378
+ ) from error
379
+ except OSError as error:
380
+ raise SpecificationError(f"Could not write {csv_path}: {error}") from error
381
+ return csv_path
382
+
383
+
384
+ def copy_instruction_collection(directory, overwrite=False):
385
+ """Copy the installed cumulative instruction CSV collection to a directory."""
386
+ directory = Path(directory)
387
+ if directory.exists() and not directory.is_dir():
388
+ raise SpecificationError(f"Collection destination is not a directory: {directory}")
389
+
390
+ try:
391
+ directory.mkdir(parents=True, exist_ok=True)
392
+ except OSError as error:
393
+ raise SpecificationError(f"Could not create {directory}: {error}") from error
394
+
395
+ output_paths = tuple(directory / name for name in INSTRUCTION_FILE_NAMES)
396
+ if not overwrite:
397
+ existing = [path.name for path in output_paths if path.exists()]
398
+ if existing:
399
+ raise SpecificationError(
400
+ f"{directory} already contains collection files; use --overwrite "
401
+ f"to replace them: {', '.join(existing)}"
402
+ )
403
+
404
+ try:
405
+ for output_path, (_name, content) in zip(
406
+ output_paths,
407
+ instruction_collection_contents(),
408
+ strict=True,
409
+ ):
410
+ mode = "wb" if overwrite else "xb"
411
+ with output_path.open(mode) as handle:
412
+ handle.write(content)
413
+ except OSError as error:
414
+ raise SpecificationError(
415
+ f"Could not copy the instruction CSV collection to {directory}: {error}"
416
+ ) from error
417
+
418
+ return output_paths
419
+
420
+
421
+ def instruction_collection_contents():
422
+ """Return installed collection filenames and bytes without writing them."""
423
+ resource_directory = resources.files("qplot").joinpath("resources", "testdata")
424
+ return tuple(
425
+ (name, resource_directory.joinpath(name).read_bytes())
426
+ for name in INSTRUCTION_FILE_NAMES
427
+ )
428
+
429
+
430
+ def _raise_if_cancelled(cancelled_callback):
431
+ if cancelled_callback is not None and cancelled_callback():
432
+ raise GenerationCancelled("Test-database generation was cancelled")
433
+
434
+
435
+ def _qcodes_uri_name(database_path):
436
+ """Return one URI-encoded path layer for QCoDeS' ``file:`` prefix."""
437
+ database_uri = Path(database_path).absolute().as_uri()
438
+ return database_uri.removeprefix("file:")
439
+
440
+
441
+ def _connect_writable_exact_path(database_path):
442
+ """Open an exact generator-owned path with QCoDeS' writable connector."""
443
+ return connect(_qcodes_uri_name(database_path))
444
+
445
+
446
+ def _quote_sqlite_identifier(identifier):
447
+ return '"' + identifier.replace('"', '""') + '"'
448
+
449
+
450
+ def _generation_provenance_trigger(table_name, operation):
451
+ """Return a stable trigger name and exact SQL for one table operation."""
452
+ return generation_provenance_trigger(table_name, operation)
453
+
454
+
455
+ def _generation_provenance_user_tables(connection):
456
+ excluded_tables = (
457
+ QPLOT_GENERATION_PROVENANCE_TABLE,
458
+ QPLOT_GENERATION_LINEAGE_STATE_TABLE,
459
+ QPLOT_GENERATION_LINEAGE_RING_TABLE,
460
+ )
461
+ return [
462
+ row[0]
463
+ for row in connection.execute(
464
+ "SELECT name FROM sqlite_schema "
465
+ "WHERE type = 'table' AND name NOT LIKE 'sqlite_%' "
466
+ "AND name NOT IN (?, ?, ?) ORDER BY name",
467
+ excluded_tables,
468
+ )
469
+ ]
470
+
471
+
472
+ def _ensure_generation_provenance_triggers(connection):
473
+ """Install exact provenance triggers for every table in this transaction."""
474
+ changed = False
475
+ existing_triggers = {
476
+ row[0]: (row[1], row[2])
477
+ for row in connection.execute(
478
+ "SELECT name, tbl_name, sql FROM sqlite_schema WHERE type = 'trigger'"
479
+ ).fetchall()
480
+ }
481
+ for table_name in _generation_provenance_user_tables(connection):
482
+ for operation in ("INSERT", "UPDATE", "DELETE"):
483
+ trigger_name, statement = _generation_provenance_trigger(
484
+ table_name,
485
+ operation,
486
+ )
487
+ trigger_row = existing_triggers.get(trigger_name)
488
+ if trigger_row is None:
489
+ connection.execute(statement)
490
+ existing_triggers[trigger_name] = (table_name, statement)
491
+ changed = True
492
+ elif trigger_row != (table_name, statement):
493
+ raise RuntimeError(
494
+ "The generated database contains a conflicting qPlot "
495
+ f"provenance trigger named {trigger_name!r}."
496
+ )
497
+ return changed
498
+
499
+
500
+ def _create_generation_lineage(connection, token):
501
+ """Create and seed the bounded nonce ancestry chain at sequence zero."""
502
+ state_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_STATE_TABLE)
503
+ ring_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_RING_TABLE)
504
+ connection.execute(
505
+ f"CREATE TABLE {ring_table} ("
506
+ "slot INTEGER PRIMARY KEY, "
507
+ "sequence INTEGER NOT NULL UNIQUE CHECK (sequence >= 0), "
508
+ "parent_nonce BLOB, nonce BLOB NOT NULL UNIQUE, "
509
+ f"CHECK (slot = sequence % {QPLOT_GENERATION_LINEAGE_WINDOW}), "
510
+ "CHECK ((sequence = 0 AND parent_nonce IS NULL) OR "
511
+ f"(typeof(parent_nonce) = 'blob' AND length(parent_nonce) = "
512
+ f"{QPLOT_GENERATION_LINEAGE_NONCE_BYTES})), "
513
+ f"CHECK (typeof(nonce) = 'blob' AND length(nonce) = "
514
+ f"{QPLOT_GENERATION_LINEAGE_NONCE_BYTES})) WITHOUT ROWID"
515
+ )
516
+ connection.execute(
517
+ f"INSERT INTO {ring_table} VALUES "
518
+ f"(0, 0, NULL, randomblob({QPLOT_GENERATION_LINEAGE_NONCE_BYTES}))"
519
+ )
520
+ connection.execute(
521
+ f"CREATE TABLE {state_table} ("
522
+ "singleton INTEGER PRIMARY KEY CHECK (singleton = 1), "
523
+ "format_version INTEGER NOT NULL, generation_token TEXT NOT NULL, "
524
+ "head_sequence INTEGER NOT NULL CHECK (head_sequence >= 0), "
525
+ "head_nonce BLOB NOT NULL, window_size INTEGER NOT NULL, "
526
+ f"CHECK (format_version = {QPLOT_GENERATION_LINEAGE_FORMAT_VERSION}), "
527
+ f"CHECK (typeof(head_nonce) = 'blob' AND length(head_nonce) = "
528
+ f"{QPLOT_GENERATION_LINEAGE_NONCE_BYTES}), "
529
+ f"CHECK (window_size = {QPLOT_GENERATION_LINEAGE_WINDOW}))"
530
+ )
531
+ connection.execute(
532
+ f"INSERT INTO {state_table} "
533
+ "SELECT 1, ?, ?, 0, nonce, ? "
534
+ f"FROM {ring_table} WHERE sequence = 0",
535
+ (
536
+ QPLOT_GENERATION_LINEAGE_FORMAT_VERSION,
537
+ token,
538
+ QPLOT_GENERATION_LINEAGE_WINDOW,
539
+ ),
540
+ )
541
+
542
+
543
+ def _advance_generation_lineage(connection):
544
+ for statement in generation_lineage_transition_statements():
545
+ connection.execute(statement)
546
+
547
+
548
+ def _generation_lineage_state(connection):
549
+ """Return a validated v2 writer state, or ``None`` for legacy provenance."""
550
+ state_exists = connection.execute(
551
+ "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?",
552
+ (QPLOT_GENERATION_LINEAGE_STATE_TABLE,),
553
+ ).fetchone()
554
+ ring_exists = connection.execute(
555
+ "SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?",
556
+ (QPLOT_GENERATION_LINEAGE_RING_TABLE,),
557
+ ).fetchone()
558
+ if state_exists is None and ring_exists is None:
559
+ return None
560
+ if state_exists is None or ring_exists is None:
561
+ raise ValueError("The qPlot generation lineage tables are incomplete.")
562
+
563
+ state_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_STATE_TABLE)
564
+ ring_table = _quote_sqlite_identifier(QPLOT_GENERATION_LINEAGE_RING_TABLE)
565
+ rows = connection.execute(
566
+ f"SELECT singleton, format_version, generation_token, head_sequence, "
567
+ f"head_nonce, window_size FROM {state_table}"
568
+ ).fetchall()
569
+ if len(rows) != 1:
570
+ raise ValueError("The qPlot generation lineage state is invalid.")
571
+ singleton, version, token, sequence, nonce, window = rows[0]
572
+ tip = connection.execute(
573
+ f"SELECT slot, parent_nonce, nonce FROM {ring_table} WHERE sequence = ?",
574
+ (sequence,),
575
+ ).fetchone()
576
+ if (
577
+ singleton != 1
578
+ or version != QPLOT_GENERATION_LINEAGE_FORMAT_VERSION
579
+ or not isinstance(token, str)
580
+ or not isinstance(sequence, int)
581
+ or sequence < 0
582
+ or not isinstance(nonce, bytes)
583
+ or len(nonce) != QPLOT_GENERATION_LINEAGE_NONCE_BYTES
584
+ or window != QPLOT_GENERATION_LINEAGE_WINDOW
585
+ or tip is None
586
+ or tip[0] != sequence % QPLOT_GENERATION_LINEAGE_WINDOW
587
+ or tip[2] != nonce
588
+ ):
589
+ raise ValueError("The qPlot generation lineage state is invalid.")
590
+ return token, sequence, nonce
591
+
592
+
593
+ def _legacy_generation_provenance_triggers(connection):
594
+ """Return only structurally exact triggers written by legacy qPlot."""
595
+ legacy_triggers = []
596
+ pattern = re.compile(r"qplot_provenance_\d+_(insert|update|delete)\Z")
597
+ provenance_table = _quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)
598
+ for trigger_name, table_name, sql in connection.execute(
599
+ "SELECT name, tbl_name, sql FROM sqlite_schema WHERE type = 'trigger'"
600
+ ).fetchall():
601
+ match = pattern.fullmatch(trigger_name)
602
+ if match is None:
603
+ continue
604
+ operation = match.group(1).upper()
605
+ expected = (
606
+ f"CREATE TRIGGER {_quote_sqlite_identifier(trigger_name)} "
607
+ f"AFTER {operation} ON {_quote_sqlite_identifier(table_name)} BEGIN "
608
+ f"UPDATE {provenance_table} SET write_epoch = write_epoch + 1 "
609
+ "WHERE singleton = 1; END"
610
+ )
611
+ if sql != expected:
612
+ raise RuntimeError(
613
+ "The database contains a conflicting legacy qPlot provenance "
614
+ f"trigger named {trigger_name!r}."
615
+ )
616
+ legacy_triggers.append(trigger_name)
617
+ return legacy_triggers
618
+
619
+
620
+ def _remove_legacy_generation_provenance_triggers(connection):
621
+ changed = False
622
+ for trigger_name in _legacy_generation_provenance_triggers(connection):
623
+ connection.execute(
624
+ f"DROP TRIGGER {_quote_sqlite_identifier(trigger_name)}"
625
+ )
626
+ changed = True
627
+ return changed
628
+
629
+
630
+ def _install_generation_provenance(connection):
631
+ """Make later WAL writes prove that they descend from this main file."""
632
+ token = secrets.token_hex(QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES)
633
+ provenance_table = _quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)
634
+ connection.execute(
635
+ f"CREATE TABLE {provenance_table} ("
636
+ "singleton INTEGER PRIMARY KEY CHECK (singleton = 1), "
637
+ "generation_token TEXT NOT NULL, "
638
+ "write_epoch INTEGER NOT NULL CHECK (write_epoch >= 0))"
639
+ )
640
+ connection.execute(
641
+ f"INSERT INTO {provenance_table} VALUES (1, ?, 0)",
642
+ (token,),
643
+ )
644
+ _create_generation_lineage(connection, token)
645
+ _ensure_generation_provenance_triggers(connection)
646
+
647
+
648
+ def _require_generation_provenance_for_writer(connection):
649
+ application_id = connection.execute("PRAGMA application_id").fetchone()
650
+ try:
651
+ provenance_rows = connection.execute(
652
+ f"SELECT singleton, generation_token, write_epoch "
653
+ f"FROM {_quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)}"
654
+ ).fetchall()
655
+ except sqlite3.Error as error:
656
+ raise ValueError(
657
+ "This is not a qPlot-generated database with writable provenance."
658
+ ) from error
659
+
660
+ if len(provenance_rows) != 1:
661
+ raise ValueError(
662
+ "This is not a qPlot-generated database with writable provenance."
663
+ )
664
+ singleton, token, epoch = provenance_rows[0]
665
+ try:
666
+ token_bytes = bytes.fromhex(token) if isinstance(token, str) else b""
667
+ except ValueError:
668
+ token_bytes = b""
669
+ if (
670
+ application_id != (QPLOT_GENERATED_DATABASE_APPLICATION_ID,)
671
+ or singleton != 1
672
+ or len(token_bytes) != QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES
673
+ or not isinstance(epoch, int)
674
+ or epoch < 0
675
+ ):
676
+ raise ValueError(
677
+ "This is not a qPlot-generated database with writable provenance."
678
+ )
679
+ lineage = _generation_lineage_state(connection)
680
+ if lineage is not None:
681
+ lineage_token, lineage_sequence, _lineage_nonce = lineage
682
+ if lineage_token != token or lineage_sequence != epoch:
683
+ raise ValueError("The qPlot generation lineage state is invalid.")
684
+ return lineage
685
+
686
+
687
+ def enable_generation_provenance_for_writer(connection):
688
+ """Cover future QCoDeS result tables on an explicitly writable connection.
689
+
690
+ SQLite has no persistent database-wide write trigger. A QCoDeS writer that
691
+ will create later runs must therefore opt into this connection hook before
692
+ it performs any new experiment or measurement writes. The hook refuses a
693
+ nonempty WAL, installs table triggers before the outer QCoDeS transaction
694
+ commits, and extends a token-bound nonce chain. Subsequent foreground or
695
+ background result writes therefore remain provable across checkpoints.
696
+
697
+ This function is exclusively for a connection deliberately opened by the
698
+ database owner for writing. qPlot's viewer never calls it and never
699
+ installs metadata or triggers in an input database.
700
+ """
701
+ if not isinstance(connection, AtomicConnection):
702
+ raise TypeError(
703
+ "enable_generation_provenance_for_writer requires a writable "
704
+ "QCoDeS AtomicConnection"
705
+ )
706
+ if getattr(connection, _PROVENANCE_WRITER_ENABLED_ATTRIBUTE, False):
707
+ return connection
708
+ if connection.in_transaction:
709
+ raise RuntimeError(
710
+ "Enable qPlot generation provenance before starting a write "
711
+ "transaction."
712
+ )
713
+ if connection.isolation_level is None:
714
+ raise RuntimeError(
715
+ "Enable qPlot generation provenance before using autocommit mode."
716
+ )
717
+
718
+ original_commit = connection.commit
719
+ original_rollback = connection.rollback
720
+ last_synchronized_total_changes = connection.total_changes
721
+ last_synchronized_schema_version = connection.execute(
722
+ "PRAGMA schema_version"
723
+ ).fetchone()[0]
724
+
725
+ def synchronize_triggers():
726
+ _ensure_generation_provenance_triggers(connection)
727
+ _advance_generation_lineage(connection)
728
+
729
+ def provenance_commit():
730
+ nonlocal last_synchronized_schema_version
731
+ nonlocal last_synchronized_total_changes
732
+ if not connection.in_transaction:
733
+ return original_commit()
734
+ schema_version = connection.execute("PRAGMA schema_version").fetchone()[0]
735
+ if (
736
+ connection.total_changes == last_synchronized_total_changes
737
+ and schema_version == last_synchronized_schema_version
738
+ ):
739
+ return original_commit()
740
+ try:
741
+ synchronize_triggers()
742
+ result = original_commit()
743
+ last_synchronized_total_changes = connection.total_changes
744
+ last_synchronized_schema_version = connection.execute(
745
+ "PRAGMA schema_version"
746
+ ).fetchone()[0]
747
+ return result
748
+ except BaseException:
749
+ try:
750
+ original_rollback()
751
+ except sqlite3.Error:
752
+ pass
753
+ last_synchronized_total_changes = connection.total_changes
754
+ last_synchronized_schema_version = connection.execute(
755
+ "PRAGMA schema_version"
756
+ ).fetchone()[0]
757
+ raise
758
+
759
+ def provenance_rollback():
760
+ nonlocal last_synchronized_schema_version
761
+ nonlocal last_synchronized_total_changes
762
+ result = original_rollback()
763
+ last_synchronized_total_changes = connection.total_changes
764
+ last_synchronized_schema_version = connection.execute(
765
+ "PRAGMA schema_version"
766
+ ).fetchone()[0]
767
+ return result
768
+
769
+ # Acquire SQLite's writer lock before inspecting the lineage or WAL. A
770
+ # second connection must not be able to park uninstrumented frames between
771
+ # the quiescence check and the first provenance event.
772
+ connection.execute("BEGIN IMMEDIATE")
773
+ try:
774
+ lineage = _require_generation_provenance_for_writer(connection)
775
+ journal_mode = connection.execute("PRAGMA journal_mode").fetchone()[0].lower()
776
+ if journal_mode == "wal":
777
+ wal_path = Path(f"{connection.path_to_dbfile}-wal")
778
+ try:
779
+ wal_size = wal_path.stat().st_size
780
+ except FileNotFoundError:
781
+ wal_size = 0
782
+ if wal_size:
783
+ raise RuntimeError(
784
+ "qPlot provenance refuses to bless writes already present in a "
785
+ "WAL. On the owner connection, checkpoint the WAL with TRUNCATE, "
786
+ "then enable provenance before creating a Measurement."
787
+ )
788
+ if lineage is None and journal_mode == "wal":
789
+ raise RuntimeError(
790
+ "This older qPlot-generated database must be migrated from a "
791
+ "quiescent rollback-journal connection. Checkpoint the WAL, switch "
792
+ "the owner connection to journal_mode=DELETE, then enable generation "
793
+ "provenance before creating a Measurement."
794
+ )
795
+ if lineage is None:
796
+ _remove_legacy_generation_provenance_triggers(connection)
797
+ migrated_token = secrets.token_hex(
798
+ QPLOT_GENERATION_PROVENANCE_TOKEN_BYTES
799
+ )
800
+ connection.execute(
801
+ f"UPDATE {_quote_sqlite_identifier(QPLOT_GENERATION_PROVENANCE_TABLE)} "
802
+ "SET generation_token = ?, write_epoch = 0 WHERE singleton = 1",
803
+ (migrated_token,),
804
+ )
805
+ _create_generation_lineage(connection, migrated_token)
806
+ else:
807
+ _remove_legacy_generation_provenance_triggers(connection)
808
+ synchronize_triggers()
809
+ original_commit()
810
+ last_synchronized_total_changes = connection.total_changes
811
+ last_synchronized_schema_version = connection.execute(
812
+ "PRAGMA schema_version"
813
+ ).fetchone()[0]
814
+ except BaseException:
815
+ try:
816
+ original_rollback()
817
+ except sqlite3.Error:
818
+ pass
819
+ raise
820
+
821
+ connection.commit = provenance_commit
822
+ connection.rollback = provenance_rollback
823
+ setattr(connection, _PROVENANCE_WRITER_ENABLED_ATTRIBUTE, True)
824
+ return connection
825
+
826
+
827
+ def _owned_temporary_artifacts(temporary_path, *, include_database=True):
828
+ if include_database:
829
+ yield temporary_path
830
+ for suffix in _SQLITE_SIDECAR_SUFFIXES:
831
+ yield Path(f"{temporary_path}{suffix}")
832
+
833
+
834
+ def _remove_owned_temporary_artifacts(temporary_path, *, include_database=True):
835
+ """Remove only the generator-owned database and its possible sidecars."""
836
+ failures = []
837
+ for artifact_path in _owned_temporary_artifacts(
838
+ temporary_path,
839
+ include_database=include_database,
840
+ ):
841
+ try:
842
+ artifact_path.unlink(missing_ok=True)
843
+ except OSError as error:
844
+ failures.append((artifact_path, error))
845
+ if failures:
846
+ failed_paths = ", ".join(str(path) for path, _error in failures)
847
+ raise OSError(
848
+ f"Could not remove generator-owned temporary files: {failed_paths}"
849
+ ) from failures[0][1]
850
+
851
+
852
+ def _destination_entry_state(database_path):
853
+ """Return a race-sensitive identity for the selected directory entry."""
854
+ try:
855
+ status = database_path.lstat()
856
+ except FileNotFoundError:
857
+ return None
858
+ except OSError as error:
859
+ raise _unsafe_publication_error(database_path) from error
860
+ return (
861
+ status.st_dev,
862
+ status.st_ino,
863
+ status.st_mode,
864
+ status.st_size,
865
+ status.st_mtime_ns,
866
+ status.st_ctime_ns,
867
+ )
868
+
869
+
870
+ def _destination_sidecars(database_path):
871
+ """Return every existing sidecar entry without following symlinks."""
872
+ sidecars = []
873
+ for suffix in _SQLITE_SIDECAR_SUFFIXES:
874
+ sidecar_path = Path(f"{database_path}{suffix}")
875
+ try:
876
+ sidecar_path.lstat()
877
+ except FileNotFoundError:
878
+ continue
879
+ except OSError as error:
880
+ # An inspection failure is not evidence that the entry is absent.
881
+ # Publication must therefore fail closed without touching it.
882
+ raise _unsafe_publication_error(database_path) from error
883
+ sidecars.append(sidecar_path)
884
+ return tuple(sidecars)
885
+
886
+
887
+ def _unsafe_publication_error(database_path, sidecars=()):
888
+ details = ""
889
+ if sidecars:
890
+ details = " Detected: " + ", ".join(path.name for path in sidecars) + "."
891
+ return SpecificationError(
892
+ f"Cannot publish {database_path}: database active or SQLite sidecars "
893
+ "present. Close every application using this database and have the "
894
+ "owning SQLite/QCoDeS application resolve any -wal, -shm, or -journal "
895
+ f"files, then retry or choose another output path.{details} qPlot did "
896
+ "not change the destination database or its SQLite sidecars."
897
+ )
898
+
899
+
900
+ def _ambiguous_sidecar_race_error(database_path, guard_path, sidecars):
901
+ if sidecars:
902
+ details = " Detected after replacement: " + ", ".join(
903
+ path.name for path in sidecars
904
+ ) + "."
905
+ else:
906
+ details = (
907
+ " A post-install safety check failed; a transient sidecar cannot "
908
+ "be ruled out."
909
+ )
910
+ return SpecificationError(
911
+ f"Cannot publish {database_path}: database active or SQLite sidecars "
912
+ f"present.{details} qPlot restored the "
913
+ "previous database main and did not change the sidecars, but cannot "
914
+ "prove which main they belong to. To prevent a stale main/WAL pairing, "
915
+ f"qPlot left the safety guard {guard_path}. Close every application "
916
+ "using the database, have the owning SQLite/QCoDeS application resolve "
917
+ "the sidecars, then remove only that .qplot-publishing guard and retry "
918
+ "or choose another output path."
919
+ )
920
+
921
+
922
+ def _require_safe_destination_sidecars(database_path):
923
+ sidecars = _destination_sidecars(database_path)
924
+ if sidecars:
925
+ raise _unsafe_publication_error(database_path, sidecars)
926
+
927
+
928
+ def _result_chunks(point_count):
929
+ for start in range(0, point_count, _RESULT_CHUNK_POINTS):
930
+ yield slice(start, min(start + _RESULT_CHUNK_POINTS, point_count))
931
+
932
+
933
+ def _timestamped_message(message):
934
+ timestamp = datetime.now().astimezone().isoformat(timespec="seconds")
935
+ print(f"[{timestamp}] {message}", flush=True)
936
+
937
+
938
+ def _random_sinusoid_components(random_generator, dimensions):
939
+ """Return independently randomised sinusoid parameters for one run."""
940
+ components = []
941
+ for _ in range(_SINUSOID_COMPONENT_COUNT):
942
+ amplitude = random_generator.uniform(_MINIMUM_AMPLITUDE, _MAXIMUM_AMPLITUDE)
943
+ frequencies = tuple(
944
+ random_generator.uniform(_MINIMUM_FREQUENCY, _MAXIMUM_FREQUENCY)
945
+ for _ in range(dimensions)
946
+ )
947
+ phase = random_generator.uniform(0.0, 2.0 * np.pi)
948
+ components.append((amplitude, frequencies, phase))
949
+ return tuple(components)
950
+
951
+
952
+ def _sinusoid_sum_1d(normalized_values, components):
953
+ values = np.zeros_like(normalized_values, dtype=float)
954
+ for amplitude, frequencies, phase in components:
955
+ values += amplitude * np.sin(
956
+ 2.0 * np.pi * frequencies[0] * normalized_values + phase
957
+ )
958
+ return values
959
+
960
+
961
+ def _sinusoid_sum_2d_row(v_sd_normalized, v_g_normalized, components):
962
+ values = np.zeros_like(v_g_normalized, dtype=float)
963
+ for amplitude, frequencies, phase in components:
964
+ values += amplitude * np.sin(
965
+ 2.0
966
+ * np.pi
967
+ * (
968
+ frequencies[0] * v_sd_normalized
969
+ + frequencies[1] * v_g_normalized
970
+ )
971
+ + phase
972
+ )
973
+ return values
974
+
975
+
976
+ def _write_run(
977
+ experiment,
978
+ run_number,
979
+ specification,
980
+ random_generator,
981
+ cancelled_callback=None,
982
+ ):
983
+ _raise_if_cancelled(cancelled_callback)
984
+ v_sd = ManualParameter("V_SD", label="Source-drain voltage", unit="V")
985
+ measured = ManualParameter(
986
+ specification.measured_name,
987
+ label=specification.measured_label,
988
+ unit=specification.measured_unit,
989
+ )
990
+ measurement = Measurement(exp=experiment, name=f"run_{run_number}")
991
+ measurement.register_parameter(v_sd)
992
+
993
+ v_sd_values = np.linspace(
994
+ specification.v_sd_start,
995
+ specification.v_sd_stop,
996
+ specification.v_sd_points,
997
+ )
998
+ v_sd_normalized = np.linspace(0.0, 1.0, specification.v_sd_points)
999
+ components = _random_sinusoid_components(
1000
+ random_generator,
1001
+ specification.dimensions,
1002
+ )
1003
+
1004
+ if specification.dimensions == 1:
1005
+ measurement.register_parameter(measured, setpoints=(v_sd,))
1006
+ measured_values = _sinusoid_sum_1d(v_sd_normalized, components)
1007
+ with measurement.run() as datasaver:
1008
+ for result_slice in _result_chunks(specification.v_sd_points):
1009
+ _raise_if_cancelled(cancelled_callback)
1010
+ datasaver.add_result(
1011
+ (v_sd, v_sd_values[result_slice]),
1012
+ (measured, measured_values[result_slice]),
1013
+ )
1014
+ points_written = int(datasaver.points_written)
1015
+ if points_written != specification.point_count:
1016
+ raise RuntimeError(
1017
+ f"run_{run_number} persisted {points_written} of "
1018
+ f"{specification.point_count} expected result rows"
1019
+ )
1020
+ return points_written
1021
+
1022
+ assert specification.v_g_start is not None
1023
+ assert specification.v_g_stop is not None
1024
+ assert specification.v_g_points is not None
1025
+ v_g = ManualParameter("V_G", label="Gate voltage", unit="V")
1026
+ measurement.register_parameter(v_g)
1027
+ measurement.register_parameter(measured, setpoints=(v_sd, v_g))
1028
+ v_g_values = np.linspace(
1029
+ specification.v_g_start,
1030
+ specification.v_g_stop,
1031
+ specification.v_g_points,
1032
+ )
1033
+ v_g_normalized = np.linspace(0.0, 1.0, specification.v_g_points)
1034
+
1035
+ with measurement.run() as datasaver:
1036
+ for v_sd_index, v_sd_value in enumerate(v_sd_values):
1037
+ measured_values = _sinusoid_sum_2d_row(
1038
+ v_sd_normalized[v_sd_index],
1039
+ v_g_normalized,
1040
+ components,
1041
+ )
1042
+ for result_slice in _result_chunks(specification.v_g_points):
1043
+ _raise_if_cancelled(cancelled_callback)
1044
+ result_count = result_slice.stop - result_slice.start
1045
+ datasaver.add_result(
1046
+ (v_sd, np.full(result_count, float(v_sd_value))),
1047
+ (v_g, v_g_values[result_slice]),
1048
+ (measured, measured_values[result_slice]),
1049
+ )
1050
+ points_written = int(datasaver.points_written)
1051
+ if points_written != specification.point_count:
1052
+ raise RuntimeError(
1053
+ f"run_{run_number} persisted {points_written} of "
1054
+ f"{specification.point_count} expected result rows"
1055
+ )
1056
+ return points_written
1057
+
1058
+
1059
+ def _require_rollback_journal_database(database_path):
1060
+ """Reject destinations that cannot be locked without creating sidecars."""
1061
+ try:
1062
+ status = database_path.lstat()
1063
+ with database_path.open("rb") as handle:
1064
+ header = handle.read(20)
1065
+ except OSError as error:
1066
+ raise _unsafe_publication_error(database_path) from error
1067
+ if (
1068
+ not stat.S_ISREG(status.st_mode)
1069
+ or not header.startswith(_SQLITE_HEADER)
1070
+ or header[18:20] != _SQLITE_ROLLBACK_JOURNAL_VERSIONS
1071
+ ):
1072
+ # A persistent WAL-mode main can have no sidecars while quiescent, but
1073
+ # BEGIN EXCLUSIVE would create its WAL/SHM. Rejecting it is the only
1074
+ # source-preserving choice; the user can choose a new output path.
1075
+ raise _unsafe_publication_error(database_path)
1076
+
1077
+
1078
+ def _open_quiescent_destination(
1079
+ database_path,
1080
+ expected_destination_state,
1081
+ lock_path,
1082
+ ):
1083
+ """Hold SQLite's own exclusive inode lock through a private alias."""
1084
+ _require_safe_destination_sidecars(database_path)
1085
+ if _destination_entry_state(database_path) != expected_destination_state:
1086
+ raise _unsafe_publication_error(database_path)
1087
+ _require_rollback_journal_database(lock_path)
1088
+ connection = None
1089
+ try:
1090
+ connection = sqlite3.connect(
1091
+ f"file:{_qcodes_uri_name(lock_path)}?mode=rw",
1092
+ uri=True,
1093
+ isolation_level=None,
1094
+ timeout=0,
1095
+ )
1096
+ # The alias is a hardlink to the destination inode, so SQLite's
1097
+ # VFS-specific EXCLUSIVE lock conflicts with real readers/writers. Its
1098
+ # recovery and journal lookup, however, uses the private alias name.
1099
+ # A destination -journal racing into this call is therefore never
1100
+ # opened, recovered, modified, or deleted by qPlot; the recheck below
1101
+ # observes it and rejects publication.
1102
+ connection.execute("BEGIN EXCLUSIVE")
1103
+ except sqlite3.Error as error:
1104
+ if connection is not None:
1105
+ connection.close()
1106
+ raise _unsafe_publication_error(database_path) from error
1107
+
1108
+ try:
1109
+ _require_safe_destination_sidecars(database_path)
1110
+ if _destination_entry_state(database_path) != expected_destination_state:
1111
+ raise _unsafe_publication_error(database_path)
1112
+ except Exception:
1113
+ connection.close()
1114
+ raise
1115
+ return connection
1116
+
1117
+
1118
+ def _close_publication_connection(connection):
1119
+ if connection is None:
1120
+ return
1121
+ try:
1122
+ connection.rollback()
1123
+ except sqlite3.Error:
1124
+ pass
1125
+ try:
1126
+ connection.close()
1127
+ except sqlite3.Error:
1128
+ pass
1129
+
1130
+
1131
+ def _create_publication_guard(database_path):
1132
+ """Create the qPlot-visible guard for the short replacement transaction."""
1133
+ guard_path = database_publication_guard_path(database_path)
1134
+ flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
1135
+ flags |= getattr(os, "O_CLOEXEC", 0)
1136
+ try:
1137
+ descriptor = os.open(guard_path, flags, 0o600)
1138
+ except OSError as error:
1139
+ raise _unsafe_publication_error(database_path) from error
1140
+ try:
1141
+ os.close(descriptor)
1142
+ except OSError as error:
1143
+ # Ownership starts at os.open, not when this helper returns. Otherwise
1144
+ # a reported close failure could strand an untracked guard and block
1145
+ # every later read/retry.
1146
+ try:
1147
+ guard_path.unlink()
1148
+ except OSError:
1149
+ pass
1150
+ raise _unsafe_publication_error(database_path) from error
1151
+ return guard_path
1152
+
1153
+
1154
+ def _create_database_backup_link(database_path):
1155
+ """Keep the old main inode available for a sidecar-race rollback."""
1156
+ handle = tempfile.NamedTemporaryFile(
1157
+ prefix=_TEMPORARY_DATABASE_PREFIX,
1158
+ suffix=".backup",
1159
+ dir=database_path.parent,
1160
+ delete=False,
1161
+ )
1162
+ backup_path = Path(handle.name)
1163
+ handle.close()
1164
+ backup_path.unlink()
1165
+ try:
1166
+ # This private alias lets SQLite lock the old inode without consulting
1167
+ # destination-named journals, and lets the publisher atomically restore
1168
+ # that exact inode if a post-install safety check fails.
1169
+ os.link(database_path, backup_path)
1170
+ except OSError as error:
1171
+ backup_path.unlink(missing_ok=True)
1172
+ raise _unsafe_publication_error(database_path) from error
1173
+ return backup_path
1174
+
1175
+
1176
+ def _restore_database_backup(backup_path, database_path):
1177
+ """Atomically restore the old main without touching any sidecar."""
1178
+ try:
1179
+ os.replace(backup_path, database_path)
1180
+ except OSError as error:
1181
+ guard_path = database_publication_guard_path(database_path)
1182
+ raise OSError(
1183
+ f"Cannot publish {database_path}: database active or SQLite "
1184
+ "sidecars present. qPlot could not restore the previous database "
1185
+ "main because the replacement path is still active. The selected "
1186
+ f"path may contain the replacement; the prior main is retained at "
1187
+ f"{backup_path} unless qPlot's final retry restored it. The safety "
1188
+ f"guard {guard_path} remains. Close every application using the "
1189
+ "database and have the owning SQLite/QCoDeS application resolve "
1190
+ "all -wal, -shm, and -journal files before recovering the prior "
1191
+ "main or removing only that guard. qPlot did not modify or remove "
1192
+ "the SQLite sidecars."
1193
+ ) from error
1194
+
1195
+
1196
+ def _replace_database_file(
1197
+ temporary_path,
1198
+ database_path,
1199
+ expected_destination_state,
1200
+ ):
1201
+ """Replace a proven-quiescent main and roll back any sidecar race."""
1202
+ guard_path = _create_publication_guard(database_path)
1203
+ backup_path = None
1204
+ connection = None
1205
+ replacement_installed = False
1206
+ guard_must_remain = False
1207
+ ambiguous_sidecars = ()
1208
+ try:
1209
+ _require_safe_destination_sidecars(database_path)
1210
+ if _destination_entry_state(database_path) != expected_destination_state:
1211
+ raise _unsafe_publication_error(database_path)
1212
+ _require_rollback_journal_database(database_path)
1213
+ backup_path = _create_database_backup_link(database_path)
1214
+ backup_state = _destination_entry_state(backup_path)
1215
+ locked_destination_state = _destination_entry_state(database_path)
1216
+ if (
1217
+ backup_state is None
1218
+ or locked_destination_state is None
1219
+ or backup_state[:5] != expected_destination_state[:5]
1220
+ or locked_destination_state != backup_state
1221
+ or not os.path.samefile(database_path, backup_path)
1222
+ ):
1223
+ raise _unsafe_publication_error(database_path)
1224
+
1225
+ connection = _open_quiescent_destination(
1226
+ database_path,
1227
+ locked_destination_state,
1228
+ backup_path,
1229
+ )
1230
+ # Windows does not permit replacing the main while this connection is
1231
+ # open. Closing it is safe there because any competing SQLite handle
1232
+ # likewise prevents os.replace. POSIX keeps the VFS lock across replace.
1233
+ if os.name == "nt":
1234
+ _close_publication_connection(connection)
1235
+ connection = None
1236
+
1237
+ _require_safe_destination_sidecars(database_path)
1238
+ if not os.path.samefile(database_path, backup_path):
1239
+ raise _unsafe_publication_error(database_path)
1240
+
1241
+ try:
1242
+ os.replace(temporary_path, database_path)
1243
+ except OSError as error:
1244
+ if error.errno in _REPLACEMENT_BUSY_ERRNOS:
1245
+ raise _unsafe_publication_error(database_path) from error
1246
+ raise
1247
+ replacement_installed = True
1248
+
1249
+ try:
1250
+ # This postcondition is essential: a non-cooperating filesystem
1251
+ # actor can create a sidecar in the final check/replace syscall gap.
1252
+ # qPlot readers see the guard throughout this interval. If a
1253
+ # sidecar appeared, restore the byte-identical old inode and leave
1254
+ # that externally owned sidecar exactly as it was created.
1255
+ ambiguous_sidecars = _destination_sidecars(database_path)
1256
+ if ambiguous_sidecars:
1257
+ raise _unsafe_publication_error(
1258
+ database_path,
1259
+ ambiguous_sidecars,
1260
+ )
1261
+ backup_after_replace = _destination_entry_state(backup_path)
1262
+ if (
1263
+ backup_after_replace is None
1264
+ or backup_after_replace[:5] != backup_state[:5]
1265
+ ):
1266
+ raise _unsafe_publication_error(database_path)
1267
+ ambiguous_sidecars = _destination_sidecars(database_path)
1268
+ if ambiguous_sidecars:
1269
+ raise _unsafe_publication_error(
1270
+ database_path,
1271
+ ambiguous_sidecars,
1272
+ )
1273
+ except Exception as error:
1274
+ # Once a post-install postcondition fails, absence observed later
1275
+ # cannot prove that a transient sidecar did not belong to the new
1276
+ # main. Keep the guard even if rollback itself needs a retry.
1277
+ guard_must_remain = True
1278
+ _restore_database_backup(backup_path, database_path)
1279
+ backup_path = None
1280
+ replacement_installed = False
1281
+ if not ambiguous_sidecars:
1282
+ try:
1283
+ ambiguous_sidecars = _destination_sidecars(database_path)
1284
+ except SpecificationError as inspection_error:
1285
+ # Inspection itself is unsafe, so retain the guard exactly
1286
+ # as for an observed sidecar. A fresh process must not guess.
1287
+ guard_must_remain = True
1288
+ raise _ambiguous_sidecar_race_error(
1289
+ database_path,
1290
+ guard_path,
1291
+ (),
1292
+ ) from inspection_error
1293
+ # A sidecar observed after the swap could belong to either the old
1294
+ # or new main. Even if none is visible now, a failed postcondition
1295
+ # cannot rule out a transient one. The guard is therefore
1296
+ # persistent recovery state, not a disposable generator temporary.
1297
+ guard_must_remain = True
1298
+ raise _ambiguous_sidecar_race_error(
1299
+ database_path,
1300
+ guard_path,
1301
+ ambiguous_sidecars,
1302
+ ) from error
1303
+
1304
+ # Keep SQLite's old-inode EXCLUSIVE lock through commit on POSIX. A
1305
+ # cooperating old SQLite connection cannot create a stale sidecar in
1306
+ # the check/unlink gap; on Windows, a handle overlapping os.replace
1307
+ # makes that syscall fail. No portable filesystem syscall atomically
1308
+ # conditions a main-file replace on three sibling paths, so the main's
1309
+ # embedded replacement marker is the cross-process backstop: qPlot
1310
+ # permanently quarantines WAL access for this identity even if a raw
1311
+ # filesystem actor bypasses SQLite's lock after the last check.
1312
+ try:
1313
+ guard_path.unlink()
1314
+ except OSError as error:
1315
+ guard_must_remain = True
1316
+ _restore_database_backup(backup_path, database_path)
1317
+ backup_path = None
1318
+ replacement_installed = False
1319
+ try:
1320
+ ambiguous_sidecars = _destination_sidecars(database_path)
1321
+ except SpecificationError as inspection_error:
1322
+ guard_must_remain = True
1323
+ raise _ambiguous_sidecar_race_error(
1324
+ database_path,
1325
+ guard_path,
1326
+ (),
1327
+ ) from inspection_error
1328
+ raise _ambiguous_sidecar_race_error(
1329
+ database_path,
1330
+ guard_path,
1331
+ ambiguous_sidecars,
1332
+ ) from error
1333
+ guard_path = None
1334
+ _close_publication_connection(connection)
1335
+ connection = None
1336
+ replacement_installed = False
1337
+
1338
+ # Publication is now committed. Failure to remove this private name
1339
+ # must not turn a completed replacement into a reported rejection.
1340
+ try:
1341
+ backup_path.unlink()
1342
+ except OSError:
1343
+ pass
1344
+ else:
1345
+ backup_path = None
1346
+ finally:
1347
+ _close_publication_connection(connection)
1348
+ if replacement_installed and backup_path is not None:
1349
+ guard_must_remain = True
1350
+ try:
1351
+ _restore_database_backup(backup_path, database_path)
1352
+ except OSError:
1353
+ # Keep both the backup and publication guard for manual recovery.
1354
+ guard_must_remain = True
1355
+ else:
1356
+ backup_path = None
1357
+ replacement_installed = False
1358
+ try:
1359
+ guard_must_remain = (
1360
+ guard_must_remain
1361
+ or bool(_destination_sidecars(database_path))
1362
+ )
1363
+ except SpecificationError:
1364
+ guard_must_remain = True
1365
+ if backup_path is not None and not replacement_installed:
1366
+ try:
1367
+ backup_path.unlink()
1368
+ except OSError:
1369
+ pass
1370
+ if (
1371
+ guard_path is not None
1372
+ and not guard_must_remain
1373
+ and not replacement_installed
1374
+ ):
1375
+ try:
1376
+ guard_path.unlink()
1377
+ except OSError:
1378
+ pass
1379
+
1380
+
1381
+ def _link_database_file(temporary_path, database_path):
1382
+ """Publish a previously absent output with a guarded postcondition."""
1383
+ guard_path = _create_publication_guard(database_path)
1384
+ destination_linked = False
1385
+ try:
1386
+ _require_safe_destination_sidecars(database_path)
1387
+ if _destination_entry_state(database_path) is not None:
1388
+ raise SpecificationError(
1389
+ f"{database_path} already exists; use --overwrite to replace it"
1390
+ )
1391
+ try:
1392
+ # The hard link is an atomic no-clobber publish because the
1393
+ # generator temporary lives in the destination directory.
1394
+ os.link(temporary_path, database_path)
1395
+ except FileExistsError as error:
1396
+ raise SpecificationError(
1397
+ f"{database_path} already exists; use --overwrite to replace it"
1398
+ ) from error
1399
+ except OSError as error:
1400
+ raise OSError(
1401
+ "Could not atomically publish the generated database without "
1402
+ "overwriting an existing file"
1403
+ ) from error
1404
+ destination_linked = True
1405
+
1406
+ # An orphan sidecar can be copied into place in the link syscall gap.
1407
+ # Since the old state was absence, rolling back only our own hard link
1408
+ # restores it without touching the externally owned sidecar.
1409
+ _require_safe_destination_sidecars(database_path)
1410
+ guard_path.unlink()
1411
+ guard_path = None
1412
+ destination_linked = False
1413
+ finally:
1414
+ if destination_linked:
1415
+ try:
1416
+ owns_destination = os.path.samefile(
1417
+ temporary_path,
1418
+ database_path,
1419
+ )
1420
+ except OSError:
1421
+ owns_destination = False
1422
+ if owns_destination:
1423
+ try:
1424
+ database_path.unlink()
1425
+ except OSError as error:
1426
+ # Keep the guard: qPlot must not open a main whose rollback
1427
+ # could not be proved after a sidecar publication race.
1428
+ raise OSError(
1429
+ "Could not roll back an unsafe new database publication; "
1430
+ "the qPlot publication guard was left in place"
1431
+ ) from error
1432
+ destination_linked = False
1433
+ if guard_path is not None and not destination_linked:
1434
+ try:
1435
+ guard_path.unlink()
1436
+ except OSError:
1437
+ pass
1438
+
1439
+
1440
+ def _publish_database(
1441
+ temporary_path,
1442
+ database_path,
1443
+ overwrite,
1444
+ expected_destination_state=None,
1445
+ publication_callback=None,
1446
+ ):
1447
+ """Publish atomically and notify once the destination is committed."""
1448
+ _require_safe_destination_sidecars(database_path)
1449
+ if overwrite:
1450
+ # A changed directory entry means the user's overwrite decision no
1451
+ # longer describes the file now at this path. Reject rather than
1452
+ # replacing an unexamined concurrent owner. The replacement helper then
1453
+ # obtains SQLite's own exclusive lock and enforces a rollback-capable
1454
+ # postcondition across the final filesystem operation.
1455
+ if _destination_entry_state(database_path) != expected_destination_state:
1456
+ raise _unsafe_publication_error(database_path)
1457
+ if expected_destination_state is not None:
1458
+ _replace_database_file(
1459
+ temporary_path,
1460
+ database_path,
1461
+ expected_destination_state,
1462
+ )
1463
+ if publication_callback is not None:
1464
+ publication_callback()
1465
+ return
1466
+
1467
+ _link_database_file(temporary_path, database_path)
1468
+ if publication_callback is not None:
1469
+ publication_callback()
1470
+
1471
+
1472
+ def generate_database(
1473
+ specifications,
1474
+ database_path,
1475
+ overwrite=False,
1476
+ rng=None,
1477
+ cancelled_callback=None,
1478
+ publication_callback=None,
1479
+ ):
1480
+ """Generate a database and optionally observe its publication boundary."""
1481
+ specifications = list(specifications)
1482
+ if not specifications:
1483
+ raise SpecificationError("At least one run specification is required")
1484
+
1485
+ database_path = Path(database_path)
1486
+ if database_path.suffix.lower() != ".db":
1487
+ raise SpecificationError("Database output path must end in .db")
1488
+ expected_destination_state = _destination_entry_state(database_path)
1489
+ if expected_destination_state is not None and not overwrite:
1490
+ raise SpecificationError(
1491
+ f"{database_path} already exists; use --overwrite to replace it"
1492
+ )
1493
+ _require_safe_destination_sidecars(database_path)
1494
+
1495
+ database_path.parent.mkdir(parents=True, exist_ok=True)
1496
+ random_generator = rng if rng is not None else np.random.default_rng()
1497
+ total_runs = len(specifications)
1498
+ total_points = sum(specification.point_count for specification in specifications)
1499
+ generation_started = perf_counter()
1500
+ _timestamped_message(
1501
+ f"Test database generation started: {database_path} "
1502
+ f"({total_runs} runs, {total_points} points)."
1503
+ )
1504
+ temporary_path = None
1505
+ publication_completed = False
1506
+ try:
1507
+ try:
1508
+ temporary_handle = tempfile.NamedTemporaryFile(
1509
+ prefix=_TEMPORARY_DATABASE_PREFIX,
1510
+ suffix=".db",
1511
+ dir=database_path.parent,
1512
+ delete=False,
1513
+ )
1514
+ temporary_path = Path(temporary_handle.name)
1515
+ temporary_handle.close()
1516
+ _raise_if_cancelled(cancelled_callback)
1517
+ connection = _connect_writable_exact_path(temporary_path)
1518
+ try:
1519
+ experiment = new_experiment(
1520
+ "qplot_test_database",
1521
+ sample_name="synthetic",
1522
+ conn=connection,
1523
+ )
1524
+ for run_number, specification in enumerate(specifications, start=1):
1525
+ run_started = perf_counter()
1526
+ _timestamped_message(
1527
+ f"Run started: run_{run_number} ({run_number}/{total_runs}, "
1528
+ f"{specification.dimensions}D, "
1529
+ f"{specification.point_count} points)."
1530
+ )
1531
+ try:
1532
+ points_written = _write_run(
1533
+ experiment,
1534
+ run_number,
1535
+ specification,
1536
+ random_generator,
1537
+ cancelled_callback=cancelled_callback,
1538
+ )
1539
+ if points_written != specification.point_count:
1540
+ raise RuntimeError(
1541
+ f"run_{run_number} persisted {points_written} of "
1542
+ f"{specification.point_count} expected result rows"
1543
+ )
1544
+ except GenerationCancelled:
1545
+ _timestamped_message(
1546
+ f"Run stopped (cancelled): run_{run_number} "
1547
+ f"after {perf_counter() - run_started:.2f} s."
1548
+ )
1549
+ raise
1550
+ except Exception as error:
1551
+ _timestamped_message(
1552
+ f"Run stopped (failed): run_{run_number} after "
1553
+ f"{perf_counter() - run_started:.2f} s "
1554
+ f"({type(error).__name__}: {error})."
1555
+ )
1556
+ raise
1557
+ _timestamped_message(
1558
+ f"Run stopped (completed): run_{run_number} in "
1559
+ f"{perf_counter() - run_started:.2f} s."
1560
+ )
1561
+ # The marker identifies the provenance format; the unique token
1562
+ # and nonce-linked branch history let a fresh qPlot process
1563
+ # distinguish a WAL descending from this exact main from an
1564
+ # unrelated or divergent sidecar. Triggers are installed only
1565
+ # after generation so they do not penalise bulk creation.
1566
+ connection.execute(
1567
+ "PRAGMA application_id = "
1568
+ f"{QPLOT_GENERATED_DATABASE_APPLICATION_ID}"
1569
+ )
1570
+ _install_generation_provenance(connection)
1571
+ connection.commit()
1572
+ _raise_if_cancelled(cancelled_callback)
1573
+ finally:
1574
+ connection.close()
1575
+ # Sidecar cleanup is fallible, so complete it before the atomic
1576
+ # publication boundary. No cleanup failure may be reported as a
1577
+ # rejected publication after the destination has already changed.
1578
+ _remove_owned_temporary_artifacts(
1579
+ temporary_path,
1580
+ include_database=False,
1581
+ )
1582
+ if publication_callback is None:
1583
+ # Preserve the established four-argument call for test doubles
1584
+ # and internal callers that do not need publication tracking.
1585
+ _publish_database(
1586
+ temporary_path,
1587
+ database_path,
1588
+ overwrite,
1589
+ expected_destination_state,
1590
+ )
1591
+ else:
1592
+ _publish_database(
1593
+ temporary_path,
1594
+ database_path,
1595
+ overwrite,
1596
+ expected_destination_state,
1597
+ publication_callback=publication_callback,
1598
+ )
1599
+ publication_completed = True
1600
+ finally:
1601
+ if temporary_path is not None:
1602
+ try:
1603
+ _remove_owned_temporary_artifacts(temporary_path)
1604
+ except Exception:
1605
+ if not publication_completed:
1606
+ raise
1607
+ # Publication is committed and must still be reported as
1608
+ # success, but retry once so a transient unlink failure
1609
+ # does not strand a generator-owned hardlink/sidecar.
1610
+ try:
1611
+ _remove_owned_temporary_artifacts(temporary_path)
1612
+ except Exception:
1613
+ pass
1614
+ except GenerationCancelled:
1615
+ _timestamped_message(
1616
+ "Test database generation stopped (cancelled) after "
1617
+ f"{perf_counter() - generation_started:.2f} s: {database_path}."
1618
+ )
1619
+ raise
1620
+ except Exception as error:
1621
+ _timestamped_message(
1622
+ "Test database generation stopped (failed) after "
1623
+ f"{perf_counter() - generation_started:.2f} s: {database_path} "
1624
+ f"({type(error).__name__}: {error})."
1625
+ )
1626
+ raise
1627
+
1628
+ try:
1629
+ _timestamped_message(
1630
+ "Test database generation stopped (completed) in "
1631
+ f"{perf_counter() - generation_started:.2f} s: {database_path}."
1632
+ )
1633
+ except Exception:
1634
+ # Publication is already committed; a closed output stream must not
1635
+ # turn success into an apparent failure after replacing the database.
1636
+ pass
1637
+ return database_path
1638
+
1639
+
1640
+ def generate_database_from_csv(
1641
+ csv_path,
1642
+ database_path,
1643
+ overwrite=False,
1644
+ rng=None,
1645
+ cancelled_callback=None,
1646
+ ):
1647
+ """Validate a CSV and generate its QCoDeS database."""
1648
+ specifications = read_specifications(csv_path)
1649
+ path = generate_database(
1650
+ specifications,
1651
+ database_path,
1652
+ overwrite=overwrite,
1653
+ rng=rng,
1654
+ cancelled_callback=cancelled_callback,
1655
+ )
1656
+ return path, specifications
1657
+
1658
+
1659
+ def _argument_parser():
1660
+ parser = argparse.ArgumentParser(
1661
+ prog="qplot-generate-db",
1662
+ description="Generate two-sinusoid test runs in a QCoDeS database.",
1663
+ )
1664
+ parser.add_argument("specification", nargs="?", help="input CSV specification")
1665
+ parser.add_argument("database", nargs="?", help="output QCoDeS .db file")
1666
+ output_group = parser.add_mutually_exclusive_group()
1667
+ output_group.add_argument(
1668
+ "--write-example",
1669
+ metavar="CSV",
1670
+ help="write a ready-to-use example CSV instead of generating a database",
1671
+ )
1672
+ output_group.add_argument(
1673
+ "--write-collection",
1674
+ metavar="DIRECTORY",
1675
+ help="copy the installed cumulative instruction CSV collection",
1676
+ )
1677
+ parser.add_argument(
1678
+ "--overwrite",
1679
+ action="store_true",
1680
+ help="replace an existing output file",
1681
+ )
1682
+ return parser
1683
+
1684
+
1685
+ def main(argv: Sequence[str] | None = None):
1686
+ """Run the ``qplot-generate-db`` command-line interface."""
1687
+ parser = _argument_parser()
1688
+ args = parser.parse_args(argv)
1689
+
1690
+ try:
1691
+ if args.write_example:
1692
+ if args.specification or args.database:
1693
+ parser.error("positional arguments cannot be used with --write-example")
1694
+ output_path = write_example_csv(args.write_example, overwrite=args.overwrite)
1695
+ print(f"Wrote example CSV: {output_path}")
1696
+ return 0
1697
+
1698
+ if args.write_collection:
1699
+ if args.specification or args.database:
1700
+ parser.error("positional arguments cannot be used with --write-collection")
1701
+ output_paths = copy_instruction_collection(
1702
+ args.write_collection,
1703
+ overwrite=args.overwrite,
1704
+ )
1705
+ print(
1706
+ f"Wrote {len(output_paths)} instruction CSV files to "
1707
+ f"{Path(args.write_collection)}"
1708
+ )
1709
+ return 0
1710
+
1711
+ if not args.specification or not args.database:
1712
+ parser.error(
1713
+ "specification and database are required unless --write-example or "
1714
+ "--write-collection is used"
1715
+ )
1716
+ output_path, specifications = generate_database_from_csv(
1717
+ args.specification,
1718
+ args.database,
1719
+ overwrite=args.overwrite,
1720
+ )
1721
+ except (OSError, SpecificationError) as error:
1722
+ parser.exit(2, f"qplot-generate-db: error: {error}\n")
1723
+
1724
+ total_points = sum(specification.point_count for specification in specifications)
1725
+ _timestamped_message(
1726
+ f"Generated {output_path} with {len(specifications)} runs "
1727
+ f"and {total_points} points."
1728
+ )
1729
+ return 0
1730
+
1731
+
1732
+ if __name__ == "__main__":
1733
+ raise SystemExit(main())