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,13 @@
1
+ from .general import (
2
+ data2matrix,
3
+ unpack_param,
4
+ )
5
+ from .worker import (
6
+ loader,
7
+ )
8
+
9
+ __all__ = [
10
+ "data2matrix",
11
+ "unpack_param",
12
+ "loader",
13
+ ]
@@ -0,0 +1,114 @@
1
+ """Bounded finite bin means with a rare exact fallback for risky sums."""
2
+
3
+ from fractions import Fraction
4
+
5
+ import numpy as np
6
+
7
+
8
+ class FiniteBinMeans:
9
+ """Keep only one count/sum per bin, never the original sample history."""
10
+
11
+ def __init__(self, shape, check_cancelled=lambda: None):
12
+ self.sums = np.zeros(shape, dtype=float)
13
+ self.counts = np.zeros(shape, dtype=np.int64)
14
+ self.exact = {}
15
+ self.check_cancelled = check_cancelled
16
+ self._largest = 0.
17
+ self._samples = 0
18
+ self._positive = np.zeros(shape, dtype=bool)
19
+ self._negative = np.zeros(shape, dtype=bool)
20
+ self._integer = np.zeros(shape, dtype=bool)
21
+ self._absolute_sums = np.zeros(shape, dtype=float)
22
+
23
+ def add(self, indices, values):
24
+ self.check_cancelled()
25
+ original = np.asarray(values).ravel()
26
+ values = original.astype(float)
27
+ if not values.size:
28
+ return
29
+ targets = np.ravel_multi_index(indices, self.sums.shape)
30
+ np.logical_or.at(self._positive.ravel(), targets, values > 0)
31
+ np.logical_or.at(self._negative.ravel(), targets, values < 0)
32
+ mixed = self._positive & self._negative
33
+ # Original object cells may retain clipped decimal bounds or mixed
34
+ # integer records. Large integer cells also need exact replay even
35
+ # when all samples have the same sign: conversion or summation can
36
+ # round before their mean is formed.
37
+ sensitive = np.zeros(self.sums.shape, dtype=bool)
38
+ if original.dtype.kind == "O":
39
+ sensitive.ravel()[targets] = True
40
+ elif original.dtype.kind in "iu":
41
+ self._integer.ravel()[targets] = True
42
+ # Several individually exact integers can have an inexact total.
43
+ # Retain the accumulated absolute bound and integer presence across
44
+ # chunks, including a later floating record in the same bin.
45
+ with np.errstate(over="ignore", invalid="ignore"):
46
+ np.add.at(self._absolute_sums.ravel(), targets, np.abs(values))
47
+ sensitive |= self._integer & (self._absolute_sums >= 2**53)
48
+ self._largest = max(self._largest, float(np.max(np.abs(values))))
49
+ self._samples += values.size
50
+ # Leave headroom for rounded additions at the finite sum boundary.
51
+ # This conservative global bound makes the normal path vectorized.
52
+ # It changes only whether we retain sums for recovery, never scaling
53
+ # the values in other bins (which could erase small measurements).
54
+ safe_sample = (np.finfo(float).max / 2) / self._samples
55
+ risk = (bool(self.exact) or self._largest > safe_sample
56
+ or np.any(mixed) or np.any(sensitive))
57
+ previous = self.sums.copy() if risk else None
58
+ with np.errstate(over="ignore", invalid="ignore"):
59
+ np.add.at(self.sums.ravel(), targets, values)
60
+ np.add.at(self.counts.ravel(), targets, 1)
61
+ if risk:
62
+ assert previous is not None
63
+ # Promote before overflow: waiting for a later chunk to overflow
64
+ # would already have rounded away small cancellation residuals.
65
+ largest = np.abs(previous)
66
+ np.maximum.at(largest.ravel(), targets, np.abs(values))
67
+ for index, target in enumerate(np.flatnonzero(
68
+ (largest > safe_sample) | mixed | sensitive)):
69
+ if index % 1024 == 0:
70
+ self.check_cancelled()
71
+ self.exact.setdefault(int(target), Fraction(float(previous.ravel()[target])))
72
+ if self.exact:
73
+ self._add_exact(targets, original)
74
+ self.sums.ravel()[list(self.exact)] = 0.
75
+ self.check_cancelled()
76
+
77
+ def _add_exact(self, targets, values):
78
+ selected = np.flatnonzero(np.isin(targets, list(self.exact)))
79
+ for index, position in enumerate(selected):
80
+ if index % 1024 == 0:
81
+ self.check_cancelled()
82
+ value = values[position]
83
+ if isinstance(value, np.generic):
84
+ value = value.item()
85
+ self.exact[int(targets[position])] += Fraction(value)
86
+
87
+ def begin_exact_replay(self):
88
+ """Discard rounded history for promoted bins, retaining their counts."""
89
+ self.check_cancelled()
90
+ for index, target in enumerate(self.exact):
91
+ if index % 1024 == 0:
92
+ self.check_cancelled()
93
+ self.exact[target] = Fraction()
94
+ return bool(self.exact)
95
+
96
+ def replay(self, indices, values):
97
+ """Revisit original samples once; only promoted totals are changed."""
98
+ self.check_cancelled()
99
+ targets = np.ravel_multi_index(indices, self.sums.shape)
100
+ # Retain original integer/object values instead of converting these
101
+ # selected samples through the ordinary display-sum float dtype.
102
+ self._add_exact(targets, np.asarray(values).ravel())
103
+ self.check_cancelled()
104
+
105
+ def means(self):
106
+ self.check_cancelled()
107
+ result = np.full(self.sums.shape, np.nan)
108
+ np.divide(self.sums, self.counts, out=result, where=self.counts > 0)
109
+ for index, (target, total) in enumerate(self.exact.items()):
110
+ if index % 1024 == 0:
111
+ self.check_cancelled()
112
+ result.ravel()[target] = float(total / int(self.counts.ravel()[target]))
113
+ self.check_cancelled()
114
+ return result
qplot/tools/general.py ADDED
@@ -0,0 +1,134 @@
1
+ from decimal import Decimal, localcontext
2
+ from fractions import Fraction
3
+ from typing import Any
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ from numpy.typing import ArrayLike
8
+ from qcodes import dataset
9
+
10
+ from qplot.tools.sample_statistics import finite_mean
11
+
12
+
13
+ def data2matrix(indep1 : ArrayLike,
14
+ indep2 : ArrayLike,
15
+ depvar : ArrayLike,
16
+ *,
17
+ check_cancelled=lambda: None,
18
+ ):
19
+ """
20
+ Converts 3 numpy.ndarry into a datagrid via pandas.DataFrame.
21
+ This is used for producing a data grid to be passed to a heatmap, also
22
+ handles numpy.Nan values in depvar and duplicates
23
+
24
+ Parameters
25
+ ----------
26
+ indep1 : np.array
27
+ Array to be placed in the pandas.DataFrame index column.
28
+ indep2 : np.array
29
+ Array to be placed in the pandas.DataFrame header row.
30
+ depvar : np.array
31
+ Array to take up the .
32
+
33
+ Returns
34
+ -------
35
+ matrix : pandas.DataFrame
36
+ The data frame containing all 3 inputted arrays as a dataframe.
37
+
38
+ """
39
+ values = np.asarray(depvar)
40
+ if values.dtype.kind in "iuO":
41
+ # A floating pivot mean rounds even a cell containing a single large
42
+ # integer. Retain those cells, including holes; repeated coordinates
43
+ # still mean-average their acquired samples, using exact arithmetic.
44
+ rows, row_indices = np.unique(indep1, return_inverse=True)
45
+ columns, column_indices = np.unique(indep2, return_inverse=True)
46
+ grid = np.full((len(rows), len(columns)), np.nan, dtype=object)
47
+ cells: dict[tuple[int, int], list[Any]] = {}
48
+ for index, (row, column, value) in enumerate(
49
+ zip(row_indices, column_indices, values, strict=True)
50
+ ):
51
+ if index % 1024 == 0:
52
+ check_cancelled()
53
+ if isinstance(value, np.generic):
54
+ value = value.item()
55
+ if not np.isfinite(float(value)):
56
+ continue
57
+ cells.setdefault((row, column), []).append(value)
58
+ for index, (position, samples) in enumerate(cells.items()):
59
+ if index % 1024 == 0:
60
+ check_cancelled()
61
+ if len(samples) == 1:
62
+ grid[position] = samples[0]
63
+ else:
64
+ mean = sum((Fraction(value) for value in samples), Fraction()) / len(samples)
65
+ if mean.denominator == 1:
66
+ grid[position] = int(mean)
67
+ else:
68
+ # Decimal keeps the offset and fractional detail while
69
+ # remaining a numeric CSV cell (Fraction writes "a/b").
70
+ with localcontext() as context:
71
+ context.prec = max(34, len(str(abs(mean.numerator))) + 17)
72
+ grid[position] = Decimal(mean.numerator) / Decimal(mean.denominator)
73
+ check_cancelled()
74
+ return pd.DataFrame(grid, index=rows, columns=columns)
75
+
76
+ # Widen narrow floats before pandas accumulates repeated measured cells.
77
+ # The source array remains untouched for raw export and later operations.
78
+ if values.dtype.kind == "f":
79
+ values = values.astype(np.result_type(values.dtype, np.float64), copy=False)
80
+
81
+ # convert to 3 column dataframe
82
+ df = pd.DataFrame({
83
+ 'indep1': indep1,
84
+ 'indep2': indep2,
85
+ 'depvar': values
86
+ })
87
+ # convert dataframe to grid
88
+ if values.dtype.kind == "f" and (
89
+ np.any(np.abs(values) > np.finfo(values.dtype).max / max(1, values.size))
90
+ or (np.any(values < 0) and np.any(values > 0))):
91
+ # The ordinary vectorized mean is fast, but an intermediate group sum
92
+ # can overflow even when the final mean is finite. Retain failed groups
93
+ # before pivot_table would drop their rows and columns, then repair only
94
+ # those groups using a scaled mean of their original finite samples.
95
+ check_cancelled()
96
+ grouped = df.groupby(['indep1', 'indep2'])['depvar']
97
+ means = grouped.mean()
98
+ check_cancelled()
99
+ mixed = (grouped.min().to_numpy() < 0) & (grouped.max().to_numpy() > 0)
100
+ for group_position in np.flatnonzero(~np.isfinite(means.to_numpy()) | mixed):
101
+ check_cancelled()
102
+ samples = grouped.get_group(means.index[group_position]).to_numpy()
103
+ if np.any(np.isinf(samples)):
104
+ continue
105
+ samples = samples[np.isfinite(samples)]
106
+ if not samples.size:
107
+ continue
108
+ means.iloc[group_position] = finite_mean(samples, check_cancelled=check_cancelled)
109
+ check_cancelled()
110
+ return means.unstack().dropna(how='all', axis=0).dropna(how='all', axis=1)
111
+ matrix = df.pivot_table(index='indep1', columns='indep2', values='depvar', fill_value=np.nan)
112
+ return matrix
113
+
114
+
115
+ def unpack_param(dataset : dataset.data_set.DataSet, paramName : str):
116
+ """
117
+ Gets specified parameter from list all parameters in a dataset.
118
+
119
+ Parameters
120
+ ----------
121
+ dataset : qcodes.dataset.data_set.DataSet
122
+ Dataset to ook through.
123
+ paramName : str
124
+ Name of parameter.
125
+
126
+ Returns
127
+ -------
128
+ ParamSpec : qcodes.dataset.descriptions.param_spec.ParamSpec
129
+ The desired parameter data.
130
+
131
+ """
132
+ for ParamSpec in dataset.get_parameters():
133
+ if ParamSpec.name == paramName:
134
+ return ParamSpec
@@ -0,0 +1,381 @@
1
+ """Coordinate geometry for rectilinear heatmaps.
2
+
3
+ This module deliberately has no Qt dependencies. It defines the coordinate
4
+ contract shared by heatmap rendering and interactions: axis values are pixel
5
+ centres, while the derived edges are pixel boundaries.
6
+ """
7
+
8
+ import math
9
+ import operator
10
+ from bisect import bisect_left, bisect_right
11
+ from collections.abc import Iterable
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+ import numpy.typing as npt
17
+
18
+ _DEFAULT_SINGLETON_SPAN = 1.0
19
+ _DEFAULT_UNIFORM_REL_TOL = 1e-9
20
+
21
+
22
+ def bounded_grid_shape(x_count, y_count, *, max_cells, max_side):
23
+ max_cells = max(1, int(max_cells))
24
+ x_bins = max(1, min(int(x_count), max_side))
25
+ y_bins = max(1, min(int(y_count), max_side))
26
+ if x_bins * y_bins <= max_cells:
27
+ return x_bins, y_bins
28
+ scale = math.sqrt(max_cells / (x_bins * y_bins))
29
+ x_bins = max(1, int(x_bins * scale))
30
+ y_bins = max(1, int(y_bins * scale))
31
+ while x_bins * y_bins > max_cells:
32
+ if x_bins >= y_bins and x_bins > 1:
33
+ x_bins -= 1
34
+ elif y_bins > 1:
35
+ y_bins -= 1
36
+ else:
37
+ break
38
+ return x_bins, y_bins
39
+
40
+
41
+ def spatial_axis_bins(lower, upper, source_count, bin_count):
42
+ if not np.isfinite(lower) or not np.isfinite(upper):
43
+ return np.array([], dtype=float), 0.0, 1.0
44
+ if source_count <= 1 or bin_count <= 1 or lower == upper:
45
+ return np.array([lower], dtype=float), lower, 1.0
46
+ source_step = (upper - lower) / (source_count - 1)
47
+ lower_edge = lower - source_step / 2
48
+ upper_edge = upper + source_step / 2
49
+ bin_width = (upper_edge - lower_edge) / bin_count
50
+ centres = lower_edge + (np.arange(bin_count, dtype=float) + 0.5) * bin_width
51
+ return centres, lower_edge, 1.0 / bin_width
52
+
53
+
54
+ @dataclass(frozen=True, slots=True, init=False)
55
+ class AxisGeometry:
56
+ """Immutable geometry for one strictly increasing heatmap axis.
57
+
58
+ Descending axes are rejected rather than silently reordered because axis
59
+ indices must remain aligned with the corresponding dimension of the data
60
+ grid. A caller that receives descending data must reverse both together.
61
+
62
+ Parameters
63
+ ----------
64
+ centres:
65
+ Finite, strictly increasing cell-centre coordinates.
66
+ singleton_span:
67
+ Width assigned to an axis containing one centre. Callers can supply a
68
+ known neighbouring step; otherwise one coordinate unit is used.
69
+ uniform_rel_tol, uniform_abs_tol:
70
+ Tolerances used only when classifying an axis as uniform.
71
+ """
72
+
73
+ centres: tuple[float, ...]
74
+ edges: tuple[float, ...]
75
+ is_uniform: bool
76
+
77
+ def __init__(
78
+ self,
79
+ centres: Iterable[float],
80
+ *,
81
+ singleton_span: float = _DEFAULT_SINGLETON_SPAN,
82
+ uniform_rel_tol: float = _DEFAULT_UNIFORM_REL_TOL,
83
+ uniform_abs_tol: float = 0.0,
84
+ ) -> None:
85
+ values = tuple(float(value) for value in centres)
86
+ span = float(singleton_span)
87
+ rel_tol = float(uniform_rel_tol)
88
+ abs_tol = float(uniform_abs_tol)
89
+
90
+ if not values:
91
+ raise ValueError("A heatmap axis requires at least one centre.")
92
+ if not all(math.isfinite(value) for value in values):
93
+ raise ValueError("Heatmap axis centres must all be finite.")
94
+ if not math.isfinite(span) or span <= 0.0:
95
+ raise ValueError("singleton_span must be positive and finite.")
96
+ if not math.isfinite(rel_tol) or rel_tol < 0.0:
97
+ raise ValueError("uniform_rel_tol must be non-negative and finite.")
98
+ if not math.isfinite(abs_tol) or abs_tol < 0.0:
99
+ raise ValueError("uniform_abs_tol must be non-negative and finite.")
100
+
101
+ deltas = tuple(
102
+ right - left
103
+ for left, right in zip(values[:-1], values[1:], strict=True)
104
+ )
105
+ if deltas and all(delta < 0.0 for delta in deltas):
106
+ raise ValueError(
107
+ "descending heatmap axes are not supported; reverse both the "
108
+ "axis centres and the corresponding data dimension."
109
+ )
110
+ if any(delta <= 0.0 for delta in deltas):
111
+ raise ValueError("Heatmap axis centres must be strictly increasing.")
112
+
113
+ derived_edges: tuple[float, ...]
114
+ if len(values) == 1:
115
+ half_span = span / 2.0
116
+ derived_edges = (values[0] - half_span, values[0] + half_span)
117
+ else:
118
+ interior_edges = tuple(
119
+ left / 2.0 + right / 2.0
120
+ for left, right in zip(values[:-1], values[1:], strict=True)
121
+ )
122
+ derived_edges = (
123
+ values[0] - (interior_edges[0] - values[0]),
124
+ *interior_edges,
125
+ values[-1] + (values[-1] - interior_edges[-1]),
126
+ )
127
+
128
+ if not all(math.isfinite(edge) for edge in derived_edges):
129
+ raise ValueError("Derived heatmap axis edges must all be finite.")
130
+ if any(
131
+ right <= left
132
+ for left, right in zip(
133
+ derived_edges[:-1],
134
+ derived_edges[1:],
135
+ strict=True,
136
+ )
137
+ ):
138
+ raise ValueError(
139
+ "Heatmap cell edges collapse at floating-point precision."
140
+ )
141
+
142
+ uniform = len(deltas) <= 1 or all(
143
+ math.isclose(
144
+ delta,
145
+ deltas[0],
146
+ rel_tol=rel_tol,
147
+ abs_tol=abs_tol,
148
+ )
149
+ for delta in deltas[1:]
150
+ )
151
+
152
+ object.__setattr__(self, "centres", values)
153
+ object.__setattr__(self, "edges", derived_edges)
154
+ object.__setattr__(self, "is_uniform", uniform)
155
+
156
+ @property
157
+ def count(self) -> int:
158
+ """Number of cells along this axis."""
159
+
160
+ return len(self.centres)
161
+
162
+ @property
163
+ def bounds(self) -> tuple[float, float]:
164
+ """Inclusive outer coordinate bounds of the axis."""
165
+
166
+ return self.edges[0], self.edges[-1]
167
+
168
+ @property
169
+ def span(self) -> float:
170
+ """Positive distance between the outer axis edges."""
171
+
172
+ return self.edges[-1] - self.edges[0]
173
+
174
+ def centre(self, index: int) -> float:
175
+ """Return the recorded centre coordinate of one cell."""
176
+
177
+ return self.centres[self._checked_index(index)]
178
+
179
+ def cell_bounds(self, index: int) -> tuple[float, float]:
180
+ """Return the lower and upper boundary of one cell."""
181
+
182
+ checked_index = self._checked_index(index)
183
+ return self.edges[checked_index], self.edges[checked_index + 1]
184
+
185
+ def index_at(self, value: float, *, clamp: bool = False) -> int | None:
186
+ """Return the cell containing ``value``.
187
+
188
+ An interior boundary belongs to the cell on its right. Both outer
189
+ boundaries are included. Out-of-bounds coordinates return ``None``
190
+ unless ``clamp`` is true, in which case the nearest outer cell is used.
191
+ Non-finite coordinates always return ``None``.
192
+ """
193
+
194
+ coordinate = float(value)
195
+ if not math.isfinite(coordinate):
196
+ return None
197
+
198
+ lower, upper = self.bounds
199
+ if coordinate < lower:
200
+ return 0 if clamp else None
201
+ if coordinate > upper:
202
+ return self.count - 1 if clamp else None
203
+ if coordinate == upper:
204
+ return self.count - 1
205
+
206
+ return bisect_right(self.edges, coordinate) - 1
207
+
208
+ def snap_interval(self, low: float, high: float) -> tuple[float, float]:
209
+ """Expand an interval to the cell edges that contain it."""
210
+
211
+ start, stop = self._cell_interval(low, high)
212
+ return self.edges[start], self.edges[stop]
213
+
214
+ def slice_for_interval(self, low: float, high: float) -> slice:
215
+ """Return the cells covered by an interval as a NumPy-style slice."""
216
+
217
+ start, stop = self._cell_interval(low, high)
218
+ return slice(start, stop)
219
+
220
+ def _cell_interval(self, low: float, high: float) -> tuple[int, int]:
221
+ low_value = float(low)
222
+ high_value = float(high)
223
+ if not math.isfinite(low_value) or not math.isfinite(high_value):
224
+ raise ValueError("heatmap selection coordinates must be finite.")
225
+
226
+ low_value, high_value = sorted((low_value, high_value))
227
+ lower_bound, upper_bound = self.bounds
228
+ low_value = min(max(low_value, lower_bound), upper_bound)
229
+ high_value = min(max(high_value, lower_bound), upper_bound)
230
+
231
+ start = bisect_right(self.edges, low_value) - 1
232
+ start = min(max(start, 0), self.count - 1)
233
+ stop = bisect_left(self.edges, high_value)
234
+ stop = min(max(stop, start + 1), self.count)
235
+ return start, stop
236
+
237
+ def _checked_index(self, index: int) -> int:
238
+ try:
239
+ checked_index = operator.index(index)
240
+ except TypeError as error:
241
+ raise TypeError("axis cell index must be an integer") from error
242
+ if not 0 <= checked_index < self.count:
243
+ raise IndexError(
244
+ f"axis cell index {checked_index} is outside "
245
+ f"0..{self.count - 1}"
246
+ )
247
+ return checked_index
248
+
249
+
250
+ @dataclass(frozen=True, slots=True)
251
+ class HeatmapGeometry:
252
+ """Immutable two-dimensional rectilinear heatmap geometry."""
253
+
254
+ x: AxisGeometry
255
+ y: AxisGeometry
256
+
257
+ @classmethod
258
+ def from_centres(
259
+ cls,
260
+ x_centres: Iterable[float],
261
+ y_centres: Iterable[float],
262
+ *,
263
+ x_singleton_span: float = _DEFAULT_SINGLETON_SPAN,
264
+ y_singleton_span: float = _DEFAULT_SINGLETON_SPAN,
265
+ uniform_rel_tol: float = _DEFAULT_UNIFORM_REL_TOL,
266
+ uniform_abs_tol: float = 0.0,
267
+ ) -> "HeatmapGeometry":
268
+ """Build heatmap geometry from X and Y cell-centre coordinates."""
269
+
270
+ return cls(
271
+ x=AxisGeometry(
272
+ x_centres,
273
+ singleton_span=x_singleton_span,
274
+ uniform_rel_tol=uniform_rel_tol,
275
+ uniform_abs_tol=uniform_abs_tol,
276
+ ),
277
+ y=AxisGeometry(
278
+ y_centres,
279
+ singleton_span=y_singleton_span,
280
+ uniform_rel_tol=uniform_rel_tol,
281
+ uniform_abs_tol=uniform_abs_tol,
282
+ ),
283
+ )
284
+
285
+ @property
286
+ def shape(self) -> tuple[int, int]:
287
+ """Expected data-grid shape in NumPy row-major order: ``(Y, X)``."""
288
+
289
+ return self.y.count, self.x.count
290
+
291
+ @property
292
+ def bounds(self) -> tuple[float, float, float, float]:
293
+ """Return ``(left, bottom, right, top)`` outer cell boundaries."""
294
+
295
+ return self.x.edges[0], self.y.edges[0], self.x.edges[-1], self.y.edges[-1]
296
+
297
+ @property
298
+ def rect(self) -> tuple[float, float, float, float]:
299
+ """Return a Qt-free ``(left, bottom, width, height)`` rectangle."""
300
+
301
+ return self.x.edges[0], self.y.edges[0], self.x.span, self.y.span
302
+
303
+ @property
304
+ def is_uniform(self) -> bool:
305
+ """Whether both axes use uniform cell spacing."""
306
+
307
+ return self.x.is_uniform and self.y.is_uniform
308
+
309
+ def index_at(
310
+ self,
311
+ x_value: float,
312
+ y_value: float,
313
+ *,
314
+ clamp: bool = False,
315
+ ) -> tuple[int, int] | None:
316
+ """Return the ``(X, Y)`` cell containing a plot coordinate."""
317
+
318
+ x_index = self.x.index_at(x_value, clamp=clamp)
319
+ y_index = self.y.index_at(y_value, clamp=clamp)
320
+ if x_index is None or y_index is None:
321
+ return None
322
+ return x_index, y_index
323
+
324
+ def cell_bounds(
325
+ self,
326
+ x_index: int,
327
+ y_index: int,
328
+ ) -> tuple[float, float, float, float]:
329
+ """Return ``(left, bottom, right, top)`` for one heatmap cell."""
330
+
331
+ left, right = self.x.cell_bounds(x_index)
332
+ bottom, top = self.y.cell_bounds(y_index)
333
+ return left, bottom, right, top
334
+
335
+ def cell_rect(
336
+ self,
337
+ x_index: int,
338
+ y_index: int,
339
+ ) -> tuple[float, float, float, float]:
340
+ """Return ``(left, bottom, width, height)`` for one heatmap cell."""
341
+
342
+ left, bottom, right, top = self.cell_bounds(x_index, y_index)
343
+ return left, bottom, right - left, top - bottom
344
+
345
+
346
+ def canonicalize_heatmap_data(
347
+ x_centres: npt.ArrayLike,
348
+ y_centres: npt.ArrayLike,
349
+ data_grid: npt.ArrayLike,
350
+ ) -> tuple[
351
+ npt.NDArray[np.float64],
352
+ npt.NDArray[np.float64],
353
+ npt.NDArray[Any],
354
+ ]:
355
+ """Return increasing axes while preserving their grid-value mapping."""
356
+
357
+ x_values = np.asarray(x_centres, dtype=float)
358
+ y_values = np.asarray(y_centres, dtype=float)
359
+ grid = np.asarray(data_grid)
360
+
361
+ if x_values.ndim != 1 or y_values.ndim != 1:
362
+ raise ValueError("Heatmap axes must be one-dimensional.")
363
+ expected_shape = (y_values.size, x_values.size)
364
+ if grid.ndim != 2 or grid.shape != expected_shape:
365
+ raise ValueError(
366
+ f"Heatmap data shape {grid.shape} does not match "
367
+ f"axis shape {expected_shape}."
368
+ )
369
+
370
+ if x_values.size > 1 and not np.all(np.diff(x_values) > 0.0):
371
+ order = np.argsort(x_values)
372
+ x_values = x_values[order]
373
+ grid = grid[:, order]
374
+ if y_values.size > 1 and not np.all(np.diff(y_values) > 0.0):
375
+ order = np.argsort(y_values)
376
+ y_values = y_values[order]
377
+ grid = grid[order, :]
378
+
379
+ AxisGeometry(x_values)
380
+ AxisGeometry(y_values)
381
+ return x_values, y_values, grid