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.
- qplot/__init__.py +46 -0
- qplot/__main__.py +232 -0
- qplot/_auxiliary_paths.py +63 -0
- qplot/_metadata.py +12 -0
- qplot/_repair.py +14 -0
- qplot/_shutdown_supervisor.py +4049 -0
- qplot/_version.py +12 -0
- qplot/_windows_shutdown_job.py +1166 -0
- qplot/configuration/__init__.py +3 -0
- qplot/configuration/config.py +573 -0
- qplot/configuration/config_schema.json +319 -0
- qplot/configuration/scripts.py +255 -0
- qplot/configuration/themes/__init__.py +13 -0
- qplot/configuration/themes/_base.py +579 -0
- qplot/configuration/themes/blank.py +17 -0
- qplot/configuration/themes/dark.py +54 -0
- qplot/configuration/themes/light.py +54 -0
- qplot/datahandling/LoadFromDB.py +224 -0
- qplot/datahandling/__init__.py +47 -0
- qplot/datahandling/_trusted_live_helper.py +812 -0
- qplot/datahandling/_trusted_live_protocol.py +1745 -0
- qplot/datahandling/database.py +2330 -0
- qplot/datahandling/dimensions.py +43 -0
- qplot/datahandling/file_identity.py +461 -0
- qplot/datahandling/parameter_data.py +149 -0
- qplot/datahandling/plot_progress.py +17 -0
- qplot/datahandling/qcodes_cache.py +277 -0
- qplot/datahandling/qcodes_compat.py +14 -0
- qplot/datahandling/readSQL.py +2442 -0
- qplot/datahandling/readonly.py +2561 -0
- qplot/datahandling/source_activity.py +21 -0
- qplot/datahandling/trusted_array_preview.py +127 -0
- qplot/datahandling/trusted_derived_cache.py +1605 -0
- qplot/datahandling/trusted_derived_rendering.py +868 -0
- qplot/datahandling/trusted_heatmap.py +241 -0
- qplot/datahandling/trusted_live.py +2808 -0
- qplot/datahandling/trusted_live_queries.py +5907 -0
- qplot/datahandling/trusted_live_service.py +1894 -0
- qplot/datahandling/trusted_live_supervisor.py +2161 -0
- qplot/datahandling/trusted_plot.py +283 -0
- qplot/datahandling/trusted_presentation.py +1065 -0
- qplot/datahandling/trusted_snapshot.py +1132 -0
- qplot/datahandling/trusted_work_coordinator.py +2157 -0
- qplot/datahandling/trusted_work_scheduler.py +1204 -0
- qplot/diagnostics.py +241 -0
- qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
- qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
- qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
- qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
- qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
- qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
- qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
- qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
- qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
- qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
- qplot/testdata.py +1733 -0
- qplot/tools/__init__.py +13 -0
- qplot/tools/finite_means.py +114 -0
- qplot/tools/general.py +134 -0
- qplot/tools/heatmap_geometry.py +381 -0
- qplot/tools/operation_registry.py +179 -0
- qplot/tools/plot_tools.py +625 -0
- qplot/tools/sample_statistics.py +56 -0
- qplot/tools/worker.py +2787 -0
- qplot/windows/__init__.py +9 -0
- qplot/windows/_colorbar.py +422 -0
- qplot/windows/_commands.py +523 -0
- qplot/windows/_config_persistence.py +123 -0
- qplot/windows/_database_actions.py +4904 -0
- qplot/windows/_dataset_handle.py +221 -0
- qplot/windows/_dragdrop.py +71 -0
- qplot/windows/_export_paths.py +1167 -0
- qplot/windows/_help.py +169 -0
- qplot/windows/_native_averaging.py +173 -0
- qplot/windows/_native_transforms.py +389 -0
- qplot/windows/_plot1d_snap.py +608 -0
- qplot/windows/_plot1d_traces.py +2183 -0
- qplot/windows/_plot2d_colorbar.py +1126 -0
- qplot/windows/_plot2d_colorbar_dialog.py +786 -0
- qplot/windows/_plot2d_layers.py +2197 -0
- qplot/windows/_plot2d_sweeps.py +712 -0
- qplot/windows/_plotWin.py +1993 -0
- qplot/windows/_plot_actions.py +2695 -0
- qplot/windows/_plot_appearance.py +276 -0
- qplot/windows/_plot_axis_scaling.py +1485 -0
- qplot/windows/_plot_export.py +1445 -0
- qplot/windows/_plot_feedback.py +110 -0
- qplot/windows/_plot_marquee.py +656 -0
- qplot/windows/_plot_refresh.py +942 -0
- qplot/windows/_plot_state.py +239 -0
- qplot/windows/_plot_transform_labels.py +60 -0
- qplot/windows/_preferences.py +635 -0
- qplot/windows/_refresh_interval.py +70 -0
- qplot/windows/_run_controls.py +828 -0
- qplot/windows/_shortcuts.py +47 -0
- qplot/windows/_subplots/__init__.py +8 -0
- qplot/windows/_subplots/subplot1d.py +531 -0
- qplot/windows/_subplots/subplot2d.py +918 -0
- qplot/windows/_svg_export.py +209 -0
- qplot/windows/_trusted_derived_qt.py +1759 -0
- qplot/windows/_widgets/__init__.py +23 -0
- qplot/windows/_widgets/_run_formatting.py +387 -0
- qplot/windows/_widgets/details_tables.py +1119 -0
- qplot/windows/_widgets/dropbox.py +290 -0
- qplot/windows/_widgets/operations.py +460 -0
- qplot/windows/_widgets/preview.py +2997 -0
- qplot/windows/_widgets/run_list_items.py +488 -0
- qplot/windows/_widgets/toolbar.py +111 -0
- qplot/windows/_widgets/treeWidgets.py +2572 -0
- qplot/windows/_window_controls.py +413 -0
- qplot/windows/main.py +1635 -0
- qplot/windows/plot1d.py +311 -0
- qplot/windows/plot2d.py +1901 -0
- qplotter-1.6.0b3.dist-info/METADATA +318 -0
- qplotter-1.6.0b3.dist-info/RECORD +119 -0
- qplotter-1.6.0b3.dist-info/WHEEL +5 -0
- qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
- qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
- qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
qplot/tools/__init__.py
ADDED
|
@@ -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
|