qplotter 1.6.0b3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. qplot/__init__.py +46 -0
  2. qplot/__main__.py +232 -0
  3. qplot/_auxiliary_paths.py +63 -0
  4. qplot/_metadata.py +12 -0
  5. qplot/_repair.py +14 -0
  6. qplot/_shutdown_supervisor.py +4049 -0
  7. qplot/_version.py +12 -0
  8. qplot/_windows_shutdown_job.py +1166 -0
  9. qplot/configuration/__init__.py +3 -0
  10. qplot/configuration/config.py +573 -0
  11. qplot/configuration/config_schema.json +319 -0
  12. qplot/configuration/scripts.py +255 -0
  13. qplot/configuration/themes/__init__.py +13 -0
  14. qplot/configuration/themes/_base.py +579 -0
  15. qplot/configuration/themes/blank.py +17 -0
  16. qplot/configuration/themes/dark.py +54 -0
  17. qplot/configuration/themes/light.py +54 -0
  18. qplot/datahandling/LoadFromDB.py +224 -0
  19. qplot/datahandling/__init__.py +47 -0
  20. qplot/datahandling/_trusted_live_helper.py +812 -0
  21. qplot/datahandling/_trusted_live_protocol.py +1745 -0
  22. qplot/datahandling/database.py +2330 -0
  23. qplot/datahandling/dimensions.py +43 -0
  24. qplot/datahandling/file_identity.py +461 -0
  25. qplot/datahandling/parameter_data.py +149 -0
  26. qplot/datahandling/plot_progress.py +17 -0
  27. qplot/datahandling/qcodes_cache.py +277 -0
  28. qplot/datahandling/qcodes_compat.py +14 -0
  29. qplot/datahandling/readSQL.py +2442 -0
  30. qplot/datahandling/readonly.py +2561 -0
  31. qplot/datahandling/source_activity.py +21 -0
  32. qplot/datahandling/trusted_array_preview.py +127 -0
  33. qplot/datahandling/trusted_derived_cache.py +1605 -0
  34. qplot/datahandling/trusted_derived_rendering.py +868 -0
  35. qplot/datahandling/trusted_heatmap.py +241 -0
  36. qplot/datahandling/trusted_live.py +2808 -0
  37. qplot/datahandling/trusted_live_queries.py +5907 -0
  38. qplot/datahandling/trusted_live_service.py +1894 -0
  39. qplot/datahandling/trusted_live_supervisor.py +2161 -0
  40. qplot/datahandling/trusted_plot.py +283 -0
  41. qplot/datahandling/trusted_presentation.py +1065 -0
  42. qplot/datahandling/trusted_snapshot.py +1132 -0
  43. qplot/datahandling/trusted_work_coordinator.py +2157 -0
  44. qplot/datahandling/trusted_work_scheduler.py +1204 -0
  45. qplot/diagnostics.py +241 -0
  46. qplot/resources/testdata/qplot_test_db_01_10mb.csv +11 -0
  47. qplot/resources/testdata/qplot_test_db_02_25mb.csv +14 -0
  48. qplot/resources/testdata/qplot_test_db_03_50mb.csv +17 -0
  49. qplot/resources/testdata/qplot_test_db_04_100mb.csv +20 -0
  50. qplot/resources/testdata/qplot_test_db_05_250mb.csv +23 -0
  51. qplot/resources/testdata/qplot_test_db_06_500mb.csv +26 -0
  52. qplot/resources/testdata/qplot_test_db_07_1gb.csv +29 -0
  53. qplot/resources/testdata/qplot_test_db_08_5gb.csv +32 -0
  54. qplot/resources/testdata/qplot_test_db_09_10gb.csv +35 -0
  55. qplot/resources/testdata/qplot_test_db_10_30gb.csv +38 -0
  56. qplot/testdata.py +1733 -0
  57. qplot/tools/__init__.py +13 -0
  58. qplot/tools/finite_means.py +114 -0
  59. qplot/tools/general.py +134 -0
  60. qplot/tools/heatmap_geometry.py +381 -0
  61. qplot/tools/operation_registry.py +179 -0
  62. qplot/tools/plot_tools.py +625 -0
  63. qplot/tools/sample_statistics.py +56 -0
  64. qplot/tools/worker.py +2787 -0
  65. qplot/windows/__init__.py +9 -0
  66. qplot/windows/_colorbar.py +422 -0
  67. qplot/windows/_commands.py +523 -0
  68. qplot/windows/_config_persistence.py +123 -0
  69. qplot/windows/_database_actions.py +4904 -0
  70. qplot/windows/_dataset_handle.py +221 -0
  71. qplot/windows/_dragdrop.py +71 -0
  72. qplot/windows/_export_paths.py +1167 -0
  73. qplot/windows/_help.py +169 -0
  74. qplot/windows/_native_averaging.py +173 -0
  75. qplot/windows/_native_transforms.py +389 -0
  76. qplot/windows/_plot1d_snap.py +608 -0
  77. qplot/windows/_plot1d_traces.py +2183 -0
  78. qplot/windows/_plot2d_colorbar.py +1126 -0
  79. qplot/windows/_plot2d_colorbar_dialog.py +786 -0
  80. qplot/windows/_plot2d_layers.py +2197 -0
  81. qplot/windows/_plot2d_sweeps.py +712 -0
  82. qplot/windows/_plotWin.py +1993 -0
  83. qplot/windows/_plot_actions.py +2695 -0
  84. qplot/windows/_plot_appearance.py +276 -0
  85. qplot/windows/_plot_axis_scaling.py +1485 -0
  86. qplot/windows/_plot_export.py +1445 -0
  87. qplot/windows/_plot_feedback.py +110 -0
  88. qplot/windows/_plot_marquee.py +656 -0
  89. qplot/windows/_plot_refresh.py +942 -0
  90. qplot/windows/_plot_state.py +239 -0
  91. qplot/windows/_plot_transform_labels.py +60 -0
  92. qplot/windows/_preferences.py +635 -0
  93. qplot/windows/_refresh_interval.py +70 -0
  94. qplot/windows/_run_controls.py +828 -0
  95. qplot/windows/_shortcuts.py +47 -0
  96. qplot/windows/_subplots/__init__.py +8 -0
  97. qplot/windows/_subplots/subplot1d.py +531 -0
  98. qplot/windows/_subplots/subplot2d.py +918 -0
  99. qplot/windows/_svg_export.py +209 -0
  100. qplot/windows/_trusted_derived_qt.py +1759 -0
  101. qplot/windows/_widgets/__init__.py +23 -0
  102. qplot/windows/_widgets/_run_formatting.py +387 -0
  103. qplot/windows/_widgets/details_tables.py +1119 -0
  104. qplot/windows/_widgets/dropbox.py +290 -0
  105. qplot/windows/_widgets/operations.py +460 -0
  106. qplot/windows/_widgets/preview.py +2997 -0
  107. qplot/windows/_widgets/run_list_items.py +488 -0
  108. qplot/windows/_widgets/toolbar.py +111 -0
  109. qplot/windows/_widgets/treeWidgets.py +2572 -0
  110. qplot/windows/_window_controls.py +413 -0
  111. qplot/windows/main.py +1635 -0
  112. qplot/windows/plot1d.py +311 -0
  113. qplot/windows/plot2d.py +1901 -0
  114. qplotter-1.6.0b3.dist-info/METADATA +318 -0
  115. qplotter-1.6.0b3.dist-info/RECORD +119 -0
  116. qplotter-1.6.0b3.dist-info/WHEEL +5 -0
  117. qplotter-1.6.0b3.dist-info/entry_points.txt +4 -0
  118. qplotter-1.6.0b3.dist-info/licenses/LICENSE +21 -0
  119. qplotter-1.6.0b3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,868 @@
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 typing import Any, TypeAlias, cast
12
+
13
+ from qplot.datahandling.trusted_live_queries import (
14
+ Trusted2DGridLayout,
15
+ TrustedDerivedSourceObservation,
16
+ )
17
+ from qplot.datahandling.trusted_work_scheduler import (
18
+ RenderingOptions,
19
+ TrustedWorkKind,
20
+ )
21
+
22
+ TRUSTED_DERIVED_RENDERER_VERSION = "trusted-derived-renderer-v6"
23
+ TRUSTED_DERIVED_MAX_IMAGES = 8
24
+ TRUSTED_DERIVED_MAX_IMAGE_WIDTH = 2_048
25
+ TRUSTED_DERIVED_MAX_IMAGE_HEIGHT = 2_048
26
+ TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES = 16 * 1024 * 1024
27
+ TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES = 8 * 1024 * 1024
28
+ TRUSTED_DERIVED_MAX_RETAINED_PAYLOAD_BYTES = 16 * 1024 * 1024
29
+ TRUSTED_DERIVED_MAX_PAYLOAD_DEPTH = 32
30
+ TRUSTED_DERIVED_MAX_PAYLOAD_NODES = 131_072
31
+ TRUSTED_DERIVED_MAX_PAYLOAD_ITEMS = 65_536
32
+ TRUSTED_DERIVED_MAX_TEXT_BYTES = 1024 * 1024
33
+
34
+ DerivedScalar: TypeAlias = None | bool | int | float | str | bytes
35
+ DerivedValue: TypeAlias = DerivedScalar | tuple["DerivedValue", ...]
36
+ DerivedPayload: TypeAlias = dict[str, DerivedValue]
37
+ CancelCheck: TypeAlias = Callable[[], None]
38
+ _DEFAULT_RENDERING_OPTIONS = RenderingOptions()
39
+ _VIRIDIS_RGB = bytes.fromhex(
40
+ "44015444025544035745055845065a45085b46095c460b5e460c5f460e61470f62471163471265471466471567471669"
41
+ "47186a48196b481a6c481c6e481d6f481e70482071482172482273482374472575472676472777472878472a79472b7a"
42
+ "472c7b462d7c462f7c46307d46317e45327f45347f453580453681443781443982433a83433b83433c84423d84423e85"
43
+ "4240854141864142864043874044873f45873f47883e48883e49893d4a893d4b893d4c893c4d8a3c4e8a3b508a3b518a"
44
+ "3a528b3a538b39548b39558b38568b38578c37588c37598c365a8c365b8c355c8c355d8c345e8d345f8d33608d33618d"
45
+ "32628d32638d31648d31658d31668d30678d30688d2f698d2f6a8d2e6b8e2e6c8e2e6d8e2d6e8e2d6f8e2c708e2c718e"
46
+ "2c728e2b738e2b748e2a758e2a768e2a778e29788e29798e287a8e287a8e287b8e277c8e277d8e277e8e267f8e26808e"
47
+ "26818e25828e25838d24848d24858d24868d23878d23888d23898d22898d228a8d228b8d218c8d218d8c218e8c208f8c"
48
+ "20908c20918c1f928c1f938b1f948b1f958b1f968b1e978a1e988a1e998a1e998a1e9a891e9b891e9c891e9d881e9e88"
49
+ "1e9f881ea0871fa1871fa2861fa38620a48520a58521a68521a78422a78423a88323a98224aa8225ab8126ac8127ad80"
50
+ "28ae7f29af7f2ab07e2bb17d2cb17d2eb27c2fb37b30b47a32b57a33b67935b77836b87738b97639b9763bba753dbb74"
51
+ "3ebc7340bd7242be7144be7045bf6f47c06e49c16d4bc26c4dc26b4fc36951c46853c56755c66657c66559c7645bc862"
52
+ "5ec96160c96062ca5f64cb5d67cc5c69cc5b6bcd596dce5870ce5672cf5574d05477d05279d1517cd24f7ed24e81d34c"
53
+ "83d34b86d44988d5478bd5468dd64490d64392d74195d73f97d83e9ad83c9dd93a9fd938a2da37a5da35a7db33aadb32"
54
+ "addc30afdc2eb2dd2cb5dd2bb7dd29bade27bdde26bfdf24c2df22c5df21c7e01fcae01ecde01dcfe11cd2e11bd4e11a"
55
+ "d7e219dae218dce218dfe318e1e318e4e318e7e419e9e419ece41aeee51bf1e51cf3e51ef6e61ff8e621fae622fde724"
56
+ )
57
+ _MISSING_CELL_RGBA = (230, 230, 230, 255)
58
+
59
+
60
+ class TrustedDerivedRenderingError(RuntimeError):
61
+ """A bounded source could not be converted into a safe payload."""
62
+
63
+
64
+ class _UnsupportedNumericData(TrustedDerivedRenderingError):
65
+ pass
66
+
67
+
68
+ def _option_integer(
69
+ options: RenderingOptions,
70
+ name: str,
71
+ default: int,
72
+ maximum: int,
73
+ ) -> int:
74
+ value = dict(options.values).get(name, default)
75
+ if type(value) is not int or not 1 <= value <= maximum:
76
+ raise ValueError(f"Rendering option {name!r} must be from 1 through {maximum}.")
77
+ return value
78
+
79
+
80
+ def _finite_number(value: object) -> float | None:
81
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
82
+ return None
83
+ converted = float(value)
84
+ return converted if math.isfinite(converted) 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
+ x_span = x_max - x_min
365
+ y_span = y_max - y_min
366
+ if not math.isfinite(x_span) or not math.isfinite(y_span):
367
+ raise _UnsupportedNumericData(
368
+ "The bounded numeric sample exceeds the supported finite range."
369
+ )
370
+ x_span = x_span or 1.0
371
+ y_span = y_span or 1.0
372
+ margin = max(2, min(width, height) // 20)
373
+ prior: tuple[int, int] | None = None
374
+ for index, (x, y) in enumerate(points):
375
+ if index % 128 == 0:
376
+ cancel_check()
377
+ raw_px = (x - x_min) * (width - 1 - 2 * margin) / x_span
378
+ raw_py = (y - y_min) * (height - 1 - 2 * margin) / y_span
379
+ if not math.isfinite(raw_px) or not math.isfinite(raw_py):
380
+ raise _UnsupportedNumericData(
381
+ "The bounded numeric sample exceeds the supported finite range."
382
+ )
383
+ px = margin + round(raw_px)
384
+ py = height - 1 - margin - round(raw_py)
385
+ if prior is not None:
386
+ _line(rgba, width, height, prior[0], prior[1], px, py, (24, 92, 170, 255))
387
+ prior = (px, py)
388
+ if len(points) == 1 and prior is not None:
389
+ _pixel(
390
+ rgba,
391
+ width,
392
+ min(width - 1, max(0, prior[0])),
393
+ min(height - 1, max(0, prior[1])),
394
+ (24, 92, 170, 255),
395
+ )
396
+ return rgba, len(points)
397
+
398
+
399
+ def _render_2d(
400
+ observation: TrustedDerivedSourceObservation,
401
+ layout: Trusted2DGridLayout,
402
+ width: int,
403
+ height: int,
404
+ cancel_check: CancelCheck,
405
+ ) -> tuple[bytearray, int]:
406
+ indexes = {name: index for index, name in enumerate(observation.sample_columns)}
407
+ fast_name = layout.dependencies[layout.fast_axis_index]
408
+ slow_name = layout.dependencies[1 - layout.fast_axis_index]
409
+ z_name = layout.dependent
410
+ if any(name not in indexes for name in (slow_name, fast_name, z_name)):
411
+ raise _UnsupportedNumericData(
412
+ "The validated 2D layout is absent from the bounded numeric sample."
413
+ )
414
+ slow_count = layout.shape[1 - layout.fast_axis_index]
415
+ fast_count = layout.shape[layout.fast_axis_index]
416
+ sampled_slow = layout.sample_slow_indexes
417
+ sampled_fast = layout.sample_fast_indexes
418
+ slow_bins = {value: index for index, value in enumerate(sampled_slow)}
419
+ fast_bins = {value: index for index, value in enumerate(sampled_fast)}
420
+ if layout.fast_axis_index == 0:
421
+ sampled_vertical = sampled_fast
422
+ sampled_horizontal = sampled_slow
423
+ vertical_count = fast_count
424
+ horizontal_count = slow_count
425
+ else:
426
+ sampled_vertical = sampled_slow
427
+ sampled_horizontal = sampled_fast
428
+ vertical_count = slow_count
429
+ horizontal_count = fast_count
430
+ grid_width = len(sampled_horizontal)
431
+ grid_size = len(sampled_vertical) * grid_width
432
+ grid_sum = [0.0] * grid_size
433
+ grid_count = [0] * grid_size
434
+ points = 0
435
+ for index, row in enumerate(observation.sample_rows):
436
+ if index % 128 == 0:
437
+ cancel_check()
438
+ if len(row) != len(observation.sample_columns):
439
+ raise _UnsupportedNumericData(
440
+ "The bounded numeric sample has inconsistent vector lengths."
441
+ )
442
+ row_id = row[0]
443
+ if type(row_id) is not int:
444
+ raise _UnsupportedNumericData(
445
+ "The bounded grid sample has an invalid result id."
446
+ )
447
+ grid_index = _layout_grid_index(
448
+ layout,
449
+ row_id,
450
+ slow_count=slow_count,
451
+ fast_count=fast_count,
452
+ )
453
+ if grid_index is None:
454
+ continue
455
+ slow = _finite_number(row[indexes[slow_name]])
456
+ fast = _finite_number(row[indexes[fast_name]])
457
+ z = _finite_number(row[indexes[z_name]])
458
+ if slow is None or fast is None:
459
+ raise _UnsupportedNumericData(
460
+ "The validated grid sample has a non-numeric dependency."
461
+ )
462
+ if z is None:
463
+ continue
464
+ slow_index, fast_index = grid_index
465
+ slow_bin = slow_bins.get(slow_index)
466
+ if slow_bin is None:
467
+ slow_bin = _scaled_grid_index(
468
+ slow_index,
469
+ slow_count,
470
+ len(sampled_slow),
471
+ )
472
+ fast_bin = fast_bins.get(fast_index)
473
+ if fast_bin is None:
474
+ fast_bin = _scaled_grid_index(
475
+ fast_index,
476
+ fast_count,
477
+ len(sampled_fast),
478
+ )
479
+ scientific_slow_bin = (
480
+ len(sampled_slow) - 1 - slow_bin if layout.slow_reversed else slow_bin
481
+ )
482
+ row_fast_reversed = layout.fast_reversed ^ (
483
+ layout.serpentine and bool(slow_index % 2)
484
+ )
485
+ scientific_fast_bin = (
486
+ len(sampled_fast) - 1 - fast_bin if row_fast_reversed else fast_bin
487
+ )
488
+ if layout.fast_axis_index == 0:
489
+ vertical_bin = scientific_fast_bin
490
+ horizontal_bin = scientific_slow_bin
491
+ else:
492
+ vertical_bin = scientific_slow_bin
493
+ horizontal_bin = scientific_fast_bin
494
+ display_row = len(sampled_vertical) - 1 - vertical_bin
495
+ target = display_row * grid_width + horizontal_bin
496
+ combined = grid_sum[target] + z
497
+ if not math.isfinite(combined):
498
+ raise _UnsupportedNumericData(
499
+ "The bounded numeric sample exceeds the supported finite range."
500
+ )
501
+ grid_sum[target] = combined
502
+ grid_count[target] += 1
503
+ points += 1
504
+ rgba = bytearray(width * height * 4)
505
+ if points == 0:
506
+ for offset in range(0, len(rgba), 4):
507
+ rgba[offset : offset + 4] = bytes(_MISSING_CELL_RGBA)
508
+ return rgba, 0
509
+ grid = tuple(
510
+ grid_sum[index] / count if count else None
511
+ for index, count in enumerate(grid_count)
512
+ )
513
+ finite = tuple(value for value in grid if value is not None)
514
+ z_min, z_max = min(finite), max(finite)
515
+ z_span = z_max - z_min
516
+ if not math.isfinite(z_span):
517
+ raise _UnsupportedNumericData(
518
+ "The bounded numeric sample exceeds the supported finite range."
519
+ )
520
+ source_rows = _nearest_sample_bins(
521
+ height,
522
+ vertical_count,
523
+ sampled_vertical,
524
+ )
525
+ source_columns = _nearest_sample_bins(
526
+ width,
527
+ horizontal_count,
528
+ sampled_horizontal,
529
+ )
530
+ for py in range(height):
531
+ if py % 32 == 0:
532
+ cancel_check()
533
+ source_row = source_rows[py]
534
+ for px, source_column in enumerate(source_columns):
535
+ value = grid[source_row * grid_width + source_column]
536
+ color = (
537
+ _MISSING_CELL_RGBA
538
+ if value is None
539
+ else _viridis_rgba(value, z_min, z_max)
540
+ )
541
+ _pixel(rgba, width, px, py, color)
542
+ return rgba, points
543
+
544
+
545
+ def _layout_grid_index(
546
+ layout: Trusted2DGridLayout,
547
+ row_id: int,
548
+ *,
549
+ slow_count: int,
550
+ fast_count: int,
551
+ ) -> tuple[int, int] | None:
552
+ relative = row_id - layout.first_row_id
553
+ if relative < 0:
554
+ return None
555
+ slow_index, remainder = divmod(relative, layout.slow_id_stride)
556
+ if remainder % layout.fast_id_stride:
557
+ return None
558
+ fast_index = remainder // layout.fast_id_stride
559
+ if slow_index >= slow_count or fast_index >= fast_count:
560
+ return None
561
+ return slow_index, fast_index
562
+
563
+
564
+ def _scaled_grid_index(index: int, source_count: int, target_count: int) -> int:
565
+ if source_count <= 1 or target_count <= 1:
566
+ return 0
567
+ return min(target_count - 1, index * (target_count - 1) // (source_count - 1))
568
+
569
+
570
+ def _nearest_sample_bins(
571
+ pixel_count: int,
572
+ source_count: int,
573
+ sampled_indexes: tuple[int, ...],
574
+ ) -> tuple[int, ...]:
575
+ """Map display pixels to their nearest sampled source-domain coordinate.
576
+
577
+ Representative samples are almost uniform, but they also contain adjacent
578
+ edge anchors used to prove acquisition direction. Treating every sample
579
+ rank as equally wide would make a single edge cell occupy the same image
580
+ width as a sample representing thousands of source cells. Integer-scaled
581
+ nearest-neighbour lookup preserves the true source-index spacing without
582
+ losing either endpoint or using imprecise large-axis floats.
583
+ """
584
+
585
+ if pixel_count <= 0 or source_count <= 0 or not sampled_indexes:
586
+ raise ValueError("Source-domain sampling requires positive dimensions.")
587
+ targets: tuple[int, ...]
588
+ if pixel_count == 1:
589
+ denominator = 2
590
+ targets = (source_count - 1,)
591
+ else:
592
+ denominator = pixel_count - 1
593
+ targets = tuple(pixel * (source_count - 1) for pixel in range(pixel_count))
594
+ scaled_samples = tuple(index * denominator for index in sampled_indexes)
595
+ output: list[int] = []
596
+ for target in targets:
597
+ upper = bisect_left(scaled_samples, target)
598
+ if upper == 0:
599
+ output.append(0)
600
+ elif upper == len(scaled_samples):
601
+ output.append(len(scaled_samples) - 1)
602
+ else:
603
+ lower = upper - 1
604
+ output.append(
605
+ lower
606
+ if target - scaled_samples[lower] <= scaled_samples[upper] - target
607
+ else upper
608
+ )
609
+ return tuple(output)
610
+
611
+
612
+ def _viridis_rgba(
613
+ value: float,
614
+ low: float,
615
+ high: float,
616
+ ) -> tuple[int, int, int, int]:
617
+ fraction = 0.5 if high == low else (value - low) / (high - low)
618
+ fraction = max(0.0, min(1.0, fraction))
619
+ color_index = min(255, int(fraction * 256.0))
620
+ offset = color_index * 3
621
+ return (
622
+ _VIRIDIS_RGB[offset],
623
+ _VIRIDIS_RGB[offset + 1],
624
+ _VIRIDIS_RGB[offset + 2],
625
+ 255,
626
+ )
627
+
628
+
629
+ def _pixel(
630
+ rgba: bytearray,
631
+ width: int,
632
+ x: int,
633
+ y: int,
634
+ color: tuple[int, int, int, int],
635
+ ) -> None:
636
+ offset = (y * width + x) * 4
637
+ rgba[offset : offset + 4] = bytes(color)
638
+
639
+
640
+ def _line(
641
+ rgba: bytearray,
642
+ width: int,
643
+ height: int,
644
+ x0: int,
645
+ y0: int,
646
+ x1: int,
647
+ y1: int,
648
+ color: tuple[int, int, int, int],
649
+ ) -> None:
650
+ dx, sx = abs(x1 - x0), 1 if x0 < x1 else -1
651
+ dy, sy = -abs(y1 - y0), 1 if y0 < y1 else -1
652
+ error = dx + dy
653
+ while True:
654
+ if 0 <= x0 < width and 0 <= y0 < height:
655
+ _pixel(rgba, width, x0, y0, color)
656
+ if x0 == x1 and y0 == y1:
657
+ break
658
+ doubled = 2 * error
659
+ if doubled >= dy:
660
+ error += dy
661
+ x0 += sx
662
+ if doubled <= dx:
663
+ error += dx
664
+ y0 += sy
665
+
666
+
667
+ def _png_chunk(kind: bytes, data: bytes) -> bytes:
668
+ checksum = binascii.crc32(kind)
669
+ checksum = binascii.crc32(data, checksum) & 0xFFFFFFFF
670
+ return struct.pack(">I", len(data)) + kind + data + struct.pack(">I", checksum)
671
+
672
+
673
+ def _encode_png_rgba(
674
+ width: int,
675
+ height: int,
676
+ rgba: bytearray,
677
+ cancel_check: CancelCheck,
678
+ ) -> bytes:
679
+ expected = width * height * 4
680
+ if len(rgba) != expected or expected > TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES:
681
+ raise TrustedDerivedRenderingError("The decoded image has invalid bounds.")
682
+ rows = bytearray(height * (width * 4 + 1))
683
+ source_stride = width * 4
684
+ target_stride = source_stride + 1
685
+ for y in range(height):
686
+ if y % 32 == 0:
687
+ cancel_check()
688
+ source = y * source_stride
689
+ target = y * target_stride
690
+ rows[target] = 0
691
+ rows[target + 1 : target + target_stride] = rgba[
692
+ source : source + source_stride
693
+ ]
694
+ cancel_check()
695
+ compressed = zlib.compress(bytes(rows), level=9)
696
+ result = (
697
+ b"\x89PNG\r\n\x1a\n"
698
+ + _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
699
+ + _png_chunk(b"IDAT", compressed)
700
+ + _png_chunk(b"IEND", b"")
701
+ )
702
+ if len(result) > TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES:
703
+ raise TrustedDerivedRenderingError("The encoded image is too large.")
704
+ return result
705
+
706
+
707
+ def trusted_derived_payload_size(payload: DerivedPayload) -> int:
708
+ """Conservatively size the retained primitive graph without serialising it."""
709
+
710
+ total = 0
711
+ nodes = 0
712
+ active_containers: set[int] = set()
713
+ stack: list[tuple[bool, Any, int]] = [(False, payload, 0)]
714
+ while stack:
715
+ leaving, value, depth = stack.pop()
716
+ if leaving:
717
+ active_containers.discard(cast(int, value))
718
+ continue
719
+ if depth > TRUSTED_DERIVED_MAX_PAYLOAD_DEPTH:
720
+ raise TrustedDerivedRenderingError(
721
+ "A derived payload is too deeply nested."
722
+ )
723
+ nodes += 1
724
+ if nodes > TRUSTED_DERIVED_MAX_PAYLOAD_NODES:
725
+ raise TrustedDerivedRenderingError("A derived payload has too many nodes.")
726
+ if value is None or isinstance(value, bool):
727
+ total += 8
728
+ elif isinstance(value, (int, float)):
729
+ if isinstance(value, float) and not math.isfinite(value):
730
+ raise TrustedDerivedRenderingError(
731
+ "A derived payload contains a non-finite scalar."
732
+ )
733
+ total += 32
734
+ elif isinstance(value, str):
735
+ encoded = value.encode("utf-8")
736
+ if len(encoded) > TRUSTED_DERIVED_MAX_TEXT_BYTES:
737
+ raise TrustedDerivedRenderingError(
738
+ "A derived payload text value is oversized."
739
+ )
740
+ total += len(encoded) + 32
741
+ elif isinstance(value, bytes):
742
+ if len(value) > TRUSTED_DERIVED_MAX_RETAINED_PAYLOAD_BYTES:
743
+ raise TrustedDerivedRenderingError(
744
+ "A derived payload byte value is oversized."
745
+ )
746
+ total += len(value) + 32
747
+ elif isinstance(value, tuple):
748
+ if len(value) > TRUSTED_DERIVED_MAX_PAYLOAD_ITEMS:
749
+ raise TrustedDerivedRenderingError(
750
+ "A derived payload tuple has too many items."
751
+ )
752
+ identity = id(value)
753
+ if identity in active_containers:
754
+ raise TrustedDerivedRenderingError("A derived payload is cyclic.")
755
+ active_containers.add(identity)
756
+ total += 32 + len(value) * 8
757
+ stack.append((True, identity, depth))
758
+ stack.extend((False, item, depth + 1) for item in value)
759
+ elif isinstance(value, dict):
760
+ if len(value) > TRUSTED_DERIVED_MAX_PAYLOAD_ITEMS:
761
+ raise TrustedDerivedRenderingError(
762
+ "A derived payload dictionary has too many items."
763
+ )
764
+ identity = id(value)
765
+ if identity in active_containers:
766
+ raise TrustedDerivedRenderingError("A derived payload is cyclic.")
767
+ active_containers.add(identity)
768
+ total += 64 + len(value) * 16
769
+ stack.append((True, identity, depth))
770
+ stack.extend((False, item, depth + 1) for item in value.keys())
771
+ stack.extend((False, item, depth + 1) for item in value.values())
772
+ else:
773
+ raise TrustedDerivedRenderingError("A derived payload is not primitive.")
774
+ if total > TRUSTED_DERIVED_MAX_RETAINED_PAYLOAD_BYTES:
775
+ raise TrustedDerivedRenderingError(
776
+ "The retained payload budget was exceeded."
777
+ )
778
+ return total
779
+
780
+
781
+ def _pair_mapping(value: object, *, name: str, maximum: int) -> dict[str, object]:
782
+ if not isinstance(value, tuple) or len(value) > maximum:
783
+ raise TrustedDerivedRenderingError(f"The derived {name} structure is invalid.")
784
+ output: dict[str, object] = {}
785
+ for item in value:
786
+ if (
787
+ not isinstance(item, tuple)
788
+ or len(item) != 2
789
+ or not isinstance(item[0], str)
790
+ or item[0] in output
791
+ ):
792
+ raise TrustedDerivedRenderingError(
793
+ f"The derived {name} structure is invalid."
794
+ )
795
+ output[item[0]] = item[1]
796
+ return output
797
+
798
+
799
+ def validate_trusted_derived_payload(payload: DerivedPayload) -> int:
800
+ """Validate the retained/cache schema and return its conservative size."""
801
+
802
+ if not isinstance(payload, dict) or any(
803
+ not isinstance(name, str) for name in payload
804
+ ):
805
+ raise TrustedDerivedRenderingError("A derived payload root is invalid.")
806
+ allowed = {
807
+ "format",
808
+ "kind",
809
+ "status",
810
+ "description",
811
+ "source",
812
+ "images",
813
+ "metadata",
814
+ }
815
+ required = {"format", "kind", "status", "description", "source", "images"}
816
+ if set(payload) - allowed or not required.issubset(payload):
817
+ raise TrustedDerivedRenderingError("A derived payload field is invalid.")
818
+ if payload["format"] != "qplot-trusted-derived-payload-v1":
819
+ raise TrustedDerivedRenderingError("A derived payload format is unsupported.")
820
+ if payload["kind"] not in {"thumbnail", "preview", "metadata"}:
821
+ raise TrustedDerivedRenderingError("A derived payload kind is invalid.")
822
+ if payload["status"] not in {"ok", "empty", "unsupported", "error"}:
823
+ raise TrustedDerivedRenderingError("A derived payload status is invalid.")
824
+ if not isinstance(payload["description"], str):
825
+ raise TrustedDerivedRenderingError("A derived payload description is invalid.")
826
+ _pair_mapping(payload["source"], name="source", maximum=32)
827
+ images = payload["images"]
828
+ if not isinstance(images, tuple) or len(images) > TRUSTED_DERIVED_MAX_IMAGES:
829
+ raise TrustedDerivedRenderingError("The derived image collection is invalid.")
830
+ encoded_total = 0
831
+ for image in images:
832
+ fields = _pair_mapping(image, name="image", maximum=16)
833
+ if set(fields) != {
834
+ "encoding",
835
+ "width",
836
+ "height",
837
+ "dependent",
838
+ "dimensions",
839
+ "sampled_points",
840
+ "bytes",
841
+ }:
842
+ raise TrustedDerivedRenderingError("A derived image field is invalid.")
843
+ width, height = fields["width"], fields["height"]
844
+ encoded = fields["bytes"]
845
+ if (
846
+ fields["encoding"] != "png"
847
+ or type(width) is not int
848
+ or type(height) is not int
849
+ or not 1 <= width <= TRUSTED_DERIVED_MAX_IMAGE_WIDTH
850
+ or not 1 <= height <= TRUSTED_DERIVED_MAX_IMAGE_HEIGHT
851
+ or width * height * 4 > TRUSTED_DERIVED_MAX_DECODED_IMAGE_BYTES
852
+ or not isinstance(fields["dependent"], str)
853
+ or fields["dimensions"] not in (1, 2)
854
+ or type(fields["sampled_points"]) is not int
855
+ or fields["sampled_points"] < 0
856
+ or not isinstance(encoded, bytes)
857
+ or not encoded.startswith(b"\x89PNG\r\n\x1a\n")
858
+ ):
859
+ raise TrustedDerivedRenderingError("A derived image value is invalid.")
860
+ encoded_total += len(encoded)
861
+ if encoded_total > TRUSTED_DERIVED_MAX_ENCODED_IMAGE_BYTES:
862
+ raise TrustedDerivedRenderingError("The encoded image budget was exceeded.")
863
+ metadata = payload.get("metadata")
864
+ if metadata is not None:
865
+ if payload["kind"] != "metadata":
866
+ raise TrustedDerivedRenderingError("Derived metadata has the wrong kind.")
867
+ _pair_mapping(metadata, name="metadata", maximum=64)
868
+ return trusted_derived_payload_size(payload)