qplotter 1.6.0__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 (130) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +67 -0
  4. qplot/_build_info.json +1 -0
  5. qplot/_metadata.py +75 -0
  6. qplot/_repair.py +14 -0
  7. qplot/_shutdown_supervisor.py +4049 -0
  8. qplot/_version.py +12 -0
  9. qplot/_windows_shutdown_job.py +1166 -0
  10. qplot/configuration/__init__.py +3 -0
  11. qplot/configuration/config.py +578 -0
  12. qplot/configuration/config_schema.json +357 -0
  13. qplot/configuration/fonts.py +3 -0
  14. qplot/configuration/scripts.py +255 -0
  15. qplot/configuration/themes/__init__.py +13 -0
  16. qplot/configuration/themes/_base.py +592 -0
  17. qplot/configuration/themes/blank.py +17 -0
  18. qplot/configuration/themes/dark.py +54 -0
  19. qplot/configuration/themes/light.py +54 -0
  20. qplot/datahandling/LoadFromDB.py +224 -0
  21. qplot/datahandling/__init__.py +47 -0
  22. qplot/datahandling/_trusted_live_helper.py +812 -0
  23. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  24. qplot/datahandling/database.py +2340 -0
  25. qplot/datahandling/dimensions.py +43 -0
  26. qplot/datahandling/file_identity.py +461 -0
  27. qplot/datahandling/parameter_data.py +149 -0
  28. qplot/datahandling/plot_progress.py +17 -0
  29. qplot/datahandling/qcodes_cache.py +277 -0
  30. qplot/datahandling/qcodes_compat.py +14 -0
  31. qplot/datahandling/readSQL.py +2442 -0
  32. qplot/datahandling/readonly.py +2566 -0
  33. qplot/datahandling/source_activity.py +21 -0
  34. qplot/datahandling/trusted_array_preview.py +148 -0
  35. qplot/datahandling/trusted_derived_cache.py +1605 -0
  36. qplot/datahandling/trusted_derived_rendering.py +876 -0
  37. qplot/datahandling/trusted_heatmap.py +246 -0
  38. qplot/datahandling/trusted_live.py +2808 -0
  39. qplot/datahandling/trusted_live_queries.py +5907 -0
  40. qplot/datahandling/trusted_live_service.py +1919 -0
  41. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  42. qplot/datahandling/trusted_plot.py +283 -0
  43. qplot/datahandling/trusted_presentation.py +1065 -0
  44. qplot/datahandling/trusted_snapshot.py +1132 -0
  45. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  46. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  47. qplot/diagnostics.py +241 -0
  48. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  49. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  50. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  51. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  52. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  53. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  54. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  55. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  56. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  57. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  58. qplot/testdata.py +1804 -0
  59. qplot/tools/__init__.py +13 -0
  60. qplot/tools/finite_means.py +123 -0
  61. qplot/tools/general.py +139 -0
  62. qplot/tools/heatmap_geometry.py +391 -0
  63. qplot/tools/operation_registry.py +179 -0
  64. qplot/tools/plot_tools.py +650 -0
  65. qplot/tools/sample_statistics.py +111 -0
  66. qplot/tools/worker.py +2838 -0
  67. qplot/windows/__init__.py +9 -0
  68. qplot/windows/_colorbar.py +422 -0
  69. qplot/windows/_commands.py +523 -0
  70. qplot/windows/_config_persistence.py +123 -0
  71. qplot/windows/_database_actions.py +4933 -0
  72. qplot/windows/_dataset_handle.py +221 -0
  73. qplot/windows/_display_precision.py +48 -0
  74. qplot/windows/_dragdrop.py +70 -0
  75. qplot/windows/_export_paths.py +1167 -0
  76. qplot/windows/_help.py +210 -0
  77. qplot/windows/_mixed_appearance.py +303 -0
  78. qplot/windows/_mixed_plots.py +461 -0
  79. qplot/windows/_native_averaging.py +323 -0
  80. qplot/windows/_native_transforms.py +551 -0
  81. qplot/windows/_plot1d_snap.py +612 -0
  82. qplot/windows/_plot1d_traces.py +2236 -0
  83. qplot/windows/_plot2d_colorbar.py +1127 -0
  84. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  85. qplot/windows/_plot2d_layers.py +2291 -0
  86. qplot/windows/_plot2d_sweeps.py +712 -0
  87. qplot/windows/_plotWin.py +2082 -0
  88. qplot/windows/_plot_actions.py +2703 -0
  89. qplot/windows/_plot_annotation.py +186 -0
  90. qplot/windows/_plot_appearance.py +283 -0
  91. qplot/windows/_plot_axis_scaling.py +1521 -0
  92. qplot/windows/_plot_data_viewbox.py +94 -0
  93. qplot/windows/_plot_drop_axes.py +224 -0
  94. qplot/windows/_plot_export.py +1445 -0
  95. qplot/windows/_plot_feedback.py +112 -0
  96. qplot/windows/_plot_marquee.py +659 -0
  97. qplot/windows/_plot_refresh.py +950 -0
  98. qplot/windows/_plot_source_promotion.py +178 -0
  99. qplot/windows/_plot_state.py +237 -0
  100. qplot/windows/_plot_status.py +115 -0
  101. qplot/windows/_plot_tiling.py +35 -0
  102. qplot/windows/_plot_transform_labels.py +60 -0
  103. qplot/windows/_preferences.py +724 -0
  104. qplot/windows/_refresh_interval.py +172 -0
  105. qplot/windows/_run_controls.py +789 -0
  106. qplot/windows/_shortcuts.py +47 -0
  107. qplot/windows/_subplots/__init__.py +8 -0
  108. qplot/windows/_subplots/subplot1d.py +532 -0
  109. qplot/windows/_subplots/subplot2d.py +919 -0
  110. qplot/windows/_svg_export.py +209 -0
  111. qplot/windows/_trusted_derived_qt.py +1759 -0
  112. qplot/windows/_widgets/__init__.py +23 -0
  113. qplot/windows/_widgets/_run_formatting.py +387 -0
  114. qplot/windows/_widgets/details_tables.py +1119 -0
  115. qplot/windows/_widgets/dropbox.py +290 -0
  116. qplot/windows/_widgets/operations.py +460 -0
  117. qplot/windows/_widgets/preview.py +2997 -0
  118. qplot/windows/_widgets/run_list_items.py +488 -0
  119. qplot/windows/_widgets/toolbar.py +111 -0
  120. qplot/windows/_widgets/treeWidgets.py +2572 -0
  121. qplot/windows/_window_controls.py +414 -0
  122. qplot/windows/main.py +1632 -0
  123. qplot/windows/plot1d.py +313 -0
  124. qplot/windows/plot2d.py +1935 -0
  125. qplotter-1.6.0.dist-info/METADATA +322 -0
  126. qplotter-1.6.0.dist-info/RECORD +130 -0
  127. qplotter-1.6.0.dist-info/WHEEL +5 -0
  128. qplotter-1.6.0.dist-info/entry_points.txt +4 -0
  129. qplotter-1.6.0.dist-info/licenses/LICENSE +21 -0
  130. qplotter-1.6.0.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,123 @@
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
+ from .sample_statistics import sample_fraction
8
+
9
+
10
+ class FiniteBinMeans:
11
+ """Keep only one count/sum per bin, never the original sample history."""
12
+
13
+ def __init__(self, shape, check_cancelled=lambda: None):
14
+ self.sums = np.zeros(shape, dtype=float)
15
+ self.counts = np.zeros(shape, dtype=np.int64)
16
+ self.exact = {}
17
+ self.check_cancelled = check_cancelled
18
+ self._largest = 0.
19
+ self._samples = 0
20
+ self._positive = np.zeros(shape, dtype=bool)
21
+ self._negative = np.zeros(shape, dtype=bool)
22
+ self._integer = np.zeros(shape, dtype=bool)
23
+ self._fractional = np.zeros(shape, dtype=bool)
24
+ self._absolute_sums = np.zeros(shape, dtype=float)
25
+
26
+ def add(self, indices, values):
27
+ self.check_cancelled()
28
+ original = np.asarray(values).ravel()
29
+ values = original.astype(float)
30
+ if not values.size:
31
+ return
32
+ targets = np.ravel_multi_index(indices, self.sums.shape)
33
+ np.logical_or.at(self._positive.ravel(), targets, values > 0)
34
+ np.logical_or.at(self._negative.ravel(), targets, values < 0)
35
+ mixed = self._positive & self._negative
36
+ # Original object cells may retain clipped decimal bounds or mixed
37
+ # integer records. Large integer cells also need exact replay even
38
+ # when all samples have the same sign: conversion or summation can
39
+ # round before their mean is formed.
40
+ sensitive = np.zeros(self.sums.shape, dtype=bool)
41
+ if original.dtype.kind == "O":
42
+ sensitive.ravel()[targets] = True
43
+ elif original.dtype.kind in "biu":
44
+ self._integer.ravel()[targets] = True
45
+ elif original.dtype.kind == "f":
46
+ np.logical_or.at(self._fractional.ravel(), targets, values != np.trunc(values))
47
+ # Several individually exact integers can have an inexact total.
48
+ # Retain the accumulated absolute bound and integer presence across
49
+ # chunks, including a later floating record in the same bin.
50
+ with np.errstate(over="ignore", invalid="ignore"):
51
+ np.add.at(self._absolute_sums.ravel(), targets, np.abs(values))
52
+ # Mixed fractional records can round a partial sum below 2**53 too:
53
+ # integer 2**51 followed by two floats 2**51 + .5 loses their residual.
54
+ # Remember both kinds across chunks, independently for each bin. Pure
55
+ # integers and integral float records retain their vectorized path.
56
+ sensitive |= self._integer & ((self._absolute_sums >= 2**53) | self._fractional)
57
+ self._largest = max(self._largest, float(np.max(np.abs(values))))
58
+ self._samples += values.size
59
+ # Leave headroom for rounded additions at the finite sum boundary.
60
+ # This conservative global bound makes the normal path vectorized.
61
+ # It changes only whether we retain sums for recovery, never scaling
62
+ # the values in other bins (which could erase small measurements).
63
+ safe_sample = (np.finfo(float).max / 2) / self._samples
64
+ risk = (bool(self.exact) or self._largest > safe_sample
65
+ or np.any(mixed) or np.any(sensitive))
66
+ previous = self.sums.copy() if risk else None
67
+ with np.errstate(over="ignore", invalid="ignore"):
68
+ np.add.at(self.sums.ravel(), targets, values)
69
+ np.add.at(self.counts.ravel(), targets, 1)
70
+ if risk:
71
+ assert previous is not None
72
+ # Promote before overflow: waiting for a later chunk to overflow
73
+ # would already have rounded away small cancellation residuals.
74
+ largest = np.abs(previous)
75
+ np.maximum.at(largest.ravel(), targets, np.abs(values))
76
+ for index, target in enumerate(np.flatnonzero(
77
+ (largest > safe_sample) | mixed | sensitive)):
78
+ if index % 1024 == 0:
79
+ self.check_cancelled()
80
+ self.exact.setdefault(int(target), Fraction(float(previous.ravel()[target])))
81
+ if self.exact:
82
+ self._add_exact(targets, original)
83
+ self.sums.ravel()[list(self.exact)] = 0.
84
+ self.check_cancelled()
85
+
86
+ def _add_exact(self, targets, values):
87
+ selected = np.flatnonzero(np.isin(targets, list(self.exact)))
88
+ for index, position in enumerate(selected):
89
+ if index % 1024 == 0:
90
+ self.check_cancelled()
91
+ value = values[position]
92
+ if isinstance(value, np.generic):
93
+ value = value.item()
94
+ self.exact[int(targets[position])] += sample_fraction(value)
95
+
96
+ def begin_exact_replay(self):
97
+ """Discard rounded history for promoted bins, retaining their counts."""
98
+ self.check_cancelled()
99
+ for index, target in enumerate(self.exact):
100
+ if index % 1024 == 0:
101
+ self.check_cancelled()
102
+ self.exact[target] = Fraction()
103
+ return bool(self.exact)
104
+
105
+ def replay(self, indices, values):
106
+ """Revisit original samples once; only promoted totals are changed."""
107
+ self.check_cancelled()
108
+ targets = np.ravel_multi_index(indices, self.sums.shape)
109
+ # Retain original integer/object values instead of converting these
110
+ # selected samples through the ordinary display-sum float dtype.
111
+ self._add_exact(targets, np.asarray(values).ravel())
112
+ self.check_cancelled()
113
+
114
+ def means(self):
115
+ self.check_cancelled()
116
+ result = np.full(self.sums.shape, np.nan)
117
+ np.divide(self.sums, self.counts, out=result, where=self.counts > 0)
118
+ for index, (target, total) in enumerate(self.exact.items()):
119
+ if index % 1024 == 0:
120
+ self.check_cancelled()
121
+ result.ravel()[target] = float(total / int(self.counts.ravel()[target]))
122
+ self.check_cancelled()
123
+ return result
qplot/tools/general.py ADDED
@@ -0,0 +1,139 @@
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, sample_fraction
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
+ total = Fraction()
65
+ for sample_index, value in enumerate(samples):
66
+ if sample_index % 1024 == 0:
67
+ check_cancelled()
68
+ total += sample_fraction(value)
69
+ mean = total / len(samples)
70
+ if mean.denominator == 1:
71
+ grid[position] = int(mean)
72
+ else:
73
+ # Decimal keeps the offset and fractional detail while
74
+ # remaining a numeric CSV cell (Fraction writes "a/b").
75
+ with localcontext() as context:
76
+ context.prec = max(34, len(str(abs(mean.numerator))) + 17)
77
+ grid[position] = Decimal(mean.numerator) / Decimal(mean.denominator)
78
+ check_cancelled()
79
+ return pd.DataFrame(grid, index=rows, columns=columns)
80
+
81
+ # Widen narrow floats before pandas accumulates repeated measured cells.
82
+ # The source array remains untouched for raw export and later operations.
83
+ if values.dtype.kind == "f":
84
+ values = values.astype(np.result_type(values.dtype, np.float64), copy=False)
85
+
86
+ # convert to 3 column dataframe
87
+ df = pd.DataFrame({
88
+ 'indep1': indep1,
89
+ 'indep2': indep2,
90
+ 'depvar': values
91
+ })
92
+ # convert dataframe to grid
93
+ if values.dtype.kind == "f" and (
94
+ np.any(np.abs(values) > np.finfo(values.dtype).max / max(1, values.size))
95
+ or (np.any(values < 0) and np.any(values > 0))):
96
+ # The ordinary vectorized mean is fast, but an intermediate group sum
97
+ # can overflow even when the final mean is finite. Retain failed groups
98
+ # before pivot_table would drop their rows and columns, then repair only
99
+ # those groups using a scaled mean of their original finite samples.
100
+ check_cancelled()
101
+ grouped = df.groupby(['indep1', 'indep2'])['depvar']
102
+ means = grouped.mean()
103
+ check_cancelled()
104
+ mixed = (grouped.min().to_numpy() < 0) & (grouped.max().to_numpy() > 0)
105
+ for group_position in np.flatnonzero(~np.isfinite(means.to_numpy()) | mixed):
106
+ check_cancelled()
107
+ samples = grouped.get_group(means.index[group_position]).to_numpy()
108
+ if np.any(np.isinf(samples)):
109
+ continue
110
+ samples = samples[np.isfinite(samples)]
111
+ if not samples.size:
112
+ continue
113
+ means.iloc[group_position] = finite_mean(samples, check_cancelled=check_cancelled)
114
+ check_cancelled()
115
+ return means.unstack().dropna(how='all', axis=0).dropna(how='all', axis=1)
116
+ matrix = df.pivot_table(index='indep1', columns='indep2', values='depvar', fill_value=np.nan)
117
+ return matrix
118
+
119
+
120
+ def unpack_param(dataset : dataset.data_set.DataSet, paramName : str):
121
+ """
122
+ Gets specified parameter from list all parameters in a dataset.
123
+
124
+ Parameters
125
+ ----------
126
+ dataset : qcodes.dataset.data_set.DataSet
127
+ Dataset to ook through.
128
+ paramName : str
129
+ Name of parameter.
130
+
131
+ Returns
132
+ -------
133
+ ParamSpec : qcodes.dataset.descriptions.param_spec.ParamSpec
134
+ The desired parameter data.
135
+
136
+ """
137
+ for ParamSpec in dataset.get_parameters():
138
+ if ParamSpec.name == paramName:
139
+ return ParamSpec
@@ -0,0 +1,391 @@
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
+ """Return centres and finite normalized geometry for SQL bin assignment.
43
+
44
+ A reciprocal bin width can overflow for valid subnormal coordinates.
45
+ Binary scaling also bounds a sweep crossing both finite float extremes,
46
+ before subtracting its endpoints or extending its half-cell edges.
47
+ """
48
+ if not np.isfinite(lower) or not np.isfinite(upper):
49
+ return np.array([], dtype=float), 0.0, 1.0, 1.0
50
+ magnitude = max(abs(lower), abs(upper))
51
+ exponent = math.frexp(magnitude)[1] - 1 if magnitude else 0
52
+ coordinate_scale = math.ldexp(1.0, max(-1022, min(1023, exponent)))
53
+ scaled_lower, scaled_upper = lower / coordinate_scale, upper / coordinate_scale
54
+ if source_count <= 1 or bin_count <= 1 or lower == upper:
55
+ return np.array([lower], dtype=float), scaled_lower, 1.0, coordinate_scale
56
+ source_step = (scaled_upper - scaled_lower) / (source_count - 1)
57
+ lower_edge = scaled_lower - source_step / 2
58
+ upper_edge = scaled_upper + source_step / 2
59
+ bin_width = (upper_edge - lower_edge) / bin_count
60
+ centres = lower_edge + (np.arange(bin_count, dtype=float) + 0.5) * bin_width
61
+ return centres * coordinate_scale, lower_edge, bin_width, coordinate_scale
62
+
63
+
64
+ @dataclass(frozen=True, slots=True, init=False)
65
+ class AxisGeometry:
66
+ """Immutable geometry for one strictly increasing heatmap axis.
67
+
68
+ Descending axes are rejected rather than silently reordered because axis
69
+ indices must remain aligned with the corresponding dimension of the data
70
+ grid. A caller that receives descending data must reverse both together.
71
+
72
+ Parameters
73
+ ----------
74
+ centres:
75
+ Finite, strictly increasing cell-centre coordinates.
76
+ singleton_span:
77
+ Width assigned to an axis containing one centre. Callers can supply a
78
+ known neighbouring step; otherwise one coordinate unit is used.
79
+ uniform_rel_tol, uniform_abs_tol:
80
+ Tolerances used only when classifying an axis as uniform.
81
+ """
82
+
83
+ centres: tuple[float, ...]
84
+ edges: tuple[float, ...]
85
+ is_uniform: bool
86
+
87
+ def __init__(
88
+ self,
89
+ centres: Iterable[float],
90
+ *,
91
+ singleton_span: float = _DEFAULT_SINGLETON_SPAN,
92
+ uniform_rel_tol: float = _DEFAULT_UNIFORM_REL_TOL,
93
+ uniform_abs_tol: float = 0.0,
94
+ ) -> None:
95
+ values = tuple(float(value) for value in centres)
96
+ span = float(singleton_span)
97
+ rel_tol = float(uniform_rel_tol)
98
+ abs_tol = float(uniform_abs_tol)
99
+
100
+ if not values:
101
+ raise ValueError("A heatmap axis requires at least one centre.")
102
+ if not all(math.isfinite(value) for value in values):
103
+ raise ValueError("Heatmap axis centres must all be finite.")
104
+ if not math.isfinite(span) or span <= 0.0:
105
+ raise ValueError("singleton_span must be positive and finite.")
106
+ if not math.isfinite(rel_tol) or rel_tol < 0.0:
107
+ raise ValueError("uniform_rel_tol must be non-negative and finite.")
108
+ if not math.isfinite(abs_tol) or abs_tol < 0.0:
109
+ raise ValueError("uniform_abs_tol must be non-negative and finite.")
110
+
111
+ deltas = tuple(
112
+ right - left
113
+ for left, right in zip(values[:-1], values[1:], strict=True)
114
+ )
115
+ if deltas and all(delta < 0.0 for delta in deltas):
116
+ raise ValueError(
117
+ "descending heatmap axes are not supported; reverse both the "
118
+ "axis centres and the corresponding data dimension."
119
+ )
120
+ if any(delta <= 0.0 for delta in deltas):
121
+ raise ValueError("Heatmap axis centres must be strictly increasing.")
122
+
123
+ derived_edges: tuple[float, ...]
124
+ if len(values) == 1:
125
+ half_span = span / 2.0
126
+ derived_edges = (values[0] - half_span, values[0] + half_span)
127
+ else:
128
+ interior_edges = tuple(
129
+ left / 2.0 + right / 2.0
130
+ for left, right in zip(values[:-1], values[1:], strict=True)
131
+ )
132
+ derived_edges = (
133
+ values[0] - (interior_edges[0] - values[0]),
134
+ *interior_edges,
135
+ values[-1] + (values[-1] - interior_edges[-1]),
136
+ )
137
+
138
+ if not all(math.isfinite(edge) for edge in derived_edges):
139
+ raise ValueError("Derived heatmap axis edges must all be finite.")
140
+ if any(
141
+ right <= left
142
+ for left, right in zip(
143
+ derived_edges[:-1],
144
+ derived_edges[1:],
145
+ strict=True,
146
+ )
147
+ ):
148
+ raise ValueError(
149
+ "Heatmap cell edges collapse at floating-point precision."
150
+ )
151
+
152
+ uniform = len(deltas) <= 1 or all(
153
+ math.isclose(
154
+ delta,
155
+ deltas[0],
156
+ rel_tol=rel_tol,
157
+ abs_tol=abs_tol,
158
+ )
159
+ for delta in deltas[1:]
160
+ )
161
+
162
+ object.__setattr__(self, "centres", values)
163
+ object.__setattr__(self, "edges", derived_edges)
164
+ object.__setattr__(self, "is_uniform", uniform)
165
+
166
+ @property
167
+ def count(self) -> int:
168
+ """Number of cells along this axis."""
169
+
170
+ return len(self.centres)
171
+
172
+ @property
173
+ def bounds(self) -> tuple[float, float]:
174
+ """Inclusive outer coordinate bounds of the axis."""
175
+
176
+ return self.edges[0], self.edges[-1]
177
+
178
+ @property
179
+ def span(self) -> float:
180
+ """Positive distance between the outer axis edges."""
181
+
182
+ return self.edges[-1] - self.edges[0]
183
+
184
+ def centre(self, index: int) -> float:
185
+ """Return the recorded centre coordinate of one cell."""
186
+
187
+ return self.centres[self._checked_index(index)]
188
+
189
+ def cell_bounds(self, index: int) -> tuple[float, float]:
190
+ """Return the lower and upper boundary of one cell."""
191
+
192
+ checked_index = self._checked_index(index)
193
+ return self.edges[checked_index], self.edges[checked_index + 1]
194
+
195
+ def index_at(self, value: float, *, clamp: bool = False) -> int | None:
196
+ """Return the cell containing ``value``.
197
+
198
+ An interior boundary belongs to the cell on its right. Both outer
199
+ boundaries are included. Out-of-bounds coordinates return ``None``
200
+ unless ``clamp`` is true, in which case the nearest outer cell is used.
201
+ Non-finite coordinates always return ``None``.
202
+ """
203
+
204
+ coordinate = float(value)
205
+ if not math.isfinite(coordinate):
206
+ return None
207
+
208
+ lower, upper = self.bounds
209
+ if coordinate < lower:
210
+ return 0 if clamp else None
211
+ if coordinate > upper:
212
+ return self.count - 1 if clamp else None
213
+ if coordinate == upper:
214
+ return self.count - 1
215
+
216
+ return bisect_right(self.edges, coordinate) - 1
217
+
218
+ def snap_interval(self, low: float, high: float) -> tuple[float, float]:
219
+ """Expand an interval to the cell edges that contain it."""
220
+
221
+ start, stop = self._cell_interval(low, high)
222
+ return self.edges[start], self.edges[stop]
223
+
224
+ def slice_for_interval(self, low: float, high: float) -> slice:
225
+ """Return the cells covered by an interval as a NumPy-style slice."""
226
+
227
+ start, stop = self._cell_interval(low, high)
228
+ return slice(start, stop)
229
+
230
+ def _cell_interval(self, low: float, high: float) -> tuple[int, int]:
231
+ low_value = float(low)
232
+ high_value = float(high)
233
+ if not math.isfinite(low_value) or not math.isfinite(high_value):
234
+ raise ValueError("heatmap selection coordinates must be finite.")
235
+
236
+ low_value, high_value = sorted((low_value, high_value))
237
+ lower_bound, upper_bound = self.bounds
238
+ low_value = min(max(low_value, lower_bound), upper_bound)
239
+ high_value = min(max(high_value, lower_bound), upper_bound)
240
+
241
+ start = bisect_right(self.edges, low_value) - 1
242
+ start = min(max(start, 0), self.count - 1)
243
+ stop = bisect_left(self.edges, high_value)
244
+ stop = min(max(stop, start + 1), self.count)
245
+ return start, stop
246
+
247
+ def _checked_index(self, index: int) -> int:
248
+ try:
249
+ checked_index = operator.index(index)
250
+ except TypeError as error:
251
+ raise TypeError("axis cell index must be an integer") from error
252
+ if not 0 <= checked_index < self.count:
253
+ raise IndexError(
254
+ f"axis cell index {checked_index} is outside "
255
+ f"0..{self.count - 1}"
256
+ )
257
+ return checked_index
258
+
259
+
260
+ @dataclass(frozen=True, slots=True)
261
+ class HeatmapGeometry:
262
+ """Immutable two-dimensional rectilinear heatmap geometry."""
263
+
264
+ x: AxisGeometry
265
+ y: AxisGeometry
266
+
267
+ @classmethod
268
+ def from_centres(
269
+ cls,
270
+ x_centres: Iterable[float],
271
+ y_centres: Iterable[float],
272
+ *,
273
+ x_singleton_span: float = _DEFAULT_SINGLETON_SPAN,
274
+ y_singleton_span: float = _DEFAULT_SINGLETON_SPAN,
275
+ uniform_rel_tol: float = _DEFAULT_UNIFORM_REL_TOL,
276
+ uniform_abs_tol: float = 0.0,
277
+ ) -> "HeatmapGeometry":
278
+ """Build heatmap geometry from X and Y cell-centre coordinates."""
279
+
280
+ return cls(
281
+ x=AxisGeometry(
282
+ x_centres,
283
+ singleton_span=x_singleton_span,
284
+ uniform_rel_tol=uniform_rel_tol,
285
+ uniform_abs_tol=uniform_abs_tol,
286
+ ),
287
+ y=AxisGeometry(
288
+ y_centres,
289
+ singleton_span=y_singleton_span,
290
+ uniform_rel_tol=uniform_rel_tol,
291
+ uniform_abs_tol=uniform_abs_tol,
292
+ ),
293
+ )
294
+
295
+ @property
296
+ def shape(self) -> tuple[int, int]:
297
+ """Expected data-grid shape in NumPy row-major order: ``(Y, X)``."""
298
+
299
+ return self.y.count, self.x.count
300
+
301
+ @property
302
+ def bounds(self) -> tuple[float, float, float, float]:
303
+ """Return ``(left, bottom, right, top)`` outer cell boundaries."""
304
+
305
+ return self.x.edges[0], self.y.edges[0], self.x.edges[-1], self.y.edges[-1]
306
+
307
+ @property
308
+ def rect(self) -> tuple[float, float, float, float]:
309
+ """Return a Qt-free ``(left, bottom, width, height)`` rectangle."""
310
+
311
+ return self.x.edges[0], self.y.edges[0], self.x.span, self.y.span
312
+
313
+ @property
314
+ def is_uniform(self) -> bool:
315
+ """Whether both axes use uniform cell spacing."""
316
+
317
+ return self.x.is_uniform and self.y.is_uniform
318
+
319
+ def index_at(
320
+ self,
321
+ x_value: float,
322
+ y_value: float,
323
+ *,
324
+ clamp: bool = False,
325
+ ) -> tuple[int, int] | None:
326
+ """Return the ``(X, Y)`` cell containing a plot coordinate."""
327
+
328
+ x_index = self.x.index_at(x_value, clamp=clamp)
329
+ y_index = self.y.index_at(y_value, clamp=clamp)
330
+ if x_index is None or y_index is None:
331
+ return None
332
+ return x_index, y_index
333
+
334
+ def cell_bounds(
335
+ self,
336
+ x_index: int,
337
+ y_index: int,
338
+ ) -> tuple[float, float, float, float]:
339
+ """Return ``(left, bottom, right, top)`` for one heatmap cell."""
340
+
341
+ left, right = self.x.cell_bounds(x_index)
342
+ bottom, top = self.y.cell_bounds(y_index)
343
+ return left, bottom, right, top
344
+
345
+ def cell_rect(
346
+ self,
347
+ x_index: int,
348
+ y_index: int,
349
+ ) -> tuple[float, float, float, float]:
350
+ """Return ``(left, bottom, width, height)`` for one heatmap cell."""
351
+
352
+ left, bottom, right, top = self.cell_bounds(x_index, y_index)
353
+ return left, bottom, right - left, top - bottom
354
+
355
+
356
+ def canonicalize_heatmap_data(
357
+ x_centres: npt.ArrayLike,
358
+ y_centres: npt.ArrayLike,
359
+ data_grid: npt.ArrayLike,
360
+ ) -> tuple[
361
+ npt.NDArray[np.float64],
362
+ npt.NDArray[np.float64],
363
+ npt.NDArray[Any],
364
+ ]:
365
+ """Return increasing axes while preserving their grid-value mapping."""
366
+
367
+ x_values = np.asarray(x_centres, dtype=float)
368
+ y_values = np.asarray(y_centres, dtype=float)
369
+ grid = np.asarray(data_grid)
370
+
371
+ if x_values.ndim != 1 or y_values.ndim != 1:
372
+ raise ValueError("Heatmap axes must be one-dimensional.")
373
+ expected_shape = (y_values.size, x_values.size)
374
+ if grid.ndim != 2 or grid.shape != expected_shape:
375
+ raise ValueError(
376
+ f"Heatmap data shape {grid.shape} does not match "
377
+ f"axis shape {expected_shape}."
378
+ )
379
+
380
+ if x_values.size > 1 and not np.all(np.diff(x_values) > 0.0):
381
+ order = np.argsort(x_values)
382
+ x_values = x_values[order]
383
+ grid = grid[:, order]
384
+ if y_values.size > 1 and not np.all(np.diff(y_values) > 0.0):
385
+ order = np.argsort(y_values)
386
+ y_values = y_values[order]
387
+ grid = grid[order, :]
388
+
389
+ AxisGeometry(x_values)
390
+ AxisGeometry(y_values)
391
+ return x_values, y_values, grid