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,876 @@
1
+ """Deterministic, bounded, Qt-independent Stage 5C derived rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import binascii
6
+ import math
7
+ import struct
8
+ import zlib
9
+ from bisect import bisect_left
10
+ from collections.abc import Callable
11
+ from fractions import Fraction
12
+ from typing import Any, TypeAlias, cast
13
+
14
+ from qplot.datahandling.trusted_live_queries import (
15
+ Trusted2DGridLayout,
16
+ TrustedDerivedSourceObservation,
17
+ )
18
+ from qplot.datahandling.trusted_work_scheduler import (
19
+ RenderingOptions,
20
+ TrustedWorkKind,
21
+ )
22
+
23
+ TRUSTED_DERIVED_RENDERER_VERSION = "trusted-derived-renderer-v7"
24
+ TRUSTED_DERIVED_MAX_IMAGES = 8
25
+ TRUSTED_DERIVED_MAX_IMAGE_WIDTH = 2_048
26
+ TRUSTED_DERIVED_MAX_IMAGE_HEIGHT = 2_048
27
+ TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES = 16 * 1024 * 1024
28
+ TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES = 8 * 1024 * 1024
29
+ TRUSTED_DERIVED_MAX_RETAINED_PAYLOAD_BYTES = 16 * 1024 * 1024
30
+ TRUSTED_DERIVED_MAX_PAYLOAD_DEPTH = 32
31
+ TRUSTED_DERIVED_MAX_PAYLOAD_NODES = 131_072
32
+ TRUSTED_DERIVED_MAX_PAYLOAD_ITEMS = 65_536
33
+ TRUSTED_DERIVED_MAX_TEXT_BYTES = 1024 * 1024
34
+
35
+ DerivedScalar: TypeAlias = None | bool | int | float | str | bytes
36
+ DerivedValue: TypeAlias = DerivedScalar | tuple["DerivedValue", ...]
37
+ DerivedPayload: TypeAlias = dict[str, DerivedValue]
38
+ CancelCheck: TypeAlias = Callable[[], None]
39
+ _DEFAULT_RENDERING_OPTIONS = RenderingOptions()
40
+ _VIRIDIS_RGB = bytes.fromhex(
41
+ "44015444025544035745055845065a45085b46095c460b5e460c5f460e61470f62471163471265471466471567471669"
42
+ "47186a48196b481a6c481c6e481d6f481e70482071482172482273482374472575472676472777472878472a79472b7a"
43
+ "472c7b462d7c462f7c46307d46317e45327f45347f453580453681443781443982433a83433b83433c84423d84423e85"
44
+ "4240854141864142864043874044873f45873f47883e48883e49893d4a893d4b893d4c893c4d8a3c4e8a3b508a3b518a"
45
+ "3a528b3a538b39548b39558b38568b38578c37588c37598c365a8c365b8c355c8c355d8c345e8d345f8d33608d33618d"
46
+ "32628d32638d31648d31658d31668d30678d30688d2f698d2f6a8d2e6b8e2e6c8e2e6d8e2d6e8e2d6f8e2c708e2c718e"
47
+ "2c728e2b738e2b748e2a758e2a768e2a778e29788e29798e287a8e287a8e287b8e277c8e277d8e277e8e267f8e26808e"
48
+ "26818e25828e25838d24848d24858d24868d23878d23888d23898d22898d228a8d228b8d218c8d218d8c218e8c208f8c"
49
+ "20908c20918c1f928c1f938b1f948b1f958b1f968b1e978a1e988a1e998a1e998a1e9a891e9b891e9c891e9d881e9e88"
50
+ "1e9f881ea0871fa1871fa2861fa38620a48520a58521a68521a78422a78423a88323a98224aa8225ab8126ac8127ad80"
51
+ "28ae7f29af7f2ab07e2bb17d2cb17d2eb27c2fb37b30b47a32b57a33b67935b77836b87738b97639b9763bba753dbb74"
52
+ "3ebc7340bd7242be7144be7045bf6f47c06e49c16d4bc26c4dc26b4fc36951c46853c56755c66657c66559c7645bc862"
53
+ "5ec96160c96062ca5f64cb5d67cc5c69cc5b6bcd596dce5870ce5672cf5574d05477d05279d1517cd24f7ed24e81d34c"
54
+ "83d34b86d44988d5478bd5468dd64490d64392d74195d73f97d83e9ad83c9dd93a9fd938a2da37a5da35a7db33aadb32"
55
+ "addc30afdc2eb2dd2cb5dd2bb7dd29bade27bdde26bfdf24c2df22c5df21c7e01fcae01ecde01dcfe11cd2e11bd4e11a"
56
+ "d7e219dae218dce218dfe318e1e318e4e318e7e419e9e419ece41aeee51bf1e51cf3e51ef6e61ff8e621fae622fde724"
57
+ )
58
+ _MISSING_CELL_RGBA = (230, 230, 230, 255)
59
+
60
+
61
+ class TrustedDerivedRenderingError(RuntimeError):
62
+ """A bounded source could not be converted into a safe payload."""
63
+
64
+
65
+ class _UnsupportedNumericData(TrustedDerivedRenderingError):
66
+ pass
67
+
68
+
69
+ def _option_integer(
70
+ options: RenderingOptions,
71
+ name: str,
72
+ default: int,
73
+ maximum: int,
74
+ ) -> int:
75
+ value = dict(options.values).get(name, default)
76
+ if type(value) is not int or not 1 <= value <= maximum:
77
+ raise ValueError(f"Rendering option {name!r} must be from 1 through {maximum}.")
78
+ return value
79
+
80
+
81
+ def _finite_number(value: object) -> int | float | None:
82
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
83
+ return None
84
+ return value if math.isfinite(value) else None
85
+
86
+
87
+ def _source_identity(observation: TrustedDerivedSourceObservation) -> DerivedValue:
88
+ return (
89
+ ("run_id", observation.run_id),
90
+ ("run_guid", observation.run_guid),
91
+ ("result_table", observation.result_table_name),
92
+ ("result_watermark", observation.result_watermark),
93
+ ("helper_incarnation", observation.helper_incarnation),
94
+ ("data_version", observation.data_version),
95
+ ("schema_sha256", observation.result_schema_sha256),
96
+ )
97
+
98
+
99
+ def _base_payload(
100
+ observation: TrustedDerivedSourceObservation,
101
+ kind: TrustedWorkKind,
102
+ *,
103
+ status: str,
104
+ description: str,
105
+ ) -> DerivedPayload:
106
+ return {
107
+ "format": "qplot-trusted-derived-payload-v1",
108
+ "kind": kind.name.lower(),
109
+ "status": status,
110
+ "description": description,
111
+ "source": _source_identity(observation),
112
+ "images": (),
113
+ }
114
+
115
+
116
+ def _metadata_payload(
117
+ observation: TrustedDerivedSourceObservation,
118
+ ) -> DerivedPayload:
119
+ payload = _base_payload(
120
+ observation,
121
+ TrustedWorkKind.METADATA,
122
+ status="ok",
123
+ description="Bounded trusted run metadata.",
124
+ )
125
+ payload["metadata"] = (
126
+ ("run_id", observation.run_id),
127
+ ("guid", observation.run_guid),
128
+ ("result_table_name", observation.result_table_name),
129
+ ("result_count", observation.result_watermark),
130
+ ("planned_shape", cast(DerivedValue, observation.planned_shape)),
131
+ ("sampled_rows", len(observation.sample_rows)),
132
+ ("sampled_columns", len(observation.sample_columns)),
133
+ (
134
+ "run_fields",
135
+ cast(
136
+ DerivedValue,
137
+ observation.run_fields
138
+ or (
139
+ ("run_id", observation.run_id),
140
+ ("guid", observation.run_guid),
141
+ ("result_table_name", observation.result_table_name),
142
+ ("result_count", observation.result_watermark),
143
+ ),
144
+ ),
145
+ ),
146
+ (
147
+ "parameters",
148
+ tuple(
149
+ (
150
+ parameter.name,
151
+ parameter.label,
152
+ parameter.unit,
153
+ parameter.depends_on,
154
+ parameter.paramtype,
155
+ )
156
+ for parameter in observation.parameters
157
+ ),
158
+ ),
159
+ (
160
+ "setpoint_summaries",
161
+ tuple(
162
+ (summary.name, summary.first, summary.last, summary.steps)
163
+ for summary in observation.setpoint_summaries
164
+ ),
165
+ ),
166
+ )
167
+ return payload
168
+
169
+
170
+ def render_trusted_derived_payload(
171
+ observation: TrustedDerivedSourceObservation,
172
+ kind: TrustedWorkKind,
173
+ options: RenderingOptions = _DEFAULT_RENDERING_OPTIONS,
174
+ *,
175
+ cancel_check: CancelCheck = lambda: None,
176
+ ) -> DerivedPayload:
177
+ """Render one bounded observation into versioned primitive/PNG payloads."""
178
+
179
+ if not isinstance(observation, TrustedDerivedSourceObservation):
180
+ raise TypeError("observation must be TrustedDerivedSourceObservation.")
181
+ if not isinstance(kind, TrustedWorkKind):
182
+ raise TypeError("kind must be TrustedWorkKind.")
183
+ if not isinstance(options, RenderingOptions):
184
+ raise TypeError("options must be RenderingOptions.")
185
+ cancel_check()
186
+ if kind is TrustedWorkKind.METADATA:
187
+ return _metadata_payload(observation)
188
+ if observation.unsupported_reason:
189
+ return _base_payload(
190
+ observation,
191
+ kind,
192
+ status="unsupported",
193
+ description=observation.unsupported_reason,
194
+ )
195
+ if observation.result_watermark == 0 or not observation.sample_rows:
196
+ return _base_payload(
197
+ observation,
198
+ kind,
199
+ status="empty",
200
+ description="The captured result prefix is empty.",
201
+ )
202
+
203
+ default_width, default_height = (
204
+ (160, 96) if kind is TrustedWorkKind.THUMBNAIL else (800, 500)
205
+ )
206
+ width = _option_integer(
207
+ options, "width", default_width, TRUSTED_DERIVED_MAX_IMAGE_WIDTH
208
+ )
209
+ height = _option_integer(
210
+ options, "height", default_height, TRUSTED_DERIVED_MAX_IMAGE_HEIGHT
211
+ )
212
+ if width * height * 4 > TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES:
213
+ raise TrustedDerivedRenderingError("The decoded image budget was exceeded.")
214
+
215
+ column_indexes = {
216
+ name: index for index, name in enumerate(observation.sample_columns)
217
+ }
218
+ parameter_by_name = {
219
+ parameter.name: parameter for parameter in observation.parameters
220
+ }
221
+ dependents = observation.dependent_parameters
222
+ dependents = dependents[:TRUSTED_DERIVED_MAX_IMAGES]
223
+ images: list[DerivedValue] = []
224
+ unavailable_descriptions: list[str] = []
225
+ encoded_total = 0
226
+ for dependent_index, dependent in enumerate(dependents):
227
+ cancel_check()
228
+ parameter = parameter_by_name.get(dependent)
229
+ dependencies = parameter.depends_on if parameter is not None else ()
230
+ try:
231
+ if len(dependencies) > 2:
232
+ raise _UnsupportedNumericData(
233
+ "Results with more than two sweep dimensions are unsupported."
234
+ )
235
+ if (
236
+ not dependencies
237
+ or dependent not in column_indexes
238
+ or any(name not in column_indexes for name in dependencies)
239
+ ):
240
+ raise _UnsupportedNumericData(
241
+ "The dependent or one of its sweep parameters is unavailable."
242
+ )
243
+ array_tree = any(
244
+ parameter_by_name[name].paramtype == "array"
245
+ for name in (*dependencies, dependent)
246
+ if name in parameter_by_name
247
+ )
248
+ if array_tree:
249
+ from .trusted_array_preview import render_array_preview
250
+
251
+ rgba, points = render_array_preview(
252
+ observation, dependencies, dependent, width, height, cancel_check,
253
+ )
254
+ dimensionality = len(dependencies)
255
+ elif len(dependencies) == 1:
256
+ rgba, points = _render_1d(
257
+ observation,
258
+ dependencies[0],
259
+ dependent,
260
+ width,
261
+ height,
262
+ cancel_check,
263
+ )
264
+ dimensionality = 1
265
+ elif len(dependencies) == 2:
266
+ layout = next(
267
+ (
268
+ candidate
269
+ for candidate in observation.validated_2d_layouts
270
+ if candidate.dependent == dependent
271
+ and candidate.dependencies == dependencies
272
+ ),
273
+ None,
274
+ )
275
+ if layout is None:
276
+ raise _UnsupportedNumericData(
277
+ "A validated rectangular 2D layout is not yet available."
278
+ )
279
+ rgba, points = _render_2d(
280
+ observation,
281
+ layout,
282
+ width,
283
+ height,
284
+ cancel_check,
285
+ )
286
+ dimensionality = 2
287
+ except _UnsupportedNumericData as error:
288
+ unavailable_descriptions.append(str(error))
289
+ continue
290
+ cancel_check()
291
+ if points == 0:
292
+ continue
293
+ encoded = _encode_png_rgba(width, height, rgba, cancel_check)
294
+ encoded_total += len(encoded)
295
+ if encoded_total > TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES:
296
+ raise TrustedDerivedRenderingError("The encoded image budget was exceeded.")
297
+ images.append(
298
+ (
299
+ ("encoding", "png"),
300
+ ("width", width),
301
+ ("height", height),
302
+ ("dependent", dependent),
303
+ ("dimensions", dimensionality),
304
+ ("sampled_points", points),
305
+ ("bytes", encoded),
306
+ )
307
+ )
308
+ if dependent_index + 1 >= TRUSTED_DERIVED_MAX_IMAGES:
309
+ break
310
+ if not images:
311
+ return _base_payload(
312
+ observation,
313
+ kind,
314
+ status="unsupported",
315
+ description=(
316
+ unavailable_descriptions[0]
317
+ if unavailable_descriptions
318
+ else "No bounded numeric 1D or 2D dependent data was available."
319
+ ),
320
+ )
321
+ payload = _base_payload(
322
+ observation,
323
+ kind,
324
+ status="ok",
325
+ description=(
326
+ "Bounded trusted result-domain rendering; some dependents remain "
327
+ f"unavailable: {unavailable_descriptions[0]}"
328
+ if unavailable_descriptions
329
+ else "Bounded trusted result-domain rendering."
330
+ ),
331
+ )
332
+ payload["images"] = tuple(images)
333
+ return payload
334
+
335
+
336
+ def _render_1d(
337
+ observation: TrustedDerivedSourceObservation,
338
+ x_name: str,
339
+ y_name: str,
340
+ width: int,
341
+ height: int,
342
+ cancel_check: CancelCheck,
343
+ ) -> tuple[bytearray, int]:
344
+ indexes = {name: index for index, name in enumerate(observation.sample_columns)}
345
+ points: list[tuple[float, float]] = []
346
+ for index, row in enumerate(observation.sample_rows):
347
+ if index % 128 == 0:
348
+ cancel_check()
349
+ if len(row) != len(observation.sample_columns):
350
+ raise _UnsupportedNumericData(
351
+ "The bounded numeric sample has inconsistent vector lengths."
352
+ )
353
+ x = _finite_number(row[indexes[x_name]])
354
+ y = _finite_number(row[indexes[y_name]])
355
+ if x is not None and y is not None:
356
+ points.append((x, y))
357
+ rgba = bytearray(b"\xff" * (width * height * 4))
358
+ if not points:
359
+ return rgba, 0
360
+ x_values = tuple(point[0] for point in points)
361
+ y_values = tuple(point[1] for point in points)
362
+ x_min, x_max = min(x_values), max(x_values)
363
+ y_min, y_max = min(y_values), max(y_values)
364
+ margin = max(2, min(width, height) // 20)
365
+ prior: tuple[int, int] | None = None
366
+ for index, (x, y) in enumerate(points):
367
+ if index % 128 == 0:
368
+ cancel_check()
369
+ raw_px = _normalised_position(x, x_min, x_max) * (width - 1 - 2 * margin)
370
+ raw_py = _normalised_position(y, y_min, y_max) * (height - 1 - 2 * margin)
371
+ if not math.isfinite(raw_px) or not math.isfinite(raw_py):
372
+ raise _UnsupportedNumericData(
373
+ "The bounded numeric sample exceeds the supported finite range."
374
+ )
375
+ px = margin + round(raw_px)
376
+ py = height - 1 - margin - round(raw_py)
377
+ if prior is not None:
378
+ _line(rgba, width, height, prior[0], prior[1], px, py, (24, 92, 170, 255))
379
+ prior = (px, py)
380
+ if len(points) == 1 and prior is not None:
381
+ _pixel(
382
+ rgba,
383
+ width,
384
+ min(width - 1, max(0, prior[0])),
385
+ min(height - 1, max(0, prior[1])),
386
+ (24, 92, 170, 255),
387
+ )
388
+ return rgba, len(points)
389
+
390
+
391
+ def _render_2d(
392
+ observation: TrustedDerivedSourceObservation,
393
+ layout: Trusted2DGridLayout,
394
+ width: int,
395
+ height: int,
396
+ cancel_check: CancelCheck,
397
+ ) -> tuple[bytearray, int]:
398
+ indexes = {name: index for index, name in enumerate(observation.sample_columns)}
399
+ fast_name = layout.dependencies[layout.fast_axis_index]
400
+ slow_name = layout.dependencies[1 - layout.fast_axis_index]
401
+ z_name = layout.dependent
402
+ if any(name not in indexes for name in (slow_name, fast_name, z_name)):
403
+ raise _UnsupportedNumericData(
404
+ "The validated 2D layout is absent from the bounded numeric sample."
405
+ )
406
+ slow_count = layout.shape[1 - layout.fast_axis_index]
407
+ fast_count = layout.shape[layout.fast_axis_index]
408
+ sampled_slow = layout.sample_slow_indexes
409
+ sampled_fast = layout.sample_fast_indexes
410
+ slow_bins = {value: index for index, value in enumerate(sampled_slow)}
411
+ fast_bins = {value: index for index, value in enumerate(sampled_fast)}
412
+ if layout.fast_axis_index == 0:
413
+ sampled_vertical = sampled_fast
414
+ sampled_horizontal = sampled_slow
415
+ vertical_count = fast_count
416
+ horizontal_count = slow_count
417
+ else:
418
+ sampled_vertical = sampled_slow
419
+ sampled_horizontal = sampled_fast
420
+ vertical_count = slow_count
421
+ horizontal_count = fast_count
422
+ grid_width = len(sampled_horizontal)
423
+ grid_size = len(sampled_vertical) * grid_width
424
+ grid_sum = [0.0] * grid_size
425
+ grid_count = [0] * grid_size
426
+ points = 0
427
+ for index, row in enumerate(observation.sample_rows):
428
+ if index % 128 == 0:
429
+ cancel_check()
430
+ if len(row) != len(observation.sample_columns):
431
+ raise _UnsupportedNumericData(
432
+ "The bounded numeric sample has inconsistent vector lengths."
433
+ )
434
+ row_id = row[0]
435
+ if type(row_id) is not int:
436
+ raise _UnsupportedNumericData(
437
+ "The bounded grid sample has an invalid result id."
438
+ )
439
+ grid_index = _layout_grid_index(
440
+ layout,
441
+ row_id,
442
+ slow_count=slow_count,
443
+ fast_count=fast_count,
444
+ )
445
+ if grid_index is None:
446
+ continue
447
+ slow = _finite_number(row[indexes[slow_name]])
448
+ fast = _finite_number(row[indexes[fast_name]])
449
+ z = _finite_number(row[indexes[z_name]])
450
+ if slow is None or fast is None:
451
+ raise _UnsupportedNumericData(
452
+ "The validated grid sample has a non-numeric dependency."
453
+ )
454
+ if z is None:
455
+ continue
456
+ slow_index, fast_index = grid_index
457
+ slow_bin = slow_bins.get(slow_index)
458
+ if slow_bin is None:
459
+ slow_bin = _scaled_grid_index(
460
+ slow_index,
461
+ slow_count,
462
+ len(sampled_slow),
463
+ )
464
+ fast_bin = fast_bins.get(fast_index)
465
+ if fast_bin is None:
466
+ fast_bin = _scaled_grid_index(
467
+ fast_index,
468
+ fast_count,
469
+ len(sampled_fast),
470
+ )
471
+ scientific_slow_bin = (
472
+ len(sampled_slow) - 1 - slow_bin if layout.slow_reversed else slow_bin
473
+ )
474
+ row_fast_reversed = layout.fast_reversed ^ (
475
+ layout.serpentine and bool(slow_index % 2)
476
+ )
477
+ scientific_fast_bin = (
478
+ len(sampled_fast) - 1 - fast_bin if row_fast_reversed else fast_bin
479
+ )
480
+ if layout.fast_axis_index == 0:
481
+ vertical_bin = scientific_fast_bin
482
+ horizontal_bin = scientific_slow_bin
483
+ else:
484
+ vertical_bin = scientific_slow_bin
485
+ horizontal_bin = scientific_fast_bin
486
+ display_row = len(sampled_vertical) - 1 - vertical_bin
487
+ target = display_row * grid_width + horizontal_bin
488
+ combined = grid_sum[target] + z
489
+ if not math.isfinite(combined):
490
+ raise _UnsupportedNumericData(
491
+ "The bounded numeric sample exceeds the supported finite range."
492
+ )
493
+ grid_sum[target] = combined
494
+ grid_count[target] += 1
495
+ points += 1
496
+ rgba = bytearray(width * height * 4)
497
+ if points == 0:
498
+ for offset in range(0, len(rgba), 4):
499
+ rgba[offset : offset + 4] = bytes(_MISSING_CELL_RGBA)
500
+ return rgba, 0
501
+ grid = tuple(
502
+ grid_sum[index] / count if count else None
503
+ for index, count in enumerate(grid_count)
504
+ )
505
+ finite = tuple(value for value in grid if value is not None)
506
+ z_min, z_max = min(finite), max(finite)
507
+ source_rows = _nearest_sample_bins(
508
+ height,
509
+ vertical_count,
510
+ sampled_vertical,
511
+ )
512
+ source_columns = _nearest_sample_bins(
513
+ width,
514
+ horizontal_count,
515
+ sampled_horizontal,
516
+ )
517
+ for py in range(height):
518
+ if py % 32 == 0:
519
+ cancel_check()
520
+ source_row = source_rows[py]
521
+ for px, source_column in enumerate(source_columns):
522
+ value = grid[source_row * grid_width + source_column]
523
+ color = (
524
+ _MISSING_CELL_RGBA
525
+ if value is None
526
+ else _viridis_rgba(value, z_min, z_max)
527
+ )
528
+ _pixel(rgba, width, px, py, color)
529
+ return rgba, points
530
+
531
+
532
+ def _layout_grid_index(
533
+ layout: Trusted2DGridLayout,
534
+ row_id: int,
535
+ *,
536
+ slow_count: int,
537
+ fast_count: int,
538
+ ) -> tuple[int, int] | None:
539
+ relative = row_id - layout.first_row_id
540
+ if relative < 0:
541
+ return None
542
+ slow_index, remainder = divmod(relative, layout.slow_id_stride)
543
+ if remainder % layout.fast_id_stride:
544
+ return None
545
+ fast_index = remainder // layout.fast_id_stride
546
+ if slow_index >= slow_count or fast_index >= fast_count:
547
+ return None
548
+ return slow_index, fast_index
549
+
550
+
551
+ def _scaled_grid_index(index: int, source_count: int, target_count: int) -> int:
552
+ if source_count <= 1 or target_count <= 1:
553
+ return 0
554
+ return min(target_count - 1, index * (target_count - 1) // (source_count - 1))
555
+
556
+
557
+ def _nearest_sample_bins(
558
+ pixel_count: int,
559
+ source_count: int,
560
+ sampled_indexes: tuple[int, ...],
561
+ ) -> tuple[int, ...]:
562
+ """Map display pixels to their nearest sampled source-domain coordinate.
563
+
564
+ Representative samples are almost uniform, but they also contain adjacent
565
+ edge anchors used to prove acquisition direction. Treating every sample
566
+ rank as equally wide would make a single edge cell occupy the same image
567
+ width as a sample representing thousands of source cells. Integer-scaled
568
+ nearest-neighbour lookup preserves the true source-index spacing without
569
+ losing either endpoint or using imprecise large-axis floats.
570
+ """
571
+
572
+ if pixel_count <= 0 or source_count <= 0 or not sampled_indexes:
573
+ raise ValueError("Source-domain sampling requires positive dimensions.")
574
+ targets: tuple[int, ...]
575
+ if pixel_count == 1:
576
+ denominator = 2
577
+ targets = (source_count - 1,)
578
+ else:
579
+ denominator = pixel_count - 1
580
+ targets = tuple(pixel * (source_count - 1) for pixel in range(pixel_count))
581
+ scaled_samples = tuple(index * denominator for index in sampled_indexes)
582
+ output: list[int] = []
583
+ for target in targets:
584
+ upper = bisect_left(scaled_samples, target)
585
+ if upper == 0:
586
+ output.append(0)
587
+ elif upper == len(scaled_samples):
588
+ output.append(len(scaled_samples) - 1)
589
+ else:
590
+ lower = upper - 1
591
+ output.append(
592
+ lower
593
+ if target - scaled_samples[lower] <= scaled_samples[upper] - target
594
+ else upper
595
+ )
596
+ return tuple(output)
597
+
598
+
599
+ def _normalised_position(value, low, high):
600
+ """Map finite samples before pixel scaling can overflow their range."""
601
+ if high == low:
602
+ return 0.
603
+ samples = (value, low, high)
604
+ integer_count = sum(isinstance(sample, int) for sample in samples)
605
+ if (any(isinstance(sample, Fraction) for sample in samples)
606
+ or (0 < integer_count < 3 and max(map(abs, samples)) >= 2**53)):
607
+ # Mixed array records can retain an integer between adjacent floats.
608
+ # Subtract exactly before normalizing, rather than rounding that
609
+ # integer to its neighbouring float and losing a recorded step.
610
+ # Repeated integer heatmap cells retain their exact fractional means.
611
+ value, low, high = map(Fraction, samples)
612
+ return float((value - low) / (high - low))
613
+ span = high - low
614
+ if math.isfinite(span):
615
+ return (value - low) / span
616
+ scale = max(abs(low), abs(high))
617
+ return (value / scale - low / scale) / (high / scale - low / scale)
618
+
619
+
620
+ def _viridis_rgba(
621
+ value: float,
622
+ low: float,
623
+ high: float,
624
+ ) -> tuple[int, int, int, int]:
625
+ fraction = 0.5 if high == low else _normalised_position(value, low, high)
626
+ fraction = max(0.0, min(1.0, fraction))
627
+ color_index = min(255, int(fraction * 256.0))
628
+ offset = color_index * 3
629
+ return (
630
+ _VIRIDIS_RGB[offset],
631
+ _VIRIDIS_RGB[offset + 1],
632
+ _VIRIDIS_RGB[offset + 2],
633
+ 255,
634
+ )
635
+
636
+
637
+ def _pixel(
638
+ rgba: bytearray,
639
+ width: int,
640
+ x: int,
641
+ y: int,
642
+ color: tuple[int, int, int, int],
643
+ ) -> None:
644
+ offset = (y * width + x) * 4
645
+ rgba[offset : offset + 4] = bytes(color)
646
+
647
+
648
+ def _line(
649
+ rgba: bytearray,
650
+ width: int,
651
+ height: int,
652
+ x0: int,
653
+ y0: int,
654
+ x1: int,
655
+ y1: int,
656
+ color: tuple[int, int, int, int],
657
+ ) -> None:
658
+ dx, sx = abs(x1 - x0), 1 if x0 < x1 else -1
659
+ dy, sy = -abs(y1 - y0), 1 if y0 < y1 else -1
660
+ error = dx + dy
661
+ while True:
662
+ if 0 <= x0 < width and 0 <= y0 < height:
663
+ _pixel(rgba, width, x0, y0, color)
664
+ if x0 == x1 and y0 == y1:
665
+ break
666
+ doubled = 2 * error
667
+ if doubled >= dy:
668
+ error += dy
669
+ x0 += sx
670
+ if doubled <= dx:
671
+ error += dx
672
+ y0 += sy
673
+
674
+
675
+ def _png_chunk(kind: bytes, data: bytes) -> bytes:
676
+ checksum = binascii.crc32(kind)
677
+ checksum = binascii.crc32(data, checksum) & 0xFFFFFFFF
678
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum)
679
+
680
+
681
+ def _encode_png_rgba(
682
+ width: int,
683
+ height: int,
684
+ rgba: bytearray,
685
+ cancel_check: CancelCheck,
686
+ ) -> bytes:
687
+ expected = width * height * 4
688
+ if len(rgba) != expected or expected > TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES:
689
+ raise TrustedDerivedRenderingError("The decoded image has invalid bounds.")
690
+ rows = bytearray(height * (width * 4 + 1))
691
+ source_stride = width * 4
692
+ target_stride = source_stride + 1
693
+ for y in range(height):
694
+ if y % 32 == 0:
695
+ cancel_check()
696
+ source = y * source_stride
697
+ target = y * target_stride
698
+ rows[target] = 0
699
+ rows[target + 1 : target + target_stride] = rgba[
700
+ source : source + source_stride
701
+ ]
702
+ cancel_check()
703
+ compressed = zlib.compress(bytes(rows), level=9)
704
+ result = (
705
+ b"\x89PNG\r\n\x1a\n"
706
+ + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
707
+ + _png_chunk(b"IDAT", compressed)
708
+ + _png_chunk(b"IEND", b"")
709
+ )
710
+ if len(result) > TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES:
711
+ raise TrustedDerivedRenderingError("The encoded image is too large.")
712
+ return result
713
+
714
+
715
+ def trusted_derived_payload_size(payload: DerivedPayload) -> int:
716
+ """Conservatively size the retained primitive graph without serialising it."""
717
+
718
+ total = 0
719
+ nodes = 0
720
+ active_containers: set[int] = set()
721
+ stack: list[tuple[bool, Any, int]] = [(False, payload, 0)]
722
+ while stack:
723
+ leaving, value, depth = stack.pop()
724
+ if leaving:
725
+ active_containers.discard(cast(int, value))
726
+ continue
727
+ if depth > TRUSTED_DERIVED_MAX_PAYLOAD_DEPTH:
728
+ raise TrustedDerivedRenderingError(
729
+ "A derived payload is too deeply nested."
730
+ )
731
+ nodes += 1
732
+ if nodes > TRUSTED_DERIVED_MAX_PAYLOAD_NODES:
733
+ raise TrustedDerivedRenderingError("A derived payload has too many nodes.")
734
+ if value is None or isinstance(value, bool):
735
+ total += 8
736
+ elif isinstance(value, (int, float)):
737
+ if isinstance(value, float) and not math.isfinite(value):
738
+ raise TrustedDerivedRenderingError(
739
+ "A derived payload contains a non-finite scalar."
740
+ )
741
+ total += 32
742
+ elif isinstance(value, str):
743
+ encoded = value.encode("utf-8")
744
+ if len(encoded) > TRUSTED_DERIVED_MAX_TEXT_BYTES:
745
+ raise TrustedDerivedRenderingError(
746
+ "A derived payload text value is oversized."
747
+ )
748
+ total += len(encoded) + 32
749
+ elif isinstance(value, bytes):
750
+ if len(value) > TRUSTED_DERIVED_MAX_RETAINED_PAYLOAD_BYTES:
751
+ raise TrustedDerivedRenderingError(
752
+ "A derived payload byte value is oversized."
753
+ )
754
+ total += len(value) + 32
755
+ elif isinstance(value, tuple):
756
+ if len(value) > TRUSTED_DERIVED_MAX_PAYLOAD_ITEMS:
757
+ raise TrustedDerivedRenderingError(
758
+ "A derived payload tuple has too many items."
759
+ )
760
+ identity = id(value)
761
+ if identity in active_containers:
762
+ raise TrustedDerivedRenderingError("A derived payload is cyclic.")
763
+ active_containers.add(identity)
764
+ total += 32 + len(value) * 8
765
+ stack.append((True, identity, depth))
766
+ stack.extend((False, item, depth + 1) for item in value)
767
+ elif isinstance(value, dict):
768
+ if len(value) > TRUSTED_DERIVED_MAX_PAYLOAD_ITEMS:
769
+ raise TrustedDerivedRenderingError(
770
+ "A derived payload dictionary has too many items."
771
+ )
772
+ identity = id(value)
773
+ if identity in active_containers:
774
+ raise TrustedDerivedRenderingError("A derived payload is cyclic.")
775
+ active_containers.add(identity)
776
+ total += 64 + len(value) * 16
777
+ stack.append((True, identity, depth))
778
+ stack.extend((False, item, depth + 1) for item in value.keys())
779
+ stack.extend((False, item, depth + 1) for item in value.values())
780
+ else:
781
+ raise TrustedDerivedRenderingError("A derived payload is not primitive.")
782
+ if total > TRUSTED_DERIVED_MAX_RETAINED_PAYLOAD_BYTES:
783
+ raise TrustedDerivedRenderingError(
784
+ "The retained payload budget was exceeded."
785
+ )
786
+ return total
787
+
788
+
789
+ def _pair_mapping(value: object, *, name: str, maximum: int) -> dict[str, object]:
790
+ if not isinstance(value, tuple) or len(value) > maximum:
791
+ raise TrustedDerivedRenderingError(f"The derived {name} structure is invalid.")
792
+ output: dict[str, object] = {}
793
+ for item in value:
794
+ if (
795
+ not isinstance(item, tuple)
796
+ or len(item) != 2
797
+ or not isinstance(item[0], str)
798
+ or item[0] in output
799
+ ):
800
+ raise TrustedDerivedRenderingError(
801
+ f"The derived {name} structure is invalid."
802
+ )
803
+ output[item[0]] = item[1]
804
+ return output
805
+
806
+
807
+ def validate_trusted_derived_payload(payload: DerivedPayload) -> int:
808
+ """Validate the retained/cache schema and return its conservative size."""
809
+
810
+ if not isinstance(payload, dict) or any(
811
+ not isinstance(name, str) for name in payload
812
+ ):
813
+ raise TrustedDerivedRenderingError("A derived payload root is invalid.")
814
+ allowed = {
815
+ "format",
816
+ "kind",
817
+ "status",
818
+ "description",
819
+ "source",
820
+ "images",
821
+ "metadata",
822
+ }
823
+ required = {"format", "kind", "status", "description", "source", "images"}
824
+ if set(payload) - allowed or not required.issubset(payload):
825
+ raise TrustedDerivedRenderingError("A derived payload field is invalid.")
826
+ if payload["format"] != "qplot-trusted-derived-payload-v1":
827
+ raise TrustedDerivedRenderingError("A derived payload format is unsupported.")
828
+ if payload["kind"] not in {"thumbnail", "preview", "metadata"}:
829
+ raise TrustedDerivedRenderingError("A derived payload kind is invalid.")
830
+ if payload["status"] not in {"ok", "empty", "unsupported", "error"}:
831
+ raise TrustedDerivedRenderingError("A derived payload status is invalid.")
832
+ if not isinstance(payload["description"], str):
833
+ raise TrustedDerivedRenderingError("A derived payload description is invalid.")
834
+ _pair_mapping(payload["source"], name="source", maximum=32)
835
+ images = payload["images"]
836
+ if not isinstance(images, tuple) or len(images) > TRUSTED_DERIVED_MAX_IMAGES:
837
+ raise TrustedDerivedRenderingError("The derived image collection is invalid.")
838
+ encoded_total = 0
839
+ for image in images:
840
+ fields = _pair_mapping(image, name="image", maximum=16)
841
+ if set(fields) != {
842
+ "encoding",
843
+ "width",
844
+ "height",
845
+ "dependent",
846
+ "dimensions",
847
+ "sampled_points",
848
+ "bytes",
849
+ }:
850
+ raise TrustedDerivedRenderingError("A derived image field is invalid.")
851
+ width, height = fields["width"], fields["height"]
852
+ encoded = fields["bytes"]
853
+ if (
854
+ fields["encoding"] != "png"
855
+ or type(width) is not int
856
+ or type(height) is not int
857
+ or not 1 <= width <= TRUSTED_DERIVED_MAX_IMAGE_WIDTH
858
+ or not 1 <= height <= TRUSTED_DERIVED_MAX_IMAGE_HEIGHT
859
+ or width * height * 4 > TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES
860
+ or not isinstance(fields["dependent"], str)
861
+ or fields["dimensions"] not in (1, 2)
862
+ or type(fields["sampled_points"]) is not int
863
+ or fields["sampled_points"] < 0
864
+ or not isinstance(encoded, bytes)
865
+ or not encoded.startswith(b"\x89PNG\r\n\x1a\n")
866
+ ):
867
+ raise TrustedDerivedRenderingError("A derived image value is invalid.")
868
+ encoded_total += len(encoded)
869
+ if encoded_total > TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES:
870
+ raise TrustedDerivedRenderingError("The encoded image budget was exceeded.")
871
+ metadata = payload.get("metadata")
872
+ if metadata is not None:
873
+ if payload["kind"] != "metadata":
874
+ raise TrustedDerivedRenderingError("Derived metadata has the wrong kind.")
875
+ _pair_mapping(metadata, name="metadata", maximum=64)
876
+ return trusted_derived_payload_size(payload)