|
| 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