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,2442 @@
1
+ import json
2
+ import math
3
+ import os
4
+ import sqlite3
5
+ import stat
6
+ from itertools import islice
7
+ from time import monotonic
8
+ from typing import Literal, NamedTuple
9
+
10
+ from qcodes.dataset.sqlite.database import get_DB_location
11
+
12
+ from qplot.datahandling.readonly import (
13
+ qcodes_read_only_connection,
14
+ sqlite_read_only_connection,
15
+ )
16
+ from qplot.datahandling.source_activity import source_activity_timestamp
17
+ from qplot.datahandling.trusted_presentation import (
18
+ TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES,
19
+ TRUSTED_PRESENTATION_MAX_PARAMETER_TEXT_BYTES,
20
+ TRUSTED_PRESENTATION_MAX_PARAMETERS,
21
+ bounded_presentation_text,
22
+ )
23
+
24
+ MAX_SELECTED_RUN_SETPOINT_SUMMARY_ROWS = 100_000
25
+ MAX_SELECTED_RUN_SETPOINT_SUMMARY_SCALAR_BYTES = 64 * 1024
26
+ MAX_SELECTED_RUN_SETPOINT_SUMMARY_SOURCE_BYTES = 8 * 1024 * 1024
27
+ MAX_SNAPSHOT_SELECTED_RUN_COLUMNS = 256
28
+ MAX_SNAPSHOT_SELECTED_RUN_SCALAR_BYTES = 1024 * 1024
29
+ MAX_SNAPSHOT_SELECTED_RUN_TOTAL_BYTES = 4 * 1024 * 1024
30
+ MAX_SNAPSHOT_SELECTED_OBSERVATION_BYTES = 64 * 1024
31
+ MAX_SNAPSHOT_SELECTED_LAYOUT_ROWS = 4_096
32
+ MAX_SNAPSHOT_SELECTED_PARAMETERS = 256
33
+ MAX_SNAPSHOT_SELECTED_SETPOINTS = 32
34
+ MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS = 32
35
+ _SNAPSHOT_SELECTED_LAYOUT_TEXT_BYTES = 512
36
+ _SNAPSHOT_STANDARD_RUN_COLUMNS = frozenset(
37
+ {
38
+ "run_id",
39
+ "exp_id",
40
+ "name",
41
+ "result_table_name",
42
+ "result_counter",
43
+ "run_timestamp",
44
+ "completed_timestamp",
45
+ "is_completed",
46
+ "parameters",
47
+ "guid",
48
+ "run_description",
49
+ "snapshot",
50
+ "parent_datasets",
51
+ "captured_run_id",
52
+ "captured_counter",
53
+ }
54
+ )
55
+ _SNAPSHOT_OBSERVATION_FIELDS = frozenset(
56
+ {
57
+ "database_modified_timestamp",
58
+ "expected_results",
59
+ "expected_results_source",
60
+ "measure_parameters",
61
+ "measurement_exception",
62
+ "point_shape",
63
+ "read_setpoint_count",
64
+ "result_count",
65
+ "setpoint_count",
66
+ "setpoint_count_source",
67
+ "setpoint_shape",
68
+ "setpoint_shape_source",
69
+ "storage_bytes",
70
+ "storage_bytes_estimated",
71
+ "sweep_parameters",
72
+ }
73
+ )
74
+
75
+
76
+ class _StorageSize(NamedTuple):
77
+ bytes: int | None
78
+ accuracy: Literal["exact", "estimated", "unavailable"]
79
+
80
+
81
+ def _raise_if_read_aborted(cancelled_callback=None, deadline=None):
82
+ if cancelled_callback is not None and cancelled_callback():
83
+ raise InterruptedError("Database read cancelled.")
84
+ if deadline is not None and monotonic() >= deadline:
85
+ raise TimeoutError("Database read deadline exceeded.")
86
+
87
+
88
+ def _install_cancel_progress_handler(
89
+ conn,
90
+ cancelled_callback,
91
+ deadline=None,
92
+ ):
93
+ if cancelled_callback is None and deadline is None:
94
+ return
95
+ _raise_if_read_aborted(cancelled_callback, deadline)
96
+ set_progress_handler = getattr(conn, "set_progress_handler", None)
97
+ if callable(set_progress_handler):
98
+ set_progress_handler(
99
+ lambda: int(
100
+ bool(cancelled_callback is not None and cancelled_callback())
101
+ or bool(deadline is not None and monotonic() >= deadline)
102
+ ),
103
+ 1000,
104
+ )
105
+
106
+
107
+ def _translate_interrupted_read(error, cancelled_callback=None, deadline=None):
108
+ if _sql_was_interrupted(error):
109
+ _raise_if_read_aborted(cancelled_callback, deadline)
110
+
111
+
112
+ def _notify_connection(connection_callback, connection):
113
+ if connection_callback is not None:
114
+ connection_callback(connection)
115
+
116
+
117
+ def _read_only_connection(
118
+ database_path,
119
+ expected_database_identity,
120
+ cancelled_callback=None,
121
+ deadline=None,
122
+ ):
123
+ """Open a QCoDeS view while retaining optional abort controls."""
124
+ kwargs = {}
125
+ if expected_database_identity is not None:
126
+ kwargs["expected_database_identity"] = expected_database_identity
127
+ if cancelled_callback is not None:
128
+ kwargs["cancelled_callback"] = cancelled_callback
129
+ if deadline is not None:
130
+ kwargs["deadline"] = deadline
131
+ return qcodes_read_only_connection(database_path, **kwargs)
132
+
133
+
134
+ def get_selected_run_setpoint_summaries(
135
+ database_path,
136
+ result_table_name,
137
+ setpoint_names,
138
+ result_count,
139
+ *,
140
+ setpoint_shape=None,
141
+ expected_database_identity=None,
142
+ cancelled_callback=None,
143
+ connection_callback=None,
144
+ deadline=None,
145
+ ):
146
+ """Return bounded, plain summaries for snapshot-mode run details.
147
+
148
+ Result-table grouping is deliberately limited to runs whose already-known
149
+ row count is at most ``MAX_SELECTED_RUN_SETPOINT_SUMMARY_ROWS``. Unknown
150
+ and larger runs retain any cheap shape-derived step counts without opening
151
+ SQLite. This function is part of the data layer so Qt widgets only render
152
+ the mapping supplied by their controller.
153
+ """
154
+ names = tuple(
155
+ dict.fromkeys(str(name) for name in setpoint_names or () if str(name))
156
+ )
157
+ summaries = {}
158
+ try:
159
+ bounded_result_count = int(result_count) if result_count is not None else None
160
+ except (TypeError, ValueError, OverflowError):
161
+ bounded_result_count = None
162
+
163
+ if (
164
+ database_path
165
+ and result_table_name
166
+ and names
167
+ and bounded_result_count is not None
168
+ and 0 <= bounded_result_count <= MAX_SELECTED_RUN_SETPOINT_SUMMARY_ROWS
169
+ ):
170
+ summaries.update(
171
+ _read_selected_run_setpoint_summaries(
172
+ database_path,
173
+ result_table_name,
174
+ names,
175
+ expected_database_identity=expected_database_identity,
176
+ cancelled_callback=cancelled_callback,
177
+ connection_callback=connection_callback,
178
+ deadline=deadline,
179
+ )
180
+ )
181
+
182
+ for name, steps in zip(names, setpoint_shape or (), strict=False):
183
+ if steps is None or steps == "":
184
+ continue
185
+ summaries.setdefault(name, {}).setdefault("steps", steps)
186
+ return {name: dict(summary) for name, summary in summaries.items()}
187
+
188
+
189
+ def _read_selected_run_setpoint_summaries(
190
+ database_path,
191
+ result_table_name,
192
+ setpoint_names,
193
+ *,
194
+ expected_database_identity=None,
195
+ cancelled_callback=None,
196
+ connection_callback=None,
197
+ deadline=None,
198
+ ):
199
+ """Read one bounded snapshot summary batch, degrading to empty on error."""
200
+ conn = None
201
+ cursor = None
202
+ try:
203
+ conn = sqlite_read_only_connection(
204
+ database_path,
205
+ timeout=2,
206
+ expected_database_identity=expected_database_identity,
207
+ cancelled_callback=cancelled_callback,
208
+ deadline=deadline,
209
+ )
210
+ _notify_connection(connection_callback, conn)
211
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
212
+ cursor = conn.cursor()
213
+ summaries = _selected_run_setpoint_summaries_from_cursor(
214
+ cursor,
215
+ result_table_name,
216
+ setpoint_names,
217
+ cancelled_callback=cancelled_callback,
218
+ deadline=deadline,
219
+ )
220
+ _raise_if_read_aborted(cancelled_callback, deadline)
221
+ return summaries
222
+ except (InterruptedError, TimeoutError):
223
+ raise
224
+ except sqlite3.OperationalError as error:
225
+ _translate_interrupted_read(error, cancelled_callback, deadline)
226
+ return {}
227
+ except Exception:
228
+ return {}
229
+ finally:
230
+ try:
231
+ _notify_connection(connection_callback, None)
232
+ finally:
233
+ if cursor is not None:
234
+ cursor.close()
235
+ if conn is not None:
236
+ conn.close()
237
+
238
+
239
+ def _selected_run_setpoint_summaries_from_cursor(
240
+ cursor,
241
+ result_table_name,
242
+ setpoint_names,
243
+ *,
244
+ cancelled_callback=None,
245
+ deadline=None,
246
+ ):
247
+ """Return bounded summaries using an already guarded snapshot connection."""
248
+ quoted_table = _sqlite_identifier(result_table_name)
249
+ _raise_if_read_aborted(cancelled_callback, deadline)
250
+ if not _selected_summary_source_within_byte_limit(cursor):
251
+ return {}
252
+ _raise_if_read_aborted(cancelled_callback, deadline)
253
+ high_watermark = _result_table_integer_pk_high_watermark(
254
+ cursor,
255
+ quoted_table,
256
+ )
257
+ if (
258
+ high_watermark is None
259
+ or high_watermark > MAX_SELECTED_RUN_SETPOINT_SUMMARY_ROWS
260
+ ):
261
+ return {}
262
+ columns = _result_table_column_types(cursor, quoted_table)
263
+ summaries = {}
264
+ for name in setpoint_names:
265
+ _raise_if_read_aborted(cancelled_callback, deadline)
266
+ if name not in columns or columns[name] == "array":
267
+ continue
268
+ summary = _selected_run_setpoint_summary(cursor, quoted_table, name)
269
+ if summary:
270
+ summaries[name] = summary
271
+ return summaries
272
+
273
+
274
+ def _selected_summary_source_within_byte_limit(cursor):
275
+ """Fail closed unless the whole private SQLite view is at most 8 MiB.
276
+
277
+ A small row count does not bound the bytes sorted by ``GROUP BY``: one
278
+ setpoint can be a multi-gigabyte BLOB, and a sparse database can have a
279
+ tiny logical page count but a huge file length. Check both SQLite's main
280
+ page extent and the exact private main/sidecar file lengths before any
281
+ result-table aggregate. Snapshot-mode connections own stable private
282
+ files, so these preflights remain valid for the following statement.
283
+ """
284
+ limit = MAX_SELECTED_RUN_SETPOINT_SUMMARY_SOURCE_BYTES
285
+ try:
286
+ cursor.execute("PRAGMA main.page_size")
287
+ page_size_row = cursor.fetchone()
288
+ cursor.execute("PRAGMA main.page_count")
289
+ page_count_row = cursor.fetchone()
290
+ if (
291
+ page_size_row is None
292
+ or page_count_row is None
293
+ or len(page_size_row) != 1
294
+ or len(page_count_row) != 1
295
+ ):
296
+ return False
297
+ page_size = page_size_row[0]
298
+ page_count = page_count_row[0]
299
+ if (
300
+ type(page_size) is not int
301
+ or type(page_count) is not int
302
+ or page_size <= 0
303
+ or page_count < 0
304
+ or page_count > limit // page_size
305
+ ):
306
+ return False
307
+
308
+ cursor.execute("PRAGMA database_list")
309
+ main_paths = [
310
+ str(row[2])
311
+ for row in cursor.fetchall()
312
+ if len(row) >= 3 and str(row[1]) == "main" and row[2]
313
+ ]
314
+ if len(main_paths) != 1:
315
+ return False
316
+
317
+ total_bytes = 0
318
+ for suffix in ("", "-wal", "-journal", "-shm"):
319
+ path = f"{main_paths[0]}{suffix}"
320
+ try:
321
+ file_stat = os.stat(path)
322
+ except FileNotFoundError:
323
+ if not suffix:
324
+ return False
325
+ continue
326
+ if not stat.S_ISREG(file_stat.st_mode) or file_stat.st_size < 0:
327
+ return False
328
+ total_bytes += file_stat.st_size
329
+ if total_bytes > limit:
330
+ return False
331
+ return True
332
+ except Exception as error:
333
+ if _sql_was_interrupted(error):
334
+ raise
335
+ return False
336
+
337
+
338
+ def _result_table_integer_pk_high_watermark(cursor, quoted_table):
339
+ """Return a conservative QCoDeS result-table row bound.
340
+
341
+ QCoDeS result tables use one ``id INTEGER PRIMARY KEY``. ``MAX(id)`` is a
342
+ bounded index lookup and safely overestimates the number of current rows
343
+ after deletions. Unknown or non-current schemas fail closed so an obsolete
344
+ controller ``result_count`` can never authorize an unbounded GROUP BY.
345
+ """
346
+ try:
347
+ cursor.execute(f"PRAGMA table_info({quoted_table})")
348
+ primary_keys = [row for row in cursor.fetchall() if len(row) >= 6 and row[5]]
349
+ if len(primary_keys) != 1:
350
+ return None
351
+ primary_key = primary_keys[0]
352
+ if (
353
+ str(primary_key[1]) != "id"
354
+ or str(primary_key[2] or "").strip().upper() != "INTEGER"
355
+ or primary_key[5] != 1
356
+ ):
357
+ return None
358
+ quoted_id = _sqlite_identifier(primary_key[1])
359
+ cursor.execute(f"SELECT MAX({quoted_id}) FROM {quoted_table}")
360
+ row = cursor.fetchone()
361
+ if row is None or len(row) != 1:
362
+ return None
363
+ value = row[0]
364
+ if value is None:
365
+ return 0
366
+ if type(value) is not int or value < 0:
367
+ return None
368
+ return value
369
+ except Exception as error:
370
+ if _sql_was_interrupted(error):
371
+ raise
372
+ return None
373
+
374
+
375
+ def get_snapshot_selected_run_detail(
376
+ database_path,
377
+ run_id,
378
+ guid,
379
+ run_metadata=None,
380
+ *,
381
+ expected_database_identity=None,
382
+ cancelled_callback=None,
383
+ connection_callback=None,
384
+ deadline=None,
385
+ ):
386
+ """Return a bounded immutable detail view without constructing a DataSet.
387
+
388
+ Every SQLite operation is finite and cancellable. Result-table values are
389
+ never materialised; only the existing bounded setpoint aggregates may
390
+ inspect a modest, already-counted result table.
391
+ """
392
+ from qplot.datahandling.trusted_live_queries import (
393
+ TrustedRunRecord,
394
+ TrustedSelectedRunDetail,
395
+ TrustedSetpointSummary,
396
+ bounded_parameter_presentation,
397
+ bounded_parameter_views_from_run_metadata,
398
+ bounded_setpoint_presentation,
399
+ freeze_primitive_fields,
400
+ )
401
+ from qplot.datahandling.trusted_presentation import (
402
+ bounded_presentation_names,
403
+ build_selected_run_presentation,
404
+ )
405
+ from qplot.datahandling.trusted_snapshot import normalize_trusted_snapshot
406
+
407
+ try:
408
+ run_id = int(run_id)
409
+ except (TypeError, ValueError, OverflowError) as error:
410
+ raise ValueError("run_id must be a positive integer") from error
411
+ guid = str(guid or "")
412
+ if run_id <= 0 or not guid:
413
+ raise ValueError("run_id and guid must identify one selected run")
414
+
415
+ conn = _read_only_connection(
416
+ database_path or get_DB_location(),
417
+ expected_database_identity,
418
+ cancelled_callback,
419
+ deadline,
420
+ )
421
+ try:
422
+ _notify_connection(connection_callback, conn)
423
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
424
+ cursor = conn.cursor()
425
+ columns = _snapshot_selected_run_columns(cursor)
426
+ values, unavailable, snapshot_omission = _bounded_snapshot_selected_run_values(
427
+ cursor,
428
+ run_id,
429
+ guid,
430
+ columns,
431
+ cancelled_callback=cancelled_callback,
432
+ deadline=deadline,
433
+ )
434
+ if values is None:
435
+ raise LookupError(f"Run {run_id} with GUID {guid} is no longer available.")
436
+ if str(values.get("guid") or "") != guid:
437
+ raise LookupError(f"Run {run_id} no longer has the selected GUID {guid}.")
438
+
439
+ database_values = {
440
+ name: value
441
+ for name, value in values.items()
442
+ if name in _SNAPSHOT_STANDARD_RUN_COLUMNS
443
+ }
444
+ database_values.pop("run_id", None)
445
+ materialized = materialize_run_basic_fields(database_values)
446
+ source_metadata = run_metadata or {}
447
+ for name in _SNAPSHOT_OBSERVATION_FIELDS:
448
+ if name not in source_metadata:
449
+ continue
450
+ value = source_metadata[name]
451
+ bounded = _snapshot_plain_value(value)
452
+ if bounded is not _SNAPSHOT_VALUE_UNAVAILABLE:
453
+ materialized[name] = bounded
454
+ materialized.update(
455
+ _snapshot_selected_experiment_fields(
456
+ cursor,
457
+ values.get("exp_id"),
458
+ )
459
+ )
460
+ materialized["guid"] = guid
461
+ materialized["run_id"] = run_id
462
+ materialized["database_modified_timestamp"] = _database_modified_timestamp(
463
+ cursor
464
+ )
465
+
466
+ layout_rows, layout_unavailable = _snapshot_selected_layout_rows(
467
+ cursor,
468
+ run_id,
469
+ )
470
+ parameters, source_parameters_truncated = (
471
+ bounded_parameter_views_from_run_metadata(
472
+ materialized,
473
+ layout_rows,
474
+ )
475
+ )
476
+
477
+ setpoint_names_list: list[str] = []
478
+ setpoint_names_seen: set[str] = set()
479
+ setpoint_names_truncated = False
480
+ for parameter in parameters:
481
+ for name in parameter.depends_on:
482
+ if not name or name in setpoint_names_seen:
483
+ continue
484
+ if len(setpoint_names_list) >= MAX_SNAPSHOT_SELECTED_SETPOINTS:
485
+ setpoint_names_truncated = True
486
+ break
487
+ setpoint_names_list.append(name)
488
+ setpoint_names_seen.add(name)
489
+ if setpoint_names_truncated:
490
+ break
491
+ setpoint_names = tuple(setpoint_names_list)
492
+ if not setpoint_names:
493
+ fallback_setpoints = materialized.get("sweep_parameters", ())
494
+ if isinstance(fallback_setpoints, (list, tuple)):
495
+ for index, name in enumerate(
496
+ islice(fallback_setpoints, MAX_SNAPSHOT_SELECTED_SETPOINTS + 1)
497
+ ):
498
+ if index >= MAX_SNAPSHOT_SELECTED_SETPOINTS:
499
+ setpoint_names_truncated = True
500
+ break
501
+ if isinstance(name, str) and name:
502
+ setpoint_names_list.append(name)
503
+ setpoint_names = tuple(dict.fromkeys(setpoint_names_list))
504
+ elif fallback_setpoints:
505
+ setpoint_names_truncated = True
506
+ if setpoint_names_truncated:
507
+ unavailable = (*unavailable, "setpoint_summaries")
508
+ summary_values = _snapshot_selected_summary_values(
509
+ cursor,
510
+ materialized,
511
+ setpoint_names,
512
+ cancelled_callback=cancelled_callback,
513
+ deadline=deadline,
514
+ )
515
+ summaries = tuple(
516
+ TrustedSetpointSummary(
517
+ name,
518
+ summary.get("from"),
519
+ summary.get("to"),
520
+ _snapshot_positive_int(summary.get("steps")),
521
+ )
522
+ for name, summary in summary_values.items()
523
+ )
524
+ parameters, parameters_truncated = bounded_parameter_presentation(parameters)
525
+ parameters_truncated = bool(
526
+ parameters_truncated
527
+ or source_parameters_truncated
528
+ or materialized.get("parameters_truncated")
529
+ )
530
+ summaries, summaries_truncated = bounded_setpoint_presentation(summaries)
531
+ metadata = {
532
+ name: value
533
+ for name, value in values.items()
534
+ if (name not in _SNAPSHOT_STANDARD_RUN_COLUMNS and value is not None)
535
+ }
536
+ snapshot = normalize_trusted_snapshot(
537
+ values.get("snapshot"),
538
+ omission=snapshot_omission,
539
+ )
540
+ unavailable_fields = tuple(
541
+ dict.fromkeys(
542
+ (
543
+ *unavailable,
544
+ *layout_unavailable,
545
+ *(("parameters.presentation",) if parameters_truncated else ()),
546
+ *(
547
+ ("setpoint_summaries.presentation",)
548
+ if summaries_truncated
549
+ else ()
550
+ ),
551
+ )
552
+ )
553
+ )
554
+ public_unavailable_fields, _unavailable_truncated = bounded_presentation_names(
555
+ unavailable_fields
556
+ )
557
+ presentation = build_selected_run_presentation(
558
+ run_fields={**materialized, "run_id": run_id},
559
+ metadata_fields=metadata,
560
+ parameters=tuple(
561
+ {
562
+ "name": parameter.name,
563
+ "label": parameter.label,
564
+ "unit": parameter.unit,
565
+ "depends_on": parameter.depends_on,
566
+ "type": parameter.paramtype,
567
+ }
568
+ for parameter in parameters
569
+ ),
570
+ snapshot_summary={
571
+ "Status": (
572
+ "available — loaded on demand"
573
+ if snapshot.status == "available"
574
+ else snapshot.status
575
+ ),
576
+ "Message": snapshot.message,
577
+ "Input bytes": snapshot.input_bytes,
578
+ "Initial page nodes": len(snapshot.nodes),
579
+ },
580
+ setpoint_summaries=tuple(
581
+ {
582
+ "name": summary.name,
583
+ "from": summary.first,
584
+ "to": summary.last,
585
+ "steps": summary.steps,
586
+ }
587
+ for summary in summaries
588
+ ),
589
+ unavailable_fields=public_unavailable_fields,
590
+ parameters_truncated=parameters_truncated,
591
+ )
592
+ _raise_if_read_aborted(cancelled_callback, deadline)
593
+ return TrustedSelectedRunDetail(
594
+ run=TrustedRunRecord(
595
+ run_id,
596
+ freeze_primitive_fields(dict(presentation.run_fields)),
597
+ public_unavailable_fields,
598
+ ),
599
+ parameters=parameters,
600
+ metadata=freeze_primitive_fields(dict(presentation.metadata_fields)),
601
+ snapshot=snapshot,
602
+ setpoint_summaries=summaries,
603
+ presentation=presentation,
604
+ unavailable_fields=public_unavailable_fields,
605
+ )
606
+ except sqlite3.OperationalError as error:
607
+ _translate_interrupted_read(error, cancelled_callback, deadline)
608
+ raise
609
+ finally:
610
+ try:
611
+ _notify_connection(connection_callback, None)
612
+ finally:
613
+ conn.close()
614
+
615
+
616
+ _SNAPSHOT_VALUE_UNAVAILABLE = object()
617
+
618
+
619
+ def _snapshot_plain_value(value):
620
+ bounded, _size = _snapshot_plain_scalar(value)
621
+ if bounded is not _SNAPSHOT_VALUE_UNAVAILABLE:
622
+ return bounded
623
+ if not isinstance(value, (list, tuple)) or len(value) > 256:
624
+ return _SNAPSHOT_VALUE_UNAVAILABLE
625
+
626
+ items = []
627
+ total_bytes = 0
628
+ for item in value:
629
+ bounded, item_bytes = _snapshot_plain_scalar(item)
630
+ if bounded is _SNAPSHOT_VALUE_UNAVAILABLE:
631
+ return _SNAPSHOT_VALUE_UNAVAILABLE
632
+ total_bytes += item_bytes
633
+ if total_bytes > MAX_SNAPSHOT_SELECTED_OBSERVATION_BYTES:
634
+ return _SNAPSHOT_VALUE_UNAVAILABLE
635
+ items.append(bounded)
636
+ return tuple(items)
637
+
638
+
639
+ def _snapshot_plain_scalar(value):
640
+ if value is None or isinstance(value, (bool, int, float)):
641
+ return value, 8
642
+ if isinstance(value, str):
643
+ value_bytes = len(value.encode("utf-8", errors="surrogatepass"))
644
+ if value_bytes <= MAX_SNAPSHOT_SELECTED_OBSERVATION_BYTES:
645
+ return value, value_bytes
646
+ return _SNAPSHOT_VALUE_UNAVAILABLE, 0
647
+ if isinstance(value, bytes):
648
+ if len(value) <= MAX_SNAPSHOT_SELECTED_OBSERVATION_BYTES:
649
+ return value, len(value)
650
+ return _SNAPSHOT_VALUE_UNAVAILABLE, 0
651
+ return _SNAPSHOT_VALUE_UNAVAILABLE, 0
652
+
653
+
654
+ def _snapshot_selected_run_columns(cursor):
655
+ cursor.execute('SELECT * FROM "runs" WHERE 0')
656
+ columns = tuple(description[0] for description in cursor.description or ())
657
+ if (
658
+ not {"run_id", "guid"}.issubset(columns)
659
+ or len(columns) > MAX_SNAPSHOT_SELECTED_RUN_COLUMNS
660
+ ):
661
+ raise RuntimeError(
662
+ "The selected QCoDeS run schema exceeds the bounded detail plan."
663
+ )
664
+ return columns
665
+
666
+
667
+ def _bounded_snapshot_selected_run_values(
668
+ cursor,
669
+ run_id,
670
+ guid,
671
+ columns,
672
+ *,
673
+ cancelled_callback=None,
674
+ deadline=None,
675
+ ):
676
+ from qplot.datahandling.trusted_snapshot import TrustedSnapshotOmission
677
+
678
+ values: dict[str, object] = {}
679
+ unavailable = []
680
+ accepted_bytes = 0
681
+ snapshot_omission = None
682
+ for name in columns:
683
+ _raise_if_read_aborted(cancelled_callback, deadline)
684
+ quoted = _sqlite_identifier(name)
685
+ cursor.execute(
686
+ f"SELECT typeof({quoted}), length(CAST({quoted} AS BLOB)) "
687
+ 'FROM "runs" WHERE "run_id" = ? AND "guid" = ? LIMIT 1',
688
+ (run_id, guid),
689
+ )
690
+ preflight = cursor.fetchone()
691
+ if preflight is None:
692
+ return None, (), None
693
+ value_type, value_bytes = preflight
694
+ if value_type == "null" and value_bytes is None:
695
+ values[name] = None
696
+ continue
697
+ if (
698
+ value_type not in {"integer", "real", "text", "blob"}
699
+ or type(value_bytes) is not int
700
+ or value_bytes < 0
701
+ ):
702
+ raise RuntimeError(
703
+ "A selected-run scalar preflight returned invalid metadata."
704
+ )
705
+ if (
706
+ value_bytes > MAX_SNAPSHOT_SELECTED_RUN_SCALAR_BYTES
707
+ or accepted_bytes + value_bytes > MAX_SNAPSHOT_SELECTED_RUN_TOTAL_BYTES
708
+ ):
709
+ values[name] = None
710
+ unavailable.append(name)
711
+ if name == "snapshot":
712
+ snapshot_omission = TrustedSnapshotOmission(
713
+ (
714
+ "payload_limit"
715
+ if value_bytes > MAX_SNAPSHOT_SELECTED_RUN_SCALAR_BYTES
716
+ else "detail_budget"
717
+ ),
718
+ value_bytes,
719
+ )
720
+ continue
721
+ cursor.execute(
722
+ "SELECT CASE WHEN "
723
+ f"typeof({quoted}) = ? AND length(CAST({quoted} AS BLOB)) = ? "
724
+ f"THEN {quoted} ELSE NULL END "
725
+ 'FROM "runs" WHERE "run_id" = ? AND "guid" = ? LIMIT 1',
726
+ (value_type, value_bytes, run_id, guid),
727
+ )
728
+ fetched = cursor.fetchone()
729
+ if fetched is None:
730
+ return None, (), None
731
+ value = fetched[0]
732
+ values[name] = value
733
+ if value is None:
734
+ unavailable.append(name)
735
+ if name == "snapshot":
736
+ snapshot_omission = TrustedSnapshotOmission(
737
+ "changed_during_read",
738
+ value_bytes,
739
+ )
740
+ else:
741
+ accepted_bytes += value_bytes
742
+ return values, tuple(unavailable), snapshot_omission
743
+
744
+
745
+ def _snapshot_selected_experiment_fields(cursor, exp_id):
746
+ if exp_id is None:
747
+ return {}
748
+ limit = _SNAPSHOT_SELECTED_LAYOUT_TEXT_BYTES
749
+ cursor.execute(
750
+ "SELECT "
751
+ "CASE WHEN length(CAST(name AS BLOB)) <= ? THEN name END, "
752
+ "CASE WHEN length(CAST(sample_name AS BLOB)) <= ? THEN sample_name END "
753
+ "FROM experiments WHERE exp_id = ? LIMIT 1",
754
+ (limit, limit, exp_id),
755
+ )
756
+ row = cursor.fetchone()
757
+ if row is None:
758
+ return {}
759
+ return {"exp_name": row[0], "sample_name": row[1]}
760
+
761
+
762
+ def _snapshot_selected_layout_rows(cursor, run_id):
763
+ limit = _SNAPSHOT_SELECTED_LAYOUT_TEXT_BYTES
764
+ cursor.execute(
765
+ "SELECT layout_id, "
766
+ "CASE WHEN length(CAST(parameter AS BLOB)) <= ? THEN parameter END, "
767
+ "CASE WHEN length(CAST(label AS BLOB)) <= ? THEN label END, "
768
+ "CASE WHEN length(CAST(unit AS BLOB)) <= ? THEN unit END, "
769
+ "CASE WHEN length(CAST(inferred_from AS BLOB)) <= ? THEN inferred_from END "
770
+ "FROM layouts WHERE run_id = ? ORDER BY layout_id LIMIT ?",
771
+ (
772
+ limit,
773
+ limit,
774
+ limit,
775
+ limit,
776
+ run_id,
777
+ MAX_SNAPSHOT_SELECTED_LAYOUT_ROWS + 1,
778
+ ),
779
+ )
780
+ rows = tuple(tuple(row) for row in cursor.fetchall())
781
+ if len(rows) <= MAX_SNAPSHOT_SELECTED_LAYOUT_ROWS:
782
+ return rows, ()
783
+ return rows[:MAX_SNAPSHOT_SELECTED_LAYOUT_ROWS], ("layouts",)
784
+
785
+
786
+ def _snapshot_selected_summary_values(
787
+ cursor,
788
+ metadata,
789
+ setpoint_names,
790
+ *,
791
+ cancelled_callback=None,
792
+ deadline=None,
793
+ ):
794
+ from qplot.datahandling.trusted_live_queries import TrustedMetadataQueryAdapter
795
+
796
+ result_count = _snapshot_positive_int(metadata.get("result_count"))
797
+ summaries = {}
798
+ if (
799
+ result_count is not None
800
+ and result_count <= MAX_SELECTED_RUN_SETPOINT_SUMMARY_ROWS
801
+ and metadata.get("result_table_name")
802
+ and setpoint_names
803
+ ):
804
+ summaries.update(
805
+ _selected_run_setpoint_summaries_from_cursor(
806
+ cursor,
807
+ metadata["result_table_name"],
808
+ setpoint_names,
809
+ cancelled_callback=cancelled_callback,
810
+ deadline=deadline,
811
+ )
812
+ )
813
+ planned_steps = TrustedMetadataQueryAdapter._planned_setpoint_steps(
814
+ metadata, setpoint_names,
815
+ )
816
+ # The largest run-wide shape cannot describe independent dependent trees.
817
+ # Retain the controller's shape only when no dependency declaration exists
818
+ # and a single dependent makes its association unambiguous.
819
+ if (
820
+ not _parameter_dependencies(_json_dict(metadata.get("run_description")))
821
+ and len(metadata.get("measure_parameters") or ()) == 1
822
+ ):
823
+ shape = metadata.get("setpoint_shape") or metadata.get("point_shape") or ()
824
+ if len(shape) == len(setpoint_names):
825
+ planned_steps = dict(zip(setpoint_names, shape, strict=True))
826
+ for name, steps in planned_steps.items():
827
+ summaries.setdefault(name, {}).setdefault("steps", steps)
828
+ return {name: summaries[name] for name in setpoint_names if name in summaries}
829
+
830
+
831
+ def _snapshot_positive_int(value):
832
+ try:
833
+ value = int(value)
834
+ except (TypeError, ValueError, OverflowError):
835
+ return None
836
+ return value if value > 0 else None
837
+
838
+
839
+ def _selected_run_setpoint_summary(cursor, quoted_table, parameter):
840
+ """Return first, last, and distinct count for one setpoint column."""
841
+ column = _sqlite_identifier(parameter)
842
+ try:
843
+ cursor.execute(f"""
844
+ WITH distinct_values(value, first_rowid) AS (
845
+ SELECT {column}, MIN(rowid)
846
+ FROM {quoted_table}
847
+ WHERE {column} IS NOT NULL
848
+ GROUP BY {column}
849
+ ), summary_values(first_value, last_value, steps) AS (
850
+ SELECT
851
+ (
852
+ SELECT value
853
+ FROM distinct_values
854
+ ORDER BY first_rowid ASC
855
+ LIMIT 1
856
+ ),
857
+ (
858
+ SELECT value
859
+ FROM distinct_values
860
+ ORDER BY first_rowid DESC
861
+ LIMIT 1
862
+ ),
863
+ (SELECT COUNT(*) FROM distinct_values)
864
+ )
865
+ SELECT
866
+ CASE WHEN length(CAST(first_value AS BLOB)) <=
867
+ {MAX_SELECTED_RUN_SETPOINT_SUMMARY_SCALAR_BYTES}
868
+ THEN first_value END,
869
+ CASE WHEN length(CAST(last_value AS BLOB)) <=
870
+ {MAX_SELECTED_RUN_SETPOINT_SUMMARY_SCALAR_BYTES}
871
+ THEN last_value END,
872
+ steps
873
+ FROM summary_values
874
+ """)
875
+ first_value, last_value, count = cursor.fetchone()
876
+ count = int(count or 0)
877
+ except Exception as error:
878
+ if _sql_was_interrupted(error):
879
+ raise
880
+ return {}
881
+ if count <= 0 or first_value is None or last_value is None:
882
+ return {}
883
+ return {
884
+ "from": first_value,
885
+ "to": last_value,
886
+ "steps": count,
887
+ }
888
+
889
+
890
+ def get_runs_via_sql(
891
+ database_path=None,
892
+ include_details=True,
893
+ cancelled_callback=None,
894
+ connection_callback=None,
895
+ expected_database_identity=None,
896
+ deadline=None,
897
+ ):
898
+ """
899
+ Read from the currently initialised QCoDeS database and fetches all data to
900
+ be displayed in Main Window runList
901
+
902
+ Returns
903
+ -------
904
+ outDict : dict{int: dict}
905
+ A nested dictionary of requried data.
906
+ Has layout:
907
+ run_id : {column_name: column_data}
908
+
909
+ """
910
+ conn = _read_only_connection(
911
+ database_path or get_DB_location(),
912
+ expected_database_identity,
913
+ cancelled_callback,
914
+ deadline,
915
+ )
916
+ try:
917
+ _notify_connection(connection_callback, conn)
918
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
919
+ cursor = conn.cursor()
920
+ rows = _fetch_run_rows(
921
+ cursor,
922
+ empty_as_none=False,
923
+ include_details=include_details,
924
+ )
925
+ _raise_if_read_aborted(cancelled_callback, deadline)
926
+ return rows
927
+ except sqlite3.OperationalError as error:
928
+ _translate_interrupted_read(error, cancelled_callback, deadline)
929
+ raise
930
+ finally:
931
+ try:
932
+ _notify_connection(connection_callback, None)
933
+ finally:
934
+ conn.close()
935
+
936
+
937
+ def get_runs_basic_via_sql(
938
+ database_path=None,
939
+ cancelled_callback=None,
940
+ connection_callback=None,
941
+ expected_database_identity=None,
942
+ deadline=None,
943
+ ):
944
+ """
945
+ Read the run list without scanning result tables.
946
+
947
+ This is the fast path for opening large databases. It includes enough
948
+ metadata to populate the run table and measurement placeholders, while
949
+ leaving expensive counts, setpoint-shape inference, and storage estimates
950
+ to later targeted loads.
951
+
952
+ """
953
+ return get_runs_via_sql(
954
+ database_path=database_path,
955
+ include_details=False,
956
+ cancelled_callback=cancelled_callback,
957
+ connection_callback=connection_callback,
958
+ expected_database_identity=expected_database_identity,
959
+ deadline=deadline,
960
+ )
961
+
962
+
963
+ def iter_run_detail_batches_via_sql(
964
+ database_path,
965
+ run_ids,
966
+ batch_size=1,
967
+ infer_missing_shapes=True,
968
+ include_storage_bytes=True,
969
+ include_storage_estimate=False,
970
+ include_read_setpoint_count=True,
971
+ cancelled_callback=None,
972
+ connection_callback=None,
973
+ expected_database_identity=None,
974
+ deadline=None,
975
+ ):
976
+ """
977
+ Yield detailed run metadata in small batches.
978
+
979
+ This keeps the initial load responsive while allowing the GUI to fill in
980
+ expensive counts, setpoint shapes, and storage sizes progressively.
981
+
982
+ """
983
+ run_ids = [run_id for run_id in run_ids if run_id is not None]
984
+ _raise_if_read_aborted(cancelled_callback, deadline)
985
+ if not run_ids:
986
+ return
987
+
988
+ batch_size = max(1, int(batch_size or 1))
989
+ conn = _read_only_connection(
990
+ database_path or get_DB_location(),
991
+ expected_database_identity,
992
+ cancelled_callback,
993
+ deadline,
994
+ )
995
+ try:
996
+ _notify_connection(connection_callback, conn)
997
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
998
+ cursor = conn.cursor()
999
+ for offset in range(0, len(run_ids), batch_size):
1000
+ _raise_if_read_aborted(cancelled_callback, deadline)
1001
+ batch = run_ids[offset : offset + batch_size]
1002
+ placeholders = ", ".join("?" for _ in batch)
1003
+ rows = _fetch_run_rows(
1004
+ cursor,
1005
+ f"WHERE runs.run_id IN ({placeholders})",
1006
+ tuple(batch),
1007
+ empty_as_none=False,
1008
+ include_details=True,
1009
+ infer_missing_shapes=infer_missing_shapes,
1010
+ include_storage_bytes=include_storage_bytes,
1011
+ include_storage_estimate=include_storage_estimate,
1012
+ include_read_setpoint_count=include_read_setpoint_count,
1013
+ )
1014
+ _raise_if_read_aborted(cancelled_callback, deadline)
1015
+ yield rows or {}
1016
+ _raise_if_read_aborted(cancelled_callback, deadline)
1017
+ except sqlite3.OperationalError as error:
1018
+ _translate_interrupted_read(error, cancelled_callback, deadline)
1019
+ raise
1020
+ finally:
1021
+ try:
1022
+ _notify_connection(connection_callback, None)
1023
+ finally:
1024
+ conn.close()
1025
+
1026
+
1027
+ def iter_run_shape_batches_via_sql(
1028
+ database_path,
1029
+ run_ids,
1030
+ batch_size=1,
1031
+ cancelled_callback=None,
1032
+ connection_callback=None,
1033
+ expected_database_identity=None,
1034
+ deadline=None,
1035
+ ):
1036
+ """
1037
+ Yield setpoint-shape metadata for runs that need result-table inference.
1038
+
1039
+ This can require COUNT(DISTINCT ...) scans on result columns, so it runs
1040
+ after cheap row counts are visible.
1041
+
1042
+ """
1043
+ run_ids = [run_id for run_id in run_ids if run_id is not None]
1044
+ _raise_if_read_aborted(cancelled_callback, deadline)
1045
+ if not run_ids:
1046
+ return
1047
+
1048
+ batch_size = max(1, int(batch_size or 1))
1049
+ conn = _read_only_connection(
1050
+ database_path or get_DB_location(),
1051
+ expected_database_identity,
1052
+ cancelled_callback,
1053
+ deadline,
1054
+ )
1055
+ try:
1056
+ _notify_connection(connection_callback, conn)
1057
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
1058
+ cursor = conn.cursor()
1059
+ for offset in range(0, len(run_ids), batch_size):
1060
+ _raise_if_read_aborted(cancelled_callback, deadline)
1061
+ batch = run_ids[offset : offset + batch_size]
1062
+ placeholders = ", ".join("?" for _ in batch)
1063
+ rows = _fetch_run_rows(
1064
+ cursor,
1065
+ f"WHERE runs.run_id IN ({placeholders})",
1066
+ tuple(batch),
1067
+ empty_as_none=False,
1068
+ include_details=True,
1069
+ infer_missing_shapes=True,
1070
+ include_storage_bytes=False,
1071
+ include_read_setpoint_count=False,
1072
+ )
1073
+ rows = {
1074
+ run_id: metadata
1075
+ for run_id, metadata in (rows or {}).items()
1076
+ if (
1077
+ metadata.get("setpoint_shape")
1078
+ or metadata.get("point_shape")
1079
+ or metadata.get("setpoint_count") is not None
1080
+ )
1081
+ }
1082
+ if rows:
1083
+ _raise_if_read_aborted(cancelled_callback, deadline)
1084
+ yield rows
1085
+ _raise_if_read_aborted(cancelled_callback, deadline)
1086
+ except sqlite3.OperationalError as error:
1087
+ _translate_interrupted_read(error, cancelled_callback, deadline)
1088
+ raise
1089
+ finally:
1090
+ try:
1091
+ _notify_connection(connection_callback, None)
1092
+ finally:
1093
+ conn.close()
1094
+
1095
+
1096
+ def iter_run_storage_batches_via_sql(
1097
+ database_path,
1098
+ run_ids,
1099
+ batch_size=25,
1100
+ cancelled_callback=None,
1101
+ connection_callback=None,
1102
+ expected_database_identity=None,
1103
+ deadline=None,
1104
+ ):
1105
+ """
1106
+ Yield per-run storage sizes after the cheap detail pass has completed.
1107
+
1108
+ Storage lookup can require a dbstat scan over the database. Keeping this as
1109
+ a separate pass prevents size calculation from blocking result counts and
1110
+ completion metadata on very large databases.
1111
+
1112
+ """
1113
+ run_ids = [run_id for run_id in run_ids if run_id is not None]
1114
+ _raise_if_read_aborted(cancelled_callback, deadline)
1115
+ if not run_ids:
1116
+ return
1117
+
1118
+ batch_size = max(1, int(batch_size or 1))
1119
+ conn = _read_only_connection(
1120
+ database_path or get_DB_location(),
1121
+ expected_database_identity,
1122
+ cancelled_callback,
1123
+ deadline,
1124
+ )
1125
+ try:
1126
+ _notify_connection(connection_callback, conn)
1127
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
1128
+ cursor = conn.cursor()
1129
+ run_tables = _run_storage_tables(cursor, run_ids)
1130
+ table_names = {
1131
+ metadata.get("result_table_name")
1132
+ for metadata in run_tables.values()
1133
+ if metadata.get("result_table_name")
1134
+ }
1135
+ sizes = _table_storage_bytes_by_name(cursor, table_names)
1136
+ if not sizes:
1137
+ _raise_if_read_aborted(cancelled_callback, deadline)
1138
+ return
1139
+
1140
+ for offset in range(0, len(run_ids), batch_size):
1141
+ _raise_if_read_aborted(cancelled_callback, deadline)
1142
+ rows = {}
1143
+ for run_id in run_ids[offset : offset + batch_size]:
1144
+ metadata = run_tables.get(run_id)
1145
+ if not metadata:
1146
+ continue
1147
+
1148
+ storage_bytes = sizes.get(metadata.get("result_table_name"))
1149
+ if storage_bytes is None:
1150
+ continue
1151
+
1152
+ rows[run_id] = {
1153
+ "guid": metadata.get("guid"),
1154
+ "storage_bytes": storage_bytes,
1155
+ "storage_bytes_estimated": False,
1156
+ }
1157
+ if rows:
1158
+ _raise_if_read_aborted(cancelled_callback, deadline)
1159
+ yield rows
1160
+ _raise_if_read_aborted(cancelled_callback, deadline)
1161
+ except sqlite3.OperationalError as error:
1162
+ _translate_interrupted_read(error, cancelled_callback, deadline)
1163
+ raise
1164
+ finally:
1165
+ try:
1166
+ _notify_connection(connection_callback, None)
1167
+ finally:
1168
+ conn.close()
1169
+
1170
+
1171
+ def find_new_runs(
1172
+ last_run_id,
1173
+ database_path=None,
1174
+ cancelled_callback=None,
1175
+ connection_callback=None,
1176
+ expected_database_identity=None,
1177
+ deadline=None,
1178
+ ):
1179
+ """
1180
+ Fetch all runs created after the last seen run ID.
1181
+
1182
+ Run IDs provide a monotonic cursor even when a run has no timestamp or
1183
+ multiple runs share the same timestamp.
1184
+
1185
+ Parameters
1186
+ ----------
1187
+ last_run_id : int
1188
+ Only runs with a greater run ID will be returned.
1189
+
1190
+ Returns
1191
+ -------
1192
+ outDict : dict{int: dict}
1193
+ A nested dictionary of requried data.
1194
+ Has layout:
1195
+ run_id : {column_name: column_data}
1196
+ """
1197
+ conn = _read_only_connection(
1198
+ database_path or get_DB_location(),
1199
+ expected_database_identity,
1200
+ cancelled_callback,
1201
+ deadline,
1202
+ )
1203
+
1204
+ try:
1205
+ _notify_connection(connection_callback, conn)
1206
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
1207
+ cursor = conn.cursor()
1208
+ rows = _fetch_run_rows(cursor, "WHERE runs.run_id > ?", (last_run_id,))
1209
+ _raise_if_read_aborted(cancelled_callback, deadline)
1210
+ return rows
1211
+ except sqlite3.OperationalError as error:
1212
+ _translate_interrupted_read(error, cancelled_callback, deadline)
1213
+ raise
1214
+ finally:
1215
+ try:
1216
+ _notify_connection(connection_callback, None)
1217
+ finally:
1218
+ conn.close()
1219
+
1220
+
1221
+ def _fetch_run_rows(
1222
+ cursor,
1223
+ where="",
1224
+ params=(),
1225
+ empty_as_none=True,
1226
+ include_details=True,
1227
+ infer_missing_shapes=True,
1228
+ include_storage_bytes=True,
1229
+ include_storage_estimate=False,
1230
+ include_read_setpoint_count=True,
1231
+ storage_bytes_by_table=None,
1232
+ ):
1233
+ optional_columns = _existing_run_columns(cursor, ["measurement_exception"])
1234
+ optional_select = "".join(
1235
+ f", runs.{_sqlite_identifier(column)} AS {_sqlite_identifier(column)}"
1236
+ for column in optional_columns
1237
+ )
1238
+ cursor.execute(
1239
+ f"""
1240
+ SELECT
1241
+ runs.run_id,
1242
+ runs.exp_id,
1243
+ runs.name,
1244
+ runs.run_timestamp,
1245
+ runs.completed_timestamp,
1246
+ runs.is_completed,
1247
+ runs.guid,
1248
+ runs.result_table_name,
1249
+ runs.parameters,
1250
+ runs.run_description,
1251
+ experiments.name AS exp_name,
1252
+ experiments.sample_name
1253
+ {optional_select}
1254
+ FROM runs
1255
+ LEFT JOIN experiments ON runs.exp_id = experiments.exp_id
1256
+ {where}
1257
+ """,
1258
+ params,
1259
+ )
1260
+ values = cursor.fetchall()
1261
+
1262
+ if len(values) == 0:
1263
+ return None if empty_as_none else {}
1264
+
1265
+ column_names = [desc[0] for desc in cursor.description]
1266
+
1267
+ outDict = {}
1268
+ database_modified_timestamp = _database_modified_timestamp(cursor)
1269
+ for row in values:
1270
+ metadata = dict(zip(column_names[1:], row[1:], strict=False))
1271
+ metadata["database_modified_timestamp"] = database_modified_timestamp
1272
+ metadata = materialize_run_basic_fields(metadata)
1273
+ if include_details:
1274
+ _add_run_detail_fields(
1275
+ cursor,
1276
+ metadata,
1277
+ infer_missing_shapes=infer_missing_shapes,
1278
+ include_storage_bytes=include_storage_bytes,
1279
+ include_storage_estimate=include_storage_estimate,
1280
+ include_read_setpoint_count=include_read_setpoint_count,
1281
+ storage_bytes_by_table=storage_bytes_by_table,
1282
+ )
1283
+ outDict[row[0]] = metadata
1284
+
1285
+ return outDict
1286
+
1287
+
1288
+ def _add_run_basic_fields(metadata):
1289
+ run_description = _json_dict(metadata.get("run_description"))
1290
+ measure_parameters, sweep_parameters, parameters_truncated = (
1291
+ _bounded_parameter_roles(
1292
+ run_description,
1293
+ metadata.get("parameters"),
1294
+ )
1295
+ )
1296
+
1297
+ point_shape, shape_truncated = _bounded_point_shape(
1298
+ run_description,
1299
+ measure_parameters,
1300
+ )
1301
+
1302
+ metadata["measure_parameters"] = measure_parameters
1303
+ metadata["sweep_parameters"] = sweep_parameters
1304
+ metadata["parameters_truncated"] = bool(parameters_truncated or shape_truncated)
1305
+ metadata["point_shape"] = point_shape
1306
+ metadata["setpoint_shape"] = metadata["point_shape"]
1307
+ shape_source = "planned" if metadata["setpoint_shape"] else None
1308
+ metadata["setpoint_shape_source"] = shape_source
1309
+ expected_results, expected_shape_truncated = _bounded_expected_results_from_shapes(
1310
+ run_description,
1311
+ measure_parameters,
1312
+ )
1313
+ metadata["parameters_truncated"] = bool(
1314
+ metadata["parameters_truncated"] or expected_shape_truncated
1315
+ )
1316
+ metadata["expected_results"] = expected_results
1317
+ metadata["expected_results_source"] = (
1318
+ "planned" if expected_results is not None else None
1319
+ )
1320
+ metadata["setpoint_count"] = _shape_size(metadata["setpoint_shape"])
1321
+ metadata["setpoint_count_source"] = shape_source
1322
+
1323
+
1324
+ def materialize_run_basic_fields(metadata):
1325
+ """Return one run mapping with qPlot's shared derived basic fields.
1326
+
1327
+ Snapshot readers and the trusted fixed-query adapter deliberately share
1328
+ this transformation. Keeping it independent of cursors, connections,
1329
+ QCoDeS objects, and filesystem access prevents the two read paths from
1330
+ drifting while allowing the trusted path to materialise only primitive
1331
+ query results.
1332
+ """
1333
+ materialized = dict(metadata or {})
1334
+ _add_run_basic_fields(materialized)
1335
+ return materialized
1336
+
1337
+
1338
+ _OBSERVATION_UNSET = object()
1339
+
1340
+
1341
+ def materialize_run_observation(
1342
+ metadata,
1343
+ *,
1344
+ result_count=None,
1345
+ setpoint_shape=None,
1346
+ setpoint_count=None,
1347
+ storage_bytes=None,
1348
+ storage_bytes_estimated=None,
1349
+ read_setpoint_count=_OBSERVATION_UNSET,
1350
+ ):
1351
+ """Return shared detail fields from already-bounded observations.
1352
+
1353
+ All database work happens before this function. The snapshot path can
1354
+ continue collecting observations with cursors, while the trusted adapter
1355
+ supplies the same values from fixed supervisor queries.
1356
+ An omitted acquired count retains the prior observation; explicit ``None``
1357
+ clears a count whose structural evidence is no longer available.
1358
+ """
1359
+ materialized = materialize_run_basic_fields(metadata)
1360
+ if result_count is not None:
1361
+ materialized["result_count"] = int(result_count)
1362
+ elif "result_count" not in materialized:
1363
+ materialized["result_count"] = None
1364
+
1365
+ if setpoint_count is not None or setpoint_shape is not None:
1366
+ normalized_shape = (
1367
+ [int(size) for size in setpoint_shape] if setpoint_shape else None
1368
+ )
1369
+ materialized["setpoint_shape"] = normalized_shape
1370
+ materialized["point_shape"] = _point_shape_from_setpoint_shape(
1371
+ normalized_shape,
1372
+ materialized.get("measure_parameters"),
1373
+ materialized.get("result_count"),
1374
+ )
1375
+ materialized["setpoint_shape_source"] = "observed" if normalized_shape else None
1376
+ materialized["setpoint_count"] = (
1377
+ int(setpoint_count) if setpoint_count is not None else None
1378
+ )
1379
+ materialized["setpoint_count_source"] = (
1380
+ "observed" if setpoint_count is not None else None
1381
+ )
1382
+ materialized["expected_results"] = None
1383
+ materialized["expected_results_source"] = None
1384
+
1385
+ if read_setpoint_count is not _OBSERVATION_UNSET:
1386
+ materialized["read_setpoint_count"] = (
1387
+ int(read_setpoint_count) if read_setpoint_count is not None else None
1388
+ )
1389
+
1390
+ if storage_bytes is not None or storage_bytes_estimated is not None:
1391
+ materialized["storage_bytes"] = (
1392
+ int(storage_bytes) if storage_bytes is not None else None
1393
+ )
1394
+ materialized["storage_bytes_estimated"] = storage_bytes_estimated
1395
+
1396
+ _add_completed_observed_result_count(materialized)
1397
+ return materialized
1398
+
1399
+
1400
+ def _add_run_detail_fields(
1401
+ cursor,
1402
+ metadata,
1403
+ infer_missing_shapes=True,
1404
+ include_storage_bytes=True,
1405
+ include_storage_estimate=False,
1406
+ include_read_setpoint_count=True,
1407
+ storage_bytes_by_table=None,
1408
+ ):
1409
+ measure_parameters = metadata.get("measure_parameters") or []
1410
+ sweep_parameters = metadata.get("sweep_parameters") or []
1411
+
1412
+ metadata["result_count"] = _result_count(cursor, metadata.get("result_table_name"))
1413
+ observed_setpoints = None
1414
+ if infer_missing_shapes and not metadata["point_shape"]:
1415
+ observed_setpoints = _add_observed_shape_fields(cursor, metadata)
1416
+ _add_completed_observed_result_count(metadata)
1417
+ if include_read_setpoint_count and (
1418
+ not bool(metadata.get("is_completed"))
1419
+ or _is_keyboard_interrupt(metadata.get("measurement_exception"))
1420
+ ):
1421
+ if observed_setpoints is None:
1422
+ observed_setpoints = _run_setpoint_observation(
1423
+ cursor,
1424
+ metadata.get("result_table_name"),
1425
+ _json_dict(metadata.get("run_description")),
1426
+ measure_parameters,
1427
+ sweep_parameters,
1428
+ )
1429
+ metadata["read_setpoint_count"] = observed_setpoints["count"]
1430
+ if include_storage_bytes:
1431
+ table_name = metadata.get("result_table_name")
1432
+ if storage_bytes_by_table is not None:
1433
+ storage_bytes = storage_bytes_by_table.get(table_name)
1434
+ storage_size = _StorageSize(
1435
+ storage_bytes,
1436
+ "exact" if storage_bytes is not None else "unavailable",
1437
+ )
1438
+ else:
1439
+ storage_size = _table_storage_bytes(
1440
+ cursor,
1441
+ table_name,
1442
+ result_count=metadata.get("result_count"),
1443
+ )
1444
+ _add_storage_size_fields(metadata, storage_size)
1445
+ elif include_storage_estimate:
1446
+ storage_bytes = _estimated_table_storage_bytes(
1447
+ cursor,
1448
+ metadata.get("result_table_name"),
1449
+ result_count=metadata.get("result_count"),
1450
+ )
1451
+ _add_storage_size_fields(
1452
+ metadata,
1453
+ _StorageSize(
1454
+ storage_bytes,
1455
+ "estimated" if storage_bytes is not None else "unavailable",
1456
+ ),
1457
+ )
1458
+
1459
+
1460
+ def _add_storage_size_fields(metadata, storage_size):
1461
+ metadata["storage_bytes"] = storage_size.bytes
1462
+ metadata["storage_bytes_estimated"] = {
1463
+ "exact": False,
1464
+ "estimated": True,
1465
+ "unavailable": None,
1466
+ }[storage_size.accuracy]
1467
+
1468
+
1469
+ def _add_observed_shape_fields(cursor, metadata):
1470
+ measure_parameters = metadata.get("measure_parameters") or []
1471
+ sweep_parameters = metadata.get("sweep_parameters") or []
1472
+ observed_setpoints = _run_setpoint_observation(
1473
+ cursor,
1474
+ metadata.get("result_table_name"),
1475
+ _json_dict(metadata.get("run_description")),
1476
+ measure_parameters,
1477
+ sweep_parameters,
1478
+ )
1479
+ setpoint_shape = observed_setpoints["shape"]
1480
+ metadata["setpoint_shape"] = setpoint_shape
1481
+ metadata["point_shape"] = _point_shape_from_setpoint_shape(
1482
+ setpoint_shape,
1483
+ measure_parameters,
1484
+ metadata.get("result_count"),
1485
+ )
1486
+ metadata["setpoint_shape_source"] = "observed" if setpoint_shape else None
1487
+ metadata["setpoint_count"] = observed_setpoints["count"]
1488
+ metadata["setpoint_count_source"] = (
1489
+ "observed" if observed_setpoints["count"] is not None else None
1490
+ )
1491
+ metadata["expected_results"] = None
1492
+ metadata["expected_results_source"] = None
1493
+ _add_completed_observed_result_count(metadata)
1494
+ return observed_setpoints
1495
+
1496
+
1497
+ def _add_completed_observed_result_count(metadata):
1498
+ if (
1499
+ metadata.get("expected_results") is None
1500
+ and _run_is_complete(metadata)
1501
+ and metadata.get("result_count") is not None
1502
+ ):
1503
+ metadata["expected_results"] = metadata["result_count"]
1504
+ metadata["expected_results_source"] = "observed"
1505
+
1506
+
1507
+ def _json_dict(value):
1508
+ if not value:
1509
+ return {}
1510
+
1511
+ try:
1512
+ decoded = json.loads(value)
1513
+ except (TypeError, json.JSONDecodeError, RecursionError):
1514
+ return {}
1515
+
1516
+ return decoded if isinstance(decoded, dict) else {}
1517
+
1518
+
1519
+ def _parameter_roles(run_description, parameter_text):
1520
+ measure, sweep, _truncated = _bounded_parameter_roles(
1521
+ run_description,
1522
+ parameter_text,
1523
+ )
1524
+ return measure, sweep
1525
+
1526
+
1527
+ def _bounded_parameter_roles(run_description, parameter_text):
1528
+ dependencies, truncated = _bounded_parameter_dependencies(run_description)
1529
+ measure_parameters: list[str] = []
1530
+ sweep_parameters: list[str] = []
1531
+ measure_seen: set[str] = set()
1532
+ sweep_seen: set[str] = set()
1533
+ all_seen: set[str] = set()
1534
+
1535
+ def add(target, seen, name):
1536
+ nonlocal truncated
1537
+ if name in seen:
1538
+ return
1539
+ if (
1540
+ name not in all_seen
1541
+ and len(all_seen) >= TRUSTED_PRESENTATION_MAX_PARAMETERS
1542
+ ):
1543
+ truncated = True
1544
+ return
1545
+ target.append(name)
1546
+ seen.add(name)
1547
+ all_seen.add(name)
1548
+
1549
+ for parameter, dependents in dependencies.items():
1550
+ add(measure_parameters, measure_seen, parameter)
1551
+ for name in dependents:
1552
+ add(sweep_parameters, sweep_seen, name)
1553
+
1554
+ if isinstance(parameter_text, str):
1555
+ raw_parameters = parameter_text.split(",", TRUSTED_PRESENTATION_MAX_PARAMETERS)
1556
+ if len(raw_parameters) > TRUSTED_PRESENTATION_MAX_PARAMETERS:
1557
+ truncated = True
1558
+ raw_parameters.pop()
1559
+ for raw_parameter in raw_parameters:
1560
+ parameter, was_truncated = _bounded_parameter_identifier(
1561
+ raw_parameter.strip()
1562
+ )
1563
+ truncated = truncated or was_truncated
1564
+ if parameter and parameter not in sweep_seen:
1565
+ add(measure_parameters, measure_seen, parameter)
1566
+ elif parameter_text:
1567
+ truncated = True
1568
+
1569
+ return measure_parameters, sweep_parameters, truncated
1570
+
1571
+
1572
+ def _parameter_dependencies(run_description):
1573
+ dependencies, _truncated = _bounded_parameter_dependencies(run_description)
1574
+ return dependencies
1575
+
1576
+
1577
+ def _bounded_parameter_dependencies(run_description):
1578
+ truncated = False
1579
+ interdependencies = run_description.get("interdependencies_")
1580
+ if not isinstance(interdependencies, dict):
1581
+ interdependencies = {}
1582
+ dependencies = interdependencies.get("dependencies", {})
1583
+ if not isinstance(dependencies, dict) or not dependencies:
1584
+ return _bounded_legacy_dependencies(run_description)
1585
+
1586
+ output: dict[str, list[str]] = {}
1587
+ items = islice(
1588
+ dependencies.items(),
1589
+ TRUSTED_PRESENTATION_MAX_PARAMETERS + 1,
1590
+ )
1591
+ for index, (raw_parameter, setpoints) in enumerate(items):
1592
+ if index >= TRUSTED_PRESENTATION_MAX_PARAMETERS:
1593
+ truncated = True
1594
+ break
1595
+ parameter, name_truncated = _bounded_parameter_identifier(raw_parameter)
1596
+ truncated = truncated or name_truncated
1597
+ if not parameter or not isinstance(setpoints, (list, tuple)):
1598
+ truncated = truncated or bool(setpoints)
1599
+ continue
1600
+ bounded_setpoints = []
1601
+ seen = set()
1602
+ for dependency_index, raw_setpoint in enumerate(
1603
+ islice(
1604
+ setpoints,
1605
+ TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES + 1,
1606
+ )
1607
+ ):
1608
+ if dependency_index >= TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES:
1609
+ truncated = True
1610
+ break
1611
+ setpoint, dependency_truncated = _bounded_parameter_identifier(raw_setpoint)
1612
+ truncated = truncated or dependency_truncated
1613
+ if setpoint and setpoint not in seen:
1614
+ bounded_setpoints.append(setpoint)
1615
+ seen.add(setpoint)
1616
+ if bounded_setpoints:
1617
+ output[parameter] = bounded_setpoints
1618
+ return output, truncated
1619
+
1620
+
1621
+ def _legacy_dependencies(run_description):
1622
+ dependencies, _truncated = _bounded_legacy_dependencies(run_description)
1623
+ return dependencies
1624
+
1625
+
1626
+ def _bounded_legacy_dependencies(run_description):
1627
+ out: dict[str, list[str]] = {}
1628
+ truncated = False
1629
+ interdependencies = run_description.get("interdependencies")
1630
+ paramspecs = (
1631
+ interdependencies.get("paramspecs", [])
1632
+ if isinstance(interdependencies, dict)
1633
+ else []
1634
+ )
1635
+ if not isinstance(paramspecs, (list, tuple)):
1636
+ return out, bool(paramspecs)
1637
+ for index, paramspec in enumerate(
1638
+ islice(paramspecs, TRUSTED_PRESENTATION_MAX_PARAMETERS + 1)
1639
+ ):
1640
+ if index >= TRUSTED_PRESENTATION_MAX_PARAMETERS:
1641
+ truncated = True
1642
+ break
1643
+ if not isinstance(paramspec, dict):
1644
+ truncated = True
1645
+ continue
1646
+ depends_on = paramspec.get("depends_on") or []
1647
+ name, name_truncated = _bounded_parameter_identifier(paramspec.get("name"))
1648
+ truncated = truncated or name_truncated
1649
+ if not name or not isinstance(depends_on, (list, tuple)):
1650
+ truncated = truncated or bool(depends_on)
1651
+ continue
1652
+ bounded_dependencies = []
1653
+ seen = set()
1654
+ for dependency_index, raw_dependency in enumerate(
1655
+ islice(
1656
+ depends_on,
1657
+ TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES + 1,
1658
+ )
1659
+ ):
1660
+ if dependency_index >= TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES:
1661
+ truncated = True
1662
+ break
1663
+ dependency, dependency_truncated = _bounded_parameter_identifier(
1664
+ raw_dependency
1665
+ )
1666
+ truncated = truncated or dependency_truncated
1667
+ if dependency and dependency not in seen:
1668
+ bounded_dependencies.append(dependency)
1669
+ seen.add(dependency)
1670
+ if bounded_dependencies:
1671
+ out[name] = bounded_dependencies
1672
+ return out, truncated
1673
+
1674
+
1675
+ def _bounded_parameter_identifier(value):
1676
+ if not isinstance(value, str):
1677
+ return "", value is not None
1678
+ return bounded_presentation_text(
1679
+ value,
1680
+ limit=TRUSTED_PRESENTATION_MAX_PARAMETER_TEXT_BYTES,
1681
+ )
1682
+
1683
+
1684
+ def _point_shape(run_description, measure_parameters):
1685
+ shape, _truncated = _bounded_point_shape(run_description, measure_parameters)
1686
+ return shape
1687
+
1688
+
1689
+ def _bounded_point_shape(run_description, measure_parameters):
1690
+ shapes = run_description.get("shapes")
1691
+ if not isinstance(shapes, dict):
1692
+ return None, False
1693
+
1694
+ best_shape = None
1695
+ best_size = 0
1696
+ truncated = False
1697
+ for parameter in measure_parameters:
1698
+ shape = shapes.get(parameter)
1699
+ if isinstance(shape, list) and shape:
1700
+ dimensions = list(islice(shape, MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS + 1))
1701
+ if len(dimensions) > MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS:
1702
+ truncated = True
1703
+ continue
1704
+ try:
1705
+ point_shape = [int(size) for size in dimensions]
1706
+ except (TypeError, ValueError):
1707
+ continue
1708
+
1709
+ size = _shape_size(point_shape) or 0
1710
+ if size > best_size:
1711
+ best_shape = point_shape
1712
+ best_size = size
1713
+
1714
+ return best_shape, truncated
1715
+
1716
+
1717
+ def _expected_results_from_shapes(run_description, measure_parameters):
1718
+ expected, _truncated = _bounded_expected_results_from_shapes(
1719
+ run_description,
1720
+ measure_parameters,
1721
+ )
1722
+ return expected
1723
+
1724
+
1725
+ def _bounded_expected_results_from_shapes(run_description, measure_parameters):
1726
+ shapes = run_description.get("shapes")
1727
+ if not isinstance(shapes, dict) or not measure_parameters:
1728
+ return None, False
1729
+
1730
+ sizes = {}
1731
+ truncated = False
1732
+ for index, parameter in enumerate(
1733
+ islice(measure_parameters, TRUSTED_PRESENTATION_MAX_PARAMETERS + 1)
1734
+ ):
1735
+ if index >= TRUSTED_PRESENTATION_MAX_PARAMETERS:
1736
+ return None, True
1737
+ shape = shapes.get(parameter)
1738
+ if not isinstance(shape, list) or not shape:
1739
+ return None, truncated
1740
+
1741
+ dimensions = list(islice(shape, MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS + 1))
1742
+ if len(dimensions) > MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS:
1743
+ truncated = True
1744
+ return None, truncated
1745
+
1746
+ size = _shape_size(dimensions)
1747
+ if size is None:
1748
+ return None, truncated
1749
+ sizes[parameter] = size
1750
+
1751
+ interdependencies = run_description.get("interdependencies_")
1752
+ inferences = (
1753
+ interdependencies.get("inferences", {})
1754
+ if isinstance(interdependencies, dict) else {}
1755
+ )
1756
+ if not isinstance(inferences, dict):
1757
+ return None, truncated
1758
+ if len(inferences) > TRUSTED_PRESENTATION_MAX_PARAMETERS:
1759
+ return None, True
1760
+ if not inferences:
1761
+ return (sum(sizes.values()) if sizes else None), truncated
1762
+
1763
+ # Current QCoDeS writes each top-level parameter tree separately. An
1764
+ # inferred channel is stored with its basis, rather than adding another
1765
+ # row. Multiple basis roots still contribute one tree each. Only infer a
1766
+ # planned row count when the complete bounded graph lies among measured
1767
+ # channels and its linked shape sizes agree; setpoint inference and other
1768
+ # ambiguous declarations retain an unknown count.
1769
+ inferred = set()
1770
+ followers: dict[str, set[str]] = {name: set() for name in sizes}
1771
+ remaining_bases = {name: 0 for name in sizes}
1772
+ for index, (name, bases) in enumerate(
1773
+ islice(inferences.items(), TRUSTED_PRESENTATION_MAX_PARAMETERS + 1)
1774
+ ):
1775
+ if index >= TRUSTED_PRESENTATION_MAX_PARAMETERS:
1776
+ return None, True
1777
+ if name not in sizes or not isinstance(bases, (list, tuple)) or not bases:
1778
+ return None, truncated
1779
+ inferred.add(name)
1780
+ for basis_index, basis in enumerate(
1781
+ islice(bases, TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES + 1)
1782
+ ):
1783
+ if basis_index >= TRUSTED_PRESENTATION_MAX_PARAMETER_DEPENDENCIES:
1784
+ return None, True
1785
+ if not isinstance(basis, str) or basis not in sizes:
1786
+ return None, truncated
1787
+ if sizes[name] != sizes[basis]:
1788
+ return None, truncated
1789
+ if name not in followers[basis]:
1790
+ followers[basis].add(name)
1791
+ remaining_bases[name] += 1
1792
+
1793
+ ready = [name for name, count in remaining_bases.items() if count == 0]
1794
+ visited = 0
1795
+ while ready:
1796
+ name = ready.pop()
1797
+ visited += 1
1798
+ for follower in followers[name]:
1799
+ remaining_bases[follower] -= 1
1800
+ if remaining_bases[follower] == 0:
1801
+ ready.append(follower)
1802
+ if visited != len(sizes):
1803
+ return None, truncated
1804
+ roots = sizes.keys() - inferred
1805
+ return (sum(sizes[name] for name in roots) if roots else None), truncated
1806
+
1807
+
1808
+ def _shape_size(shape):
1809
+ if not shape:
1810
+ return None
1811
+
1812
+ try:
1813
+ dimensions = list(islice(iter(shape), MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS + 1))
1814
+ if len(dimensions) > MAX_RUN_DESCRIPTION_SHAPE_DIMENSIONS:
1815
+ return None
1816
+ return math.prod(int(size) for size in dimensions)
1817
+ except (TypeError, ValueError):
1818
+ return None
1819
+
1820
+
1821
+ def _database_modified_timestamp(cursor):
1822
+ connection = cursor.connection
1823
+ if hasattr(connection, "_qplot_source_activity_timestamp"):
1824
+ return connection._qplot_source_activity_timestamp
1825
+ try:
1826
+ cursor.execute("PRAGMA database_list")
1827
+ databases = cursor.fetchall()
1828
+ except Exception as error:
1829
+ if _sql_was_interrupted(error):
1830
+ raise
1831
+ return None
1832
+
1833
+ for database in databases:
1834
+ if len(database) < 3 or database[1] != "main" or not database[2]:
1835
+ continue
1836
+ return source_activity_timestamp(database[2])
1837
+
1838
+ return None
1839
+
1840
+
1841
+ def _existing_run_columns(cursor, column_names):
1842
+ try:
1843
+ cursor.execute("PRAGMA table_info(runs)")
1844
+ columns = {row[1] for row in cursor.fetchall()}
1845
+ except Exception as error:
1846
+ if _sql_was_interrupted(error):
1847
+ raise
1848
+ return []
1849
+
1850
+ return [column for column in column_names if column in columns]
1851
+
1852
+
1853
+ def _is_keyboard_interrupt(value):
1854
+ return bool(value and "KeyboardInterrupt" in str(value))
1855
+
1856
+
1857
+ def _run_is_complete(metadata):
1858
+ return bool(metadata.get("completed_timestamp") or metadata.get("is_completed"))
1859
+
1860
+
1861
+ def _point_shape_from_result_table(
1862
+ cursor,
1863
+ table_name,
1864
+ sweep_parameters,
1865
+ measure_parameters=None,
1866
+ result_count=None,
1867
+ ):
1868
+ shape = _setpoint_shape_from_result_table(cursor, table_name, sweep_parameters)
1869
+ return _point_shape_from_setpoint_shape(shape, measure_parameters, result_count)
1870
+
1871
+
1872
+ def _point_shape_from_setpoint_shape(shape, measure_parameters=None, result_count=None):
1873
+ if not shape:
1874
+ return None
1875
+
1876
+ shape_size = _shape_size(shape)
1877
+ measure_count = len(measure_parameters or [])
1878
+ if shape_size and result_count and measure_count > 1:
1879
+ try:
1880
+ result_count = int(result_count)
1881
+ except (TypeError, ValueError):
1882
+ result_count = None
1883
+
1884
+ if (
1885
+ result_count
1886
+ and result_count % shape_size == 0
1887
+ and result_count // shape_size == measure_count
1888
+ ):
1889
+ shape = shape + [measure_count]
1890
+
1891
+ return shape
1892
+
1893
+
1894
+ def _setpoint_shape_from_result_table(cursor, table_name, sweep_parameters):
1895
+ if not table_name or not sweep_parameters:
1896
+ return None
1897
+
1898
+ quoted_table_name = _sqlite_identifier(table_name)
1899
+ columns = _result_table_column_types(cursor, quoted_table_name)
1900
+ if not columns or any(parameter not in columns for parameter in sweep_parameters):
1901
+ return None
1902
+
1903
+ observation = _setpoint_observation(
1904
+ cursor,
1905
+ quoted_table_name,
1906
+ sweep_parameters,
1907
+ columns,
1908
+ )
1909
+ return observation["shape"]
1910
+
1911
+
1912
+ def _read_setpoint_count(cursor, table_name, sweep_parameters):
1913
+ if not table_name or not sweep_parameters:
1914
+ return None
1915
+
1916
+ quoted_table_name = _sqlite_identifier(table_name)
1917
+ columns = _result_table_column_types(cursor, quoted_table_name)
1918
+ if not columns or any(parameter not in columns for parameter in sweep_parameters):
1919
+ return None
1920
+
1921
+ if any(columns[parameter] == "array" for parameter in sweep_parameters):
1922
+ return None
1923
+
1924
+ return _distinct_setpoint_count(
1925
+ cursor,
1926
+ quoted_table_name,
1927
+ sweep_parameters,
1928
+ )
1929
+
1930
+
1931
+ def _run_setpoint_observation(
1932
+ cursor,
1933
+ table_name,
1934
+ run_description,
1935
+ measure_parameters,
1936
+ sweep_parameters,
1937
+ ):
1938
+ """Return a safe global shape and the largest per-dependent point count."""
1939
+ empty = {"shape": None, "count": None}
1940
+ if not table_name:
1941
+ return empty
1942
+
1943
+ quoted_table_name = _sqlite_identifier(table_name)
1944
+ columns = _result_table_column_types(cursor, quoted_table_name)
1945
+ if not columns:
1946
+ return empty
1947
+
1948
+ # An array cell holds an acquisition record with an unknown number of
1949
+ # samples. DISTINCT counts serialized records, not logical data points.
1950
+ # This also applies when an array dependent has scalar setpoints, or when
1951
+ # scalar and array dependent trees share a run. Keep planned shapes in the
1952
+ # caller, but do not invent a global observed shape/count from these rows.
1953
+ if any(
1954
+ columns.get(parameter) == "array"
1955
+ for parameter in (*measure_parameters, *sweep_parameters)
1956
+ ):
1957
+ return empty
1958
+
1959
+ dependencies = _parameter_dependencies(run_description)
1960
+ observations = []
1961
+ if dependencies:
1962
+ for parameter in measure_parameters:
1963
+ setpoints = dependencies.get(parameter)
1964
+ if (
1965
+ not setpoints
1966
+ or parameter not in columns
1967
+ or any(setpoint not in columns for setpoint in setpoints)
1968
+ ):
1969
+ continue
1970
+ observation = _setpoint_observation(
1971
+ cursor,
1972
+ quoted_table_name,
1973
+ setpoints,
1974
+ columns,
1975
+ dependent_parameter=parameter,
1976
+ )
1977
+ if observation["count"] is not None:
1978
+ observations.append((tuple(setpoints), observation))
1979
+ elif sweep_parameters and all(
1980
+ parameter in columns for parameter in sweep_parameters
1981
+ ):
1982
+ observation = _setpoint_observation(
1983
+ cursor,
1984
+ quoted_table_name,
1985
+ sweep_parameters,
1986
+ columns,
1987
+ )
1988
+ if observation["count"] is not None:
1989
+ observations.append((tuple(sweep_parameters), observation))
1990
+
1991
+ if not observations:
1992
+ return empty
1993
+
1994
+ observed_count = max(observation["count"] for _, observation in observations)
1995
+ dependency_sets = {setpoints for setpoints, _ in observations}
1996
+ observed_shapes = [observation["shape"] for _, observation in observations]
1997
+ shared_shape = (
1998
+ observed_shapes[0]
1999
+ if (
2000
+ len(dependency_sets) == 1
2001
+ and observed_shapes[0] is not None
2002
+ and all(shape == observed_shapes[0] for shape in observed_shapes)
2003
+ )
2004
+ else None
2005
+ )
2006
+ return {"shape": shared_shape, "count": observed_count}
2007
+
2008
+
2009
+ def _setpoint_observation(
2010
+ cursor,
2011
+ quoted_table_name,
2012
+ sweep_parameters,
2013
+ columns,
2014
+ dependent_parameter=None,
2015
+ ):
2016
+ empty = {"shape": None, "count": None}
2017
+ required_columns = list(sweep_parameters)
2018
+ if dependent_parameter is not None:
2019
+ required_columns.append(dependent_parameter)
2020
+ if not required_columns or any(
2021
+ column not in columns for column in required_columns
2022
+ ):
2023
+ return empty
2024
+
2025
+ if any(columns[column] == "array" for column in required_columns):
2026
+ return empty
2027
+
2028
+ observed_count = _distinct_setpoint_count(
2029
+ cursor,
2030
+ quoted_table_name,
2031
+ sweep_parameters,
2032
+ dependent_parameter=dependent_parameter,
2033
+ )
2034
+ if not observed_count:
2035
+ return empty
2036
+
2037
+ conditions = _setpoint_not_null_conditions(
2038
+ sweep_parameters,
2039
+ dependent_parameter=dependent_parameter,
2040
+ )
2041
+ distinct_counts = ", ".join(
2042
+ f"COUNT(DISTINCT {_sqlite_identifier(parameter)})"
2043
+ for parameter in sweep_parameters
2044
+ )
2045
+ try:
2046
+ cursor.execute(f"""
2047
+ SELECT {distinct_counts}
2048
+ FROM {quoted_table_name}
2049
+ WHERE {conditions}
2050
+ """)
2051
+ shape = [int(count) for count in cursor.fetchone()]
2052
+ except Exception as error:
2053
+ if _sql_was_interrupted(error):
2054
+ raise
2055
+ return {"shape": None, "count": observed_count}
2056
+
2057
+ if any(count <= 0 for count in shape) or _shape_size(shape) != observed_count:
2058
+ return {"shape": None, "count": observed_count}
2059
+ return {"shape": shape, "count": observed_count}
2060
+
2061
+
2062
+ def _distinct_setpoint_count(
2063
+ cursor,
2064
+ quoted_table_name,
2065
+ sweep_parameters,
2066
+ dependent_parameter=None,
2067
+ ):
2068
+ quoted_columns = ", ".join(
2069
+ _sqlite_identifier(parameter) for parameter in sweep_parameters
2070
+ )
2071
+ conditions = _setpoint_not_null_conditions(
2072
+ sweep_parameters,
2073
+ dependent_parameter=dependent_parameter,
2074
+ )
2075
+ try:
2076
+ cursor.execute(f"""
2077
+ SELECT COUNT(*)
2078
+ FROM (
2079
+ SELECT DISTINCT {quoted_columns}
2080
+ FROM {quoted_table_name}
2081
+ WHERE {conditions}
2082
+ ) AS qplot_distinct_setpoints
2083
+ """)
2084
+ count = int(cursor.fetchone()[0])
2085
+ except Exception as error:
2086
+ if _sql_was_interrupted(error):
2087
+ raise
2088
+ return None
2089
+ return count if count > 0 else None
2090
+
2091
+
2092
+ def _setpoint_not_null_conditions(sweep_parameters, dependent_parameter=None):
2093
+ parameters = list(sweep_parameters)
2094
+ if dependent_parameter is not None:
2095
+ parameters.append(dependent_parameter)
2096
+ return " AND ".join(
2097
+ f"{_sqlite_identifier(parameter)} IS NOT NULL" for parameter in parameters
2098
+ )
2099
+
2100
+
2101
+ def _result_table_column_types(cursor, quoted_table_name):
2102
+ """Read current QCoDeS storage types without fetching array payloads."""
2103
+ try:
2104
+ cursor.execute(f"PRAGMA table_info({quoted_table_name})")
2105
+ return {row[1]: str(row[2]).lower() for row in cursor.fetchall()}
2106
+ except Exception as error:
2107
+ if _sql_was_interrupted(error):
2108
+ raise
2109
+ return {}
2110
+
2111
+
2112
+ def _result_count(cursor, table_name):
2113
+ if not table_name:
2114
+ return None
2115
+
2116
+ try:
2117
+ cursor.execute(f"SELECT COUNT(*) FROM {_sqlite_identifier(table_name)}")
2118
+ return cursor.fetchone()[0]
2119
+ except Exception as error:
2120
+ if _sql_was_interrupted(error):
2121
+ raise
2122
+ return None
2123
+
2124
+
2125
+ def _sqlite_identifier(name):
2126
+ return f'"{str(name).replace(chr(34), chr(34) * 2)}"'
2127
+
2128
+
2129
+ def _table_storage_bytes(cursor, table_name, result_count=None):
2130
+ if not table_name:
2131
+ return _StorageSize(None, "unavailable")
2132
+
2133
+ try:
2134
+ cursor.execute("SELECT SUM(pgsize) FROM dbstat WHERE name = ?", (table_name,))
2135
+ row = cursor.fetchone()
2136
+ value = row[0] if row else None
2137
+ if value is not None:
2138
+ return _StorageSize(int(value), "exact")
2139
+ except Exception as err:
2140
+ if _sql_was_interrupted(err):
2141
+ raise
2142
+
2143
+ estimated_bytes = _estimated_table_storage_bytes(
2144
+ cursor,
2145
+ table_name,
2146
+ result_count=result_count,
2147
+ )
2148
+ if estimated_bytes is None:
2149
+ return _StorageSize(None, "unavailable")
2150
+ return _StorageSize(estimated_bytes, "estimated")
2151
+
2152
+
2153
+ def _sql_was_interrupted(error):
2154
+ return "interrupted" in str(error).lower()
2155
+
2156
+
2157
+ def _run_storage_tables(cursor, run_ids):
2158
+ if not run_ids:
2159
+ return {}
2160
+
2161
+ rows = {}
2162
+ chunk_size = 500
2163
+ for offset in range(0, len(run_ids), chunk_size):
2164
+ batch = run_ids[offset : offset + chunk_size]
2165
+ placeholders = ", ".join("?" for _ in batch)
2166
+ try:
2167
+ cursor.execute(
2168
+ f"""
2169
+ SELECT run_id, guid, result_table_name
2170
+ FROM runs
2171
+ WHERE run_id IN ({placeholders})
2172
+ """,
2173
+ tuple(batch),
2174
+ )
2175
+ except Exception as error:
2176
+ if _sql_was_interrupted(error):
2177
+ raise
2178
+ continue
2179
+
2180
+ for run_id, guid, table_name in cursor.fetchall():
2181
+ rows[run_id] = {
2182
+ "guid": guid,
2183
+ "result_table_name": table_name,
2184
+ }
2185
+
2186
+ return rows
2187
+
2188
+
2189
+ def _table_storage_bytes_by_name(cursor, table_names):
2190
+ table_names = sorted({name for name in table_names if name})
2191
+ if not table_names:
2192
+ return {}
2193
+
2194
+ placeholders = ", ".join("?" for _ in table_names)
2195
+ try:
2196
+ cursor.execute(
2197
+ f"""
2198
+ SELECT name, SUM(pgsize)
2199
+ FROM dbstat
2200
+ WHERE name IN ({placeholders})
2201
+ GROUP BY name
2202
+ """,
2203
+ tuple(table_names),
2204
+ )
2205
+ except Exception as err:
2206
+ if _sql_was_interrupted(err):
2207
+ raise
2208
+ return {}
2209
+
2210
+ sizes = {}
2211
+ for name, value in cursor.fetchall():
2212
+ if name not in table_names or value is None:
2213
+ continue
2214
+ try:
2215
+ sizes[name] = int(value)
2216
+ except (TypeError, ValueError):
2217
+ pass
2218
+
2219
+ return sizes
2220
+
2221
+
2222
+ def _estimated_table_storage_bytes(cursor, table_name, result_count=None):
2223
+ if not table_name:
2224
+ return None
2225
+
2226
+ quoted_table_name = _sqlite_identifier(table_name)
2227
+ try:
2228
+ cursor.execute(f"PRAGMA table_info({quoted_table_name})")
2229
+ columns = cursor.fetchall()
2230
+ except Exception as error:
2231
+ if _sql_was_interrupted(error):
2232
+ raise
2233
+ return None
2234
+
2235
+ if not columns:
2236
+ return None
2237
+
2238
+ try:
2239
+ row_count = int(result_count)
2240
+ except (TypeError, ValueError):
2241
+ row_count = None
2242
+
2243
+ if row_count is None:
2244
+ row_count = _result_count(cursor, table_name)
2245
+
2246
+ if row_count is None:
2247
+ return None
2248
+
2249
+ return max(0, row_count) * _estimated_table_row_bytes(columns)
2250
+
2251
+
2252
+ def _estimated_table_row_bytes(columns):
2253
+ row_bytes = len(columns) + 2
2254
+ for column in columns:
2255
+ column_type = str(column[2] or "").upper()
2256
+ if any(
2257
+ type_name in column_type
2258
+ for type_name in ("INT", "REAL", "FLOA", "DOUB", "NUM")
2259
+ ):
2260
+ row_bytes += 8
2261
+ else:
2262
+ row_bytes += 32
2263
+ return row_bytes
2264
+
2265
+
2266
+ def get_run_status(
2267
+ guid,
2268
+ database_path=None,
2269
+ include_storage_bytes=True,
2270
+ cancelled_callback=None,
2271
+ connection_callback=None,
2272
+ expected_database_identity=None,
2273
+ deadline=None,
2274
+ ):
2275
+ """
2276
+ Returns completion and result count information for one run.
2277
+
2278
+ """
2279
+ conn = _read_only_connection(
2280
+ database_path or get_DB_location(),
2281
+ expected_database_identity,
2282
+ cancelled_callback,
2283
+ deadline,
2284
+ )
2285
+ try:
2286
+ _notify_connection(connection_callback, conn)
2287
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
2288
+ cursor = conn.cursor()
2289
+ optional_columns = _existing_run_columns(cursor, ["measurement_exception"])
2290
+ optional_select = "".join(
2291
+ f", {_sqlite_identifier(column)}" for column in optional_columns
2292
+ )
2293
+
2294
+ cursor.execute(
2295
+ f"""
2296
+ SELECT
2297
+ run_timestamp,
2298
+ completed_timestamp,
2299
+ is_completed,
2300
+ result_table_name,
2301
+ run_description,
2302
+ parameters
2303
+ {optional_select}
2304
+ FROM runs
2305
+ WHERE guid=?
2306
+ LIMIT 1
2307
+ """,
2308
+ (guid,),
2309
+ )
2310
+ value = cursor.fetchone()
2311
+ if value is None:
2312
+ _raise_if_read_aborted(cancelled_callback, deadline)
2313
+ return {}
2314
+
2315
+ status = {
2316
+ "run_timestamp": value[0],
2317
+ "completed_timestamp": value[1],
2318
+ "is_completed": value[2],
2319
+ "result_count": _result_count(cursor, value[3]),
2320
+ "database_modified_timestamp": _database_modified_timestamp(cursor),
2321
+ }
2322
+ if include_storage_bytes:
2323
+ _add_storage_size_fields(
2324
+ status,
2325
+ _table_storage_bytes(
2326
+ cursor,
2327
+ value[3],
2328
+ result_count=status["result_count"],
2329
+ ),
2330
+ )
2331
+ for index, column in enumerate(optional_columns, start=6):
2332
+ status[column] = value[index]
2333
+
2334
+ shape_metadata = {
2335
+ "completed_timestamp": value[1],
2336
+ "is_completed": value[2],
2337
+ "result_table_name": value[3],
2338
+ "run_description": value[4],
2339
+ "parameters": value[5],
2340
+ "result_count": status["result_count"],
2341
+ }
2342
+ _add_run_basic_fields(shape_metadata)
2343
+ observed_setpoints = None
2344
+ if not shape_metadata["point_shape"]:
2345
+ observed_setpoints = _add_observed_shape_fields(cursor, shape_metadata)
2346
+ _add_completed_observed_result_count(shape_metadata)
2347
+ for field in (
2348
+ "point_shape",
2349
+ "setpoint_shape",
2350
+ "setpoint_shape_source",
2351
+ "setpoint_count",
2352
+ "setpoint_count_source",
2353
+ "expected_results",
2354
+ "expected_results_source",
2355
+ ):
2356
+ status[field] = shape_metadata.get(field)
2357
+
2358
+ if not bool(value[2]) or _is_keyboard_interrupt(
2359
+ status.get("measurement_exception")
2360
+ ):
2361
+ if observed_setpoints is None:
2362
+ observed_setpoints = _run_setpoint_observation(
2363
+ cursor,
2364
+ value[3],
2365
+ _json_dict(value[4]),
2366
+ shape_metadata["measure_parameters"],
2367
+ shape_metadata["sweep_parameters"],
2368
+ )
2369
+ status["read_setpoint_count"] = observed_setpoints["count"]
2370
+
2371
+ _raise_if_read_aborted(cancelled_callback, deadline)
2372
+ return status
2373
+ except sqlite3.OperationalError as error:
2374
+ _translate_interrupted_read(error, cancelled_callback, deadline)
2375
+ raise
2376
+ finally:
2377
+ try:
2378
+ _notify_connection(connection_callback, None)
2379
+ finally:
2380
+ conn.close()
2381
+
2382
+
2383
+ def has_finished(
2384
+ guid,
2385
+ expected_database_identity=None,
2386
+ cancelled_callback=None,
2387
+ connection_callback=None,
2388
+ deadline=None,
2389
+ ) -> float | None:
2390
+ """
2391
+ Checks if specific run (by guid) has finished running.
2392
+ If the run with guid has finished, returns the completed time.
2393
+ Otherwise returns a NULL value which python interprets as None.
2394
+
2395
+ Parameters
2396
+ ----------
2397
+ guid : str
2398
+ The unique id of the run to look up.
2399
+
2400
+ Returns
2401
+ -------
2402
+ completed_timestamp : float | None
2403
+ The completion timestamp as a Unix-time float. Returns None when the
2404
+ run is present but unfinished, or when no matching run exists.
2405
+
2406
+ """
2407
+ conn = _read_only_connection(
2408
+ get_DB_location(),
2409
+ expected_database_identity,
2410
+ cancelled_callback,
2411
+ deadline,
2412
+ )
2413
+
2414
+ try:
2415
+ _notify_connection(connection_callback, conn)
2416
+ _install_cancel_progress_handler(conn, cancelled_callback, deadline)
2417
+ cursor = conn.cursor()
2418
+
2419
+ cursor.execute(
2420
+ """
2421
+ SELECT
2422
+ completed_timestamp
2423
+ FROM runs
2424
+ WHERE guid=?
2425
+ LIMIT 1
2426
+ """,
2427
+ (guid,),
2428
+ )
2429
+ row = cursor.fetchone()
2430
+ if row is None or row[0] is None:
2431
+ _raise_if_read_aborted(cancelled_callback, deadline)
2432
+ return None
2433
+ _raise_if_read_aborted(cancelled_callback, deadline)
2434
+ return float(row[0])
2435
+ except sqlite3.OperationalError as error:
2436
+ _translate_interrupted_read(error, cancelled_callback, deadline)
2437
+ raise
2438
+ finally:
2439
+ try:
2440
+ _notify_connection(connection_callback, None)
2441
+ finally:
2442
+ conn.close()