Skip to content

Commit b4510b5

Browse files
authored
Merge pull request #462 from toolsforexperiments/astafan8/qcodes-db-overview
Use qcodes.dataset.get_db_overview when available
2 parents 42cd393 + 5a498dc commit b4510b5

4 files changed

Lines changed: 283 additions & 196 deletions

File tree

plottr/apps/inspectr.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -452,7 +452,8 @@ def loadDB(self) -> None:
452452
overview: Optional[Dict[int, Any]] = None
453453
if self.use_fast_sql:
454454
try:
455-
overview = get_db_overview(self.path)
455+
overview = get_db_overview(self.path,
456+
extra_columns=['inspectr_tag'])
456457
except Exception as e:
457458
LOGGER.warning(f"Fast SQL overview failed, falling back to "
458459
f"qcodes API: {e}")

plottr/data/_qcodes_db_overview.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
"""
2+
Local (vendored) copy of the QCoDeS ``get_db_overview`` implementation.
3+
4+
This module is a private fallback used by :mod:`plottr.data.qcodes_db_overview`
5+
for QCoDeS versions that do not yet expose ``qcodes.dataset.get_db_overview``.
6+
Its content is kept as an exact copy of the upstream QCoDeS implementation,
7+
except that the (private) QCoDeS SQLite imports are done lazily inside
8+
:func:`get_db_overview` rather than at module import time.
9+
10+
:func:`get_db_overview` issues a single ``JOIN`` query against the ``runs`` and
11+
``experiments`` tables to collect run metadata (experiment/sample names, time
12+
stamps, record counts, guids, ...) without instantiating a ``DataSet`` object
13+
per run. This avoids the expensive ``experiments()`` + ``data_sets()``
14+
enumeration and makes it possible to list the contents of databases with many
15+
thousands of runs almost instantly.
16+
"""
17+
18+
from __future__ import annotations
19+
20+
import datetime
21+
import json
22+
import logging
23+
from contextlib import closing, nullcontext
24+
from typing import TYPE_CHECKING
25+
26+
from typing_extensions import TypedDict
27+
28+
if TYPE_CHECKING:
29+
from collections.abc import Sequence
30+
from pathlib import Path
31+
32+
from qcodes.dataset.sqlite.connection import AtomicConnection
33+
34+
log = logging.getLogger(__name__)
35+
36+
37+
class RunOverviewDict(TypedDict):
38+
"""
39+
Lightweight overview of a single run.
40+
41+
Contains only cheap-to-query metadata: no snapshot, no data and no full
42+
``DataSet`` object. Extra ad-hoc metadata columns requested via the
43+
``extra_columns`` argument of :func:`get_db_overview` are added to the
44+
dictionary under their column name in addition to the keys documented here.
45+
"""
46+
47+
#: ``run_id`` of the run.
48+
run_id: int
49+
#: Name of the experiment the run belongs to.
50+
experiment: str
51+
#: Sample name of the experiment the run belongs to.
52+
sample: str
53+
#: Name of the run.
54+
name: str
55+
#: Local date the run was started, formatted as ``YYYY-MM-DD`` (empty
56+
#: string if unknown).
57+
started_date: str
58+
#: Local time the run was started, formatted as ``HH:MM:SS`` (empty string
59+
#: if unknown).
60+
started_time: str
61+
#: Local date the run was completed, formatted as ``YYYY-MM-DD`` (empty
62+
#: string if the run has not completed).
63+
completed_date: str
64+
#: Local time the run was completed, formatted as ``HH:MM:SS`` (empty
65+
#: string if the run has not completed).
66+
completed_time: str
67+
#: Best-effort number of data points in the run, see :func:`get_db_overview`.
68+
records: int
69+
#: guid of the run.
70+
guid: str
71+
72+
73+
def _format_timestamp(ts: float | None) -> tuple[str, str]:
74+
"""
75+
Convert a unix timestamp into ``(date, time)`` strings in local time.
76+
77+
Returns a pair of empty strings if the timestamp is missing or invalid.
78+
"""
79+
if ts is None or ts == 0:
80+
return "", ""
81+
try:
82+
dt = datetime.datetime.fromtimestamp(ts)
83+
except (OSError, ValueError, OverflowError):
84+
return "", ""
85+
return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M:%S")
86+
87+
88+
def _records_from_run_description(run_description_json: str | None) -> int:
89+
"""
90+
Extract a data-point count from the ``shapes`` field of a run description.
91+
92+
A QCoDeS run description may contain a ``shapes`` mapping from dependent
93+
parameter names to their shape tuples. The count returned here is the sum
94+
over all dependent parameters of the product of their shape dimensions.
95+
Returns ``0`` if the run description is missing, cannot be parsed or does
96+
not contain shape information.
97+
"""
98+
if not run_description_json:
99+
return 0
100+
try:
101+
desc = json.loads(run_description_json)
102+
except (json.JSONDecodeError, TypeError):
103+
return 0
104+
shapes = desc.get("shapes") if isinstance(desc, dict) else None
105+
if not shapes:
106+
return 0
107+
total = 0
108+
for shape in shapes.values():
109+
if isinstance(shape, (list, tuple)) and len(shape) > 0:
110+
n = 1
111+
for dim in shape:
112+
n *= dim
113+
total += n
114+
return total
115+
116+
117+
def get_db_overview(
118+
path_to_db: str | Path | None = None,
119+
*,
120+
conn: AtomicConnection | None = None,
121+
start_run_id: int = 0,
122+
extra_columns: Sequence[str] | None = None,
123+
) -> dict[int, RunOverviewDict]:
124+
"""
125+
Get a lightweight overview of the runs in a QCoDeS database.
126+
127+
This uses a single SQL ``JOIN`` query on the ``runs`` and ``experiments``
128+
tables to fetch run metadata, avoiding the much more expensive
129+
``experiments()`` + ``data_sets()`` enumeration that instantiates a
130+
``DataSet`` object per run. It is therefore well suited for listing the
131+
contents of databases with many runs. The (potentially large) snapshot of
132+
each run is deliberately not read, as it would slow down building the
133+
overview significantly.
134+
135+
The reported number of ``records`` is a best-effort estimate of the number
136+
of data points in a run and may be less precise than
137+
``DataSet.number_of_results``:
138+
139+
* For completed runs the shape information stored in the run description is
140+
preferred (it is the authoritative final count), falling back to the
141+
number of rows in the results table.
142+
* For runs that are still in progress the number of rows in the results
143+
table is preferred (it grows as data is added), falling back to the run
144+
description shapes.
145+
* If neither is available the count is reported as ``0`` (unknown).
146+
147+
Only one of ``path_to_db`` and ``conn`` should be supplied. If a
148+
``path_to_db`` is given the database is opened in read-only mode and the
149+
connection is closed again before returning.
150+
151+
Args:
152+
path_to_db: Path to the database file. Opened read-only if given.
153+
conn: An existing connection to use instead of ``path_to_db``. It is
154+
left open by this function.
155+
start_run_id: Only return runs whose ``run_id`` is strictly greater
156+
than this value. Use ``0`` (the default) to get all runs, or pass
157+
the last known ``run_id`` to fetch only newly added runs.
158+
extra_columns: Names of additional ``runs``-table columns to include in
159+
each :class:`RunOverviewDict`. Columns that do not exist in the
160+
``runs`` table of the given database are silently skipped. This is
161+
useful for reading ad-hoc metadata columns added via
162+
``DataSet.add_metadata``.
163+
164+
Returns:
165+
A dictionary mapping ``run_id`` to a :class:`RunOverviewDict`.
166+
167+
"""
168+
# These are (technically private) QCoDeS SQLite helpers; import them lazily
169+
# so that importing this module does not depend on QCoDeS internals.
170+
import sqlite3
171+
172+
from qcodes.dataset.sqlite.database import conn_from_dbpath_or_conn
173+
from qcodes.dataset.sqlite.query_helpers import is_column_in_table
174+
175+
overview: dict[int, RunOverviewDict] = {}
176+
177+
created_conn = conn is None
178+
connection = conn_from_dbpath_or_conn(
179+
conn=conn, path_to_db=path_to_db, read_only=True
180+
)
181+
manager = closing(connection) if created_conn else nullcontext(connection)
182+
183+
with manager as c:
184+
valid_extra_columns = [
185+
col for col in (extra_columns or []) if is_column_in_table(c, "runs", col)
186+
]
187+
extra_select = "".join(f", r.{col}" for col in valid_extra_columns)
188+
189+
# ``run_description`` is queried to derive the record count for
190+
# completed runs; the (potentially large) snapshot is deliberately
191+
# excluded.
192+
query = f"""
193+
SELECT r.run_id, e.name, e.sample_name, r.name,
194+
r.run_timestamp, r.completed_timestamp,
195+
r.guid, r.result_table_name,
196+
r.run_description{extra_select}
197+
FROM runs r
198+
JOIN experiments e ON r.exp_id = e.exp_id
199+
WHERE r.run_id > ?
200+
ORDER BY r.run_id
201+
"""
202+
203+
try:
204+
rows = c.execute(query, (start_run_id,)).fetchall()
205+
except sqlite3.Error as e:
206+
log.warning("Could not query database overview: %s", e)
207+
return overview
208+
209+
# ``result_counter`` in the runs table is the run's ordinal within its
210+
# experiment, not a data-point count, so it is not usable here. For the
211+
# ``array`` paramtype a single INSERT can also contain many data points.
212+
# The real number of data points is therefore the row count of the
213+
# results table, queried separately.
214+
result_tables = {row[7] for row in rows if row[7]}
215+
row_counts: dict[str, int] = {}
216+
for table in result_tables:
217+
try:
218+
(count,) = c.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()
219+
except sqlite3.Error:
220+
continue # results table may not exist (yet)
221+
row_counts[table] = count
222+
223+
n_fixed = 9 # number of columns selected before ``extra_columns``
224+
for row in rows:
225+
run_id = row[0]
226+
started_date, started_time = _format_timestamp(row[4])
227+
completed_date, completed_time = _format_timestamp(row[5])
228+
result_table = row[7] or ""
229+
is_completed = row[5] is not None and row[5] != 0
230+
231+
# The record count is a best-effort data-point count. For completed
232+
# runs the run-description shapes are the authoritative final count;
233+
# for in-progress runs the live results-table row count is preferred
234+
# as it grows while data is added. ``0`` means "unknown".
235+
if is_completed:
236+
records = _records_from_run_description(row[8])
237+
if records == 0:
238+
records = row_counts.get(result_table, 0)
239+
else:
240+
records = row_counts.get(result_table, 0)
241+
if records == 0:
242+
records = _records_from_run_description(row[8])
243+
244+
entry: RunOverviewDict = {
245+
"run_id": run_id,
246+
"experiment": row[1] or "",
247+
"sample": row[2] or "",
248+
"name": row[3] or "",
249+
"started_date": started_date,
250+
"started_time": started_time,
251+
"completed_date": completed_date,
252+
"completed_time": completed_time,
253+
"records": records,
254+
"guid": row[6] or "",
255+
}
256+
if valid_extra_columns:
257+
extra = {
258+
col: row[n_fixed + i] for i, col in enumerate(valid_extra_columns)
259+
}
260+
# The keys of ``extra`` are only known at runtime (they are the
261+
# user-supplied ``extra_columns``), so they cannot be part of
262+
# the closed ``RunOverviewDict`` definition.
263+
entry.update(extra) # type: ignore[typeddict-item]
264+
265+
overview[run_id] = entry
266+
267+
return overview

0 commit comments

Comments
 (0)