quantized-lab 0.8.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 (233) hide show
  1. quantized/__init__.py +11 -0
  2. quantized/__main__.py +14 -0
  3. quantized/api.py +220 -0
  4. quantized/app.py +206 -0
  5. quantized/calc/__init__.py +8 -0
  6. quantized/calc/_clipfit.py +76 -0
  7. quantized/calc/_natural_neighbor.py +219 -0
  8. quantized/calc/aggregate.py +159 -0
  9. quantized/calc/backgrounds.py +353 -0
  10. quantized/calc/baseline.py +349 -0
  11. quantized/calc/batch_fit.py +148 -0
  12. quantized/calc/constants.py +27 -0
  13. quantized/calc/corrections.py +192 -0
  14. quantized/calc/crystallography.py +400 -0
  15. quantized/calc/diffusion.py +120 -0
  16. quantized/calc/electrical.py +248 -0
  17. quantized/calc/electrochemistry.py +176 -0
  18. quantized/calc/element_data.json +1 -0
  19. quantized/calc/element_data.py +59 -0
  20. quantized/calc/errors.py +246 -0
  21. quantized/calc/figure.py +417 -0
  22. quantized/calc/figure_break.py +152 -0
  23. quantized/calc/figure_categorical.py +156 -0
  24. quantized/calc/figure_corner.py +229 -0
  25. quantized/calc/figure_facets.py +127 -0
  26. quantized/calc/figure_field.py +137 -0
  27. quantized/calc/figure_hitmap.py +116 -0
  28. quantized/calc/figure_labels.py +62 -0
  29. quantized/calc/figure_map.py +287 -0
  30. quantized/calc/figure_overrides.py +159 -0
  31. quantized/calc/figure_page.py +266 -0
  32. quantized/calc/figure_scale.py +125 -0
  33. quantized/calc/figure_statplots.py +167 -0
  34. quantized/calc/figure_styles.py +131 -0
  35. quantized/calc/figure_ternary.py +239 -0
  36. quantized/calc/figure_ticks.py +217 -0
  37. quantized/calc/fit_autoguess.py +156 -0
  38. quantized/calc/fit_bootstrap.py +163 -0
  39. quantized/calc/fit_bumps.py +258 -0
  40. quantized/calc/fit_constraints.py +114 -0
  41. quantized/calc/fit_equation.py +264 -0
  42. quantized/calc/fit_findxy.py +80 -0
  43. quantized/calc/fit_models.py +195 -0
  44. quantized/calc/fit_models_special.py +189 -0
  45. quantized/calc/fit_odr.py +99 -0
  46. quantized/calc/fit_scan.py +243 -0
  47. quantized/calc/fit_stats.py +215 -0
  48. quantized/calc/fitting.py +199 -0
  49. quantized/calc/formula.py +90 -0
  50. quantized/calc/global_curve_fit.py +305 -0
  51. quantized/calc/global_fit.py +181 -0
  52. quantized/calc/interp2d.py +260 -0
  53. quantized/calc/linecut.py +269 -0
  54. quantized/calc/magnetic.py +414 -0
  55. quantized/calc/magnetometry.py +464 -0
  56. quantized/calc/map.py +228 -0
  57. quantized/calc/mcmc.py +177 -0
  58. quantized/calc/optics.py +228 -0
  59. quantized/calc/pawley.py +251 -0
  60. quantized/calc/peak_batch.py +128 -0
  61. quantized/calc/peak_fit.py +259 -0
  62. quantized/calc/peak_integrate.py +104 -0
  63. quantized/calc/peak_multifit.py +260 -0
  64. quantized/calc/peak_track.py +134 -0
  65. quantized/calc/peaks.py +298 -0
  66. quantized/calc/peakshapes.py +85 -0
  67. quantized/calc/plotting.py +147 -0
  68. quantized/calc/processing.py +232 -0
  69. quantized/calc/qspace.py +48 -0
  70. quantized/calc/reductions.py +155 -0
  71. quantized/calc/reductions_fft.py +383 -0
  72. quantized/calc/refl_sld_presets.json +1 -0
  73. quantized/calc/reflectivity.py +80 -0
  74. quantized/calc/registry.py +303 -0
  75. quantized/calc/relaxation.py +119 -0
  76. quantized/calc/report.py +253 -0
  77. quantized/calc/report_emit.py +227 -0
  78. quantized/calc/resample.py +142 -0
  79. quantized/calc/rsm.py +91 -0
  80. quantized/calc/rsm_analyze.py +245 -0
  81. quantized/calc/semiconductor.py +488 -0
  82. quantized/calc/sld.py +131 -0
  83. quantized/calc/sld_formula.py +138 -0
  84. quantized/calc/spectral.py +357 -0
  85. quantized/calc/statplots.py +214 -0
  86. quantized/calc/stats.py +399 -0
  87. quantized/calc/stats_anova2.py +202 -0
  88. quantized/calc/stats_anova_ext.py +338 -0
  89. quantized/calc/stats_dist.py +196 -0
  90. quantized/calc/stats_glm.py +245 -0
  91. quantized/calc/stats_multivar.py +289 -0
  92. quantized/calc/stats_roc.py +157 -0
  93. quantized/calc/stats_survival.py +261 -0
  94. quantized/calc/stats_tests.py +380 -0
  95. quantized/calc/substrates.py +181 -0
  96. quantized/calc/superconductor.py +359 -0
  97. quantized/calc/surface_fit.py +290 -0
  98. quantized/calc/surface_models.py +156 -0
  99. quantized/calc/thermal.py +119 -0
  100. quantized/calc/thin_film.py +425 -0
  101. quantized/calc/unit_convert.py +259 -0
  102. quantized/calc/units.py +80 -0
  103. quantized/calc/vacuum.py +290 -0
  104. quantized/calc/xray.py +169 -0
  105. quantized/cli.py +214 -0
  106. quantized/datastruct.py +153 -0
  107. quantized/io/__init__.py +11 -0
  108. quantized/io/_hdf5_layout.py +308 -0
  109. quantized/io/_jcamp_asdf.py +135 -0
  110. quantized/io/_xrdml_scan.py +291 -0
  111. quantized/io/base.py +82 -0
  112. quantized/io/bruker_brml.py +177 -0
  113. quantized/io/bruker_raw.py +158 -0
  114. quantized/io/cif.py +266 -0
  115. quantized/io/consolidated.py +122 -0
  116. quantized/io/delimited.py +222 -0
  117. quantized/io/excel.py +135 -0
  118. quantized/io/hdf5.py +192 -0
  119. quantized/io/import_filters.py +178 -0
  120. quantized/io/import_preview.py +262 -0
  121. quantized/io/jcamp.py +179 -0
  122. quantized/io/lakeshore.py +163 -0
  123. quantized/io/ncnr.py +278 -0
  124. quantized/io/netcdf.py +195 -0
  125. quantized/io/opus.py +231 -0
  126. quantized/io/origin.py +346 -0
  127. quantized/io/origin_com.py +194 -0
  128. quantized/io/origin_project/__init__.py +221 -0
  129. quantized/io/origin_project/annotation_marks.py +288 -0
  130. quantized/io/origin_project/container.py +262 -0
  131. quantized/io/origin_project/curve_style_color.py +359 -0
  132. quantized/io/origin_project/figure_geometry.py +108 -0
  133. quantized/io/origin_project/figure_layers.py +333 -0
  134. quantized/io/origin_project/figure_text.py +258 -0
  135. quantized/io/origin_project/figures.py +210 -0
  136. quantized/io/origin_project/figures_opju.py +440 -0
  137. quantized/io/origin_project/notes.py +302 -0
  138. quantized/io/origin_project/opj.py +459 -0
  139. quantized/io/origin_project/opj_curves.py +297 -0
  140. quantized/io/origin_project/opj_shapes.py +148 -0
  141. quantized/io/origin_project/opju.py +146 -0
  142. quantized/io/origin_project/opju_axis_real_form.py +418 -0
  143. quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
  144. quantized/io/origin_project/opju_codec.py +370 -0
  145. quantized/io/origin_project/opju_curves.py +497 -0
  146. quantized/io/origin_project/opju_curves_allcols.py +258 -0
  147. quantized/io/origin_project/opju_figure_curves.py +302 -0
  148. quantized/io/origin_project/opju_figure_text.py +245 -0
  149. quantized/io/origin_project/opju_reports.py +129 -0
  150. quantized/io/origin_project/origin_richtext.py +145 -0
  151. quantized/io/origin_project/preview.py +132 -0
  152. quantized/io/origin_project/templates.py +314 -0
  153. quantized/io/origin_project/tree.py +379 -0
  154. quantized/io/origin_project/tree_opju.py +228 -0
  155. quantized/io/origin_project/windows.py +238 -0
  156. quantized/io/origin_project/windows_opju.py +393 -0
  157. quantized/io/origin_project/writer.py +156 -0
  158. quantized/io/origin_project/writer_blocks.py +282 -0
  159. quantized/io/qd.py +380 -0
  160. quantized/io/refl1d.py +132 -0
  161. quantized/io/registry.py +210 -0
  162. quantized/io/report_export.py +347 -0
  163. quantized/io/rigaku.py +100 -0
  164. quantized/io/sims.py +398 -0
  165. quantized/io/spc.py +311 -0
  166. quantized/io/xrd_csv.py +308 -0
  167. quantized/io/xrdml.py +394 -0
  168. quantized/jobs.py +173 -0
  169. quantized/plugins/__init__.py +50 -0
  170. quantized/plugins/contract.py +111 -0
  171. quantized/plugins/loader.py +394 -0
  172. quantized/plugins/steps.py +90 -0
  173. quantized/routes/__init__.py +7 -0
  174. quantized/routes/_bookcache.py +62 -0
  175. quantized/routes/_export_common.py +27 -0
  176. quantized/routes/_payload.py +58 -0
  177. quantized/routes/_uploadcache.py +59 -0
  178. quantized/routes/aggregate.py +46 -0
  179. quantized/routes/baseline.py +210 -0
  180. quantized/routes/books.py +117 -0
  181. quantized/routes/calc.py +56 -0
  182. quantized/routes/corrections.py +78 -0
  183. quantized/routes/crystallography.py +80 -0
  184. quantized/routes/diffusion.py +58 -0
  185. quantized/routes/electrical.py +101 -0
  186. quantized/routes/electrochemistry.py +83 -0
  187. quantized/routes/export.py +280 -0
  188. quantized/routes/export_facets.py +83 -0
  189. quantized/routes/export_figures.py +471 -0
  190. quantized/routes/export_page.py +125 -0
  191. quantized/routes/fitting.py +379 -0
  192. quantized/routes/fitting_bumps.py +97 -0
  193. quantized/routes/import_template.py +97 -0
  194. quantized/routes/import_wizard.py +150 -0
  195. quantized/routes/jobs_api.py +59 -0
  196. quantized/routes/magnetic.py +135 -0
  197. quantized/routes/magnetometry.py +133 -0
  198. quantized/routes/optics.py +98 -0
  199. quantized/routes/parsers.py +281 -0
  200. quantized/routes/peaks.py +184 -0
  201. quantized/routes/plot.py +103 -0
  202. quantized/routes/reductions.py +121 -0
  203. quantized/routes/reference.py +58 -0
  204. quantized/routes/reflectivity.py +91 -0
  205. quantized/routes/report_export.py +119 -0
  206. quantized/routes/rsm.py +136 -0
  207. quantized/routes/samples.py +32 -0
  208. quantized/routes/semiconductor.py +207 -0
  209. quantized/routes/sld.py +42 -0
  210. quantized/routes/spectral.py +54 -0
  211. quantized/routes/statplots.py +99 -0
  212. quantized/routes/stats.py +418 -0
  213. quantized/routes/stats_design.py +321 -0
  214. quantized/routes/substrates.py +46 -0
  215. quantized/routes/superconductor.py +139 -0
  216. quantized/routes/thermal.py +57 -0
  217. quantized/routes/thin_film.py +153 -0
  218. quantized/routes/vacuum.py +113 -0
  219. quantized/routes/xray.py +32 -0
  220. quantized/samples/demo_vsm.csv +42 -0
  221. quantized/server_launch.py +251 -0
  222. quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
  223. quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
  224. quantized/web/assets/index-BHmmCL-x.js +27 -0
  225. quantized/web/assets/index-BiZzN7J6.css +1 -0
  226. quantized/web/index.html +13 -0
  227. quantized/web/loading.html +69 -0
  228. quantized_lab-0.8.0.dist-info/METADATA +122 -0
  229. quantized_lab-0.8.0.dist-info/RECORD +233 -0
  230. quantized_lab-0.8.0.dist-info/WHEEL +4 -0
  231. quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
  232. quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
  233. quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,370 @@
1
+ """The ``.opju`` (CPYUA) worksheet-column codec — canonical Burtscher FPC.
2
+
3
+ Format facts derived clean-room (no GPL liborigin) from known-content Rosetta
4
+ specimens generated with an Origin 2026b trial, then locked against Origin's
5
+ own exported ground truth (``tests/test_io_origin_ground_truth.py``): every
6
+ ``XAS.opju`` column (243/243 values) and 193 ``Hc2 data`` columns decode
7
+ bit-exact, plus ``RockingCurve`` axes at 71 and 201 rows.
8
+
9
+ Each numeric column is an FPC-compressed float64 stream (Burtscher &
10
+ Ratanaworabhan, *FPC: A High-Speed Compressor for Double-Precision
11
+ Floating-Point Data*, IEEE TC 2009). Two predictors race for every value:
12
+
13
+ * **FCM** — a value predictor: ``pred = fcm[fh]``;
14
+ * **DFCM** — a stride predictor: ``pred = last + dfcm[dh]``.
15
+
16
+ The encoder XORs the true bits against the closer prediction and stores the
17
+ low ``k`` bytes little-endian (the dropped high bytes are the leading zeros).
18
+ Every value carries a 4-bit code; two codes pack into one control byte, low
19
+ nibble first:
20
+
21
+ * **bit 3** selects the predictor (0 = FCM, 1 = DFCM),
22
+ * **bits 0-2** give the residual byte-count: codes 0-3 store 0-3 bytes and
23
+ codes 4-7 store 5-8 (a count of exactly 4 is unsupported, per the paper),
24
+ so code 0 with either selector (``0x0``/``0x8``) means predictor-exact.
25
+
26
+ Both hash tables hold ``2**12`` entries and update the textbook FPC way::
27
+
28
+ fh = ((fh << 6) ^ (value >> 48)) & 0xFFF
29
+ dh = ((dh << 2) ^ (stride >> 40)) & 0xFFF
30
+
31
+ Origin frames each column record as ``0a 05 <varint> ff ff <nrows:varint> 00
32
+ <segments>``. Segments open with a ZigZag varint: −m = "m FPC rows", with
33
+ ``0x0c`` + the stream *inline* and a FRESH predictor state per stream (a
34
+ plain column is the single segment ``zigzag(−nrows)``); +k = "k rows of one
35
+ repeated value" whose value-spec follows (``0x50``+float64, ``0x1a``/``0x11``
36
+ + the double's top 2/1 bytes, or bare ``0x64`` = 0.0). Segments interleave
37
+ freely — Origin run-length-compresses constant runs (total-reflection
38
+ plateaus, logger hold-steps) *outside* the FPC streams, and a fully constant
39
+ column has no stream at all. Empty numeric cells carry the
40
+ ``ORIGIN_MISSING`` sentinel and map to NaN.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import re
46
+ import struct
47
+
48
+ import numpy as np
49
+ from numpy.typing import NDArray
50
+
51
+ from quantized.io.origin_project.container import ORIGIN_MISSING, plausible_column
52
+
53
+ __all__ = ["CodecError", "curve_plot_style", "decode_stream", "scan_columns", "tail_start"]
54
+
55
+ _MASK = 0xFFFFFFFFFFFFFFFF
56
+ _TABLE_MASK = (1 << 12) - 1 # 2**12-entry FCM/DFCM hash tables
57
+ _FCM_SHIFT, _FCM_DROP = 6, 48 # fh = ((fh << 6) ^ (value >> 48)) & mask
58
+ _DFCM_SHIFT, _DFCM_DROP = 2, 40 # dh = ((dh << 2) ^ (stride >> 40)) & mask
59
+
60
+ # A length-prefixed dataset name "<Book>_<Col>[@sheet]" (CPYUA strings carry
61
+ # no NUL terminator, so the byte before the match must equal the name
62
+ # length). The optional "@<N>" suffix marks a column of sheet N>1 in a
63
+ # multi-sheet book (same convention `.opj`'s container.NAME_RE uses) — added
64
+ # alongside plan item 4's report-sheet decode, whose fitreport2.opju oracle
65
+ # was the first `.opju` specimen with more than one sheet in a book and
66
+ # exposed that every extra-sheet column was previously mis-anchored to the
67
+ # nearest SHEET-1 name instead (e.g. every FitNL1/FitNLCurve1 column
68
+ # collapsing onto "FitBook_B"): the un-suffixed pattern still matched an
69
+ # "@2"-suffixed name as a prefix, but the length-prefix byte then disagreed
70
+ # with the (shorter) matched length, so it should have failed the anchoring
71
+ # check below -- except sheet-1's own un-suffixed names sit right before the
72
+ # extra-sheet block and satisfied it instead, silently swallowing every
73
+ # later column into whichever sheet-1 name came last.
74
+ # Bounds deliberately wider than any corpus maximum (book 62 / column 16 /
75
+ # sheet 999 — the old 40/4/99 caps were corpus-shaped and silently dropped a
76
+ # column whose name exceeded them; 2026-07-06 genericity audit). The
77
+ # length-prefix byte check at the match site is the actual validity gate.
78
+ _NAME = re.compile(rb"[A-Za-z][\w ]{0,62}_[A-Za-z0-9]{1,16}(?:@\d{1,3})?")
79
+
80
+
81
+ class CodecError(ValueError):
82
+ """A column stream that doesn't parse under the FPC codec."""
83
+
84
+
85
+ def _width(nibble: int) -> int:
86
+ """Residual byte-count for a 4-bit code — canonical FPC bcode semantics.
87
+
88
+ The low 3 bits encode the count of *stored* residual bytes, except that a
89
+ count of exactly 4 is unsupported (rare in practice, per the FPC paper) so
90
+ codes 4-7 mean 5-8 bytes. Code 0 is the predictor-exact case (0 bytes) —
91
+ for either predictor, so ``0x0`` (FCM exact) and ``0x8`` (DFCM exact) both
92
+ carry no payload. An earlier width table ``(c & 7) + 1`` coincided with
93
+ this one for c >= 4 — the only codes clean ramps ever exercise — which let
94
+ hundreds of columns validate bit-exact while ultra-smooth data (codes 0-3)
95
+ misparsed; that was the real cause of the "DFCM-collision" drop-outs.
96
+ """
97
+ c = nibble & 7
98
+ return c if c < 4 else c + 1
99
+
100
+
101
+ def _decode(stream: bytes, nrows: int) -> tuple[NDArray[np.float64], int]:
102
+ """Decode ``nrows`` float64s; return ``(values, bytes_consumed)``."""
103
+ fcm: dict[int, int] = {}
104
+ dfcm: dict[int, int] = {}
105
+ fh = dh = 0
106
+ last = 0
107
+ out: list[int] = []
108
+ pos = 0
109
+ n = len(stream)
110
+ while len(out) < nrows:
111
+ if pos >= n:
112
+ raise CodecError(f"stream exhausted at {len(out)}/{nrows} values")
113
+ ctrl = stream[pos]
114
+ pos += 1
115
+ for nibble in (ctrl & 0xF, ctrl >> 4):
116
+ width = _width(nibble)
117
+ if pos + width > n:
118
+ raise CodecError("residual overruns the buffer")
119
+ resid = int.from_bytes(stream[pos : pos + width] + b"\x00" * (8 - width), "little")
120
+ pos += width
121
+ pred = (last + dfcm.get(dh, 0)) & _MASK if nibble & 8 else fcm.get(fh, 0)
122
+ val = pred ^ resid
123
+ out.append(val)
124
+ stride = (val - last) & _MASK
125
+ fcm[fh] = val
126
+ dfcm[dh] = stride
127
+ fh = ((fh << _FCM_SHIFT) ^ (val >> _FCM_DROP)) & _TABLE_MASK
128
+ dh = ((dh << _DFCM_SHIFT) ^ (stride >> _DFCM_DROP)) & _TABLE_MASK
129
+ last = val
130
+ if len(out) >= nrows:
131
+ break
132
+ vals = np.frombuffer(np.asarray(out, dtype="<u8").tobytes(), dtype="<f8").copy()
133
+ vals[vals == ORIGIN_MISSING] = np.nan # empty cells → NaN
134
+ return vals, pos
135
+
136
+
137
+ def decode_stream(stream: bytes, nrows: int) -> NDArray[np.float64]:
138
+ """Decode ``nrows`` float64 values from one FPC control+residual stream."""
139
+ return _decode(stream, nrows)[0]
140
+
141
+
142
+ def _read_varint(b: bytes, p: int) -> tuple[int, int]:
143
+ """Read one LEB128 varint at ``p``; return ``(value, next_pos)``."""
144
+ val = shift = 0
145
+ while p < len(b):
146
+ byte = b[p]
147
+ p += 1
148
+ val |= (byte & 0x7F) << shift
149
+ shift += 7
150
+ if not byte & 0x80:
151
+ return val, p
152
+ raise CodecError("varint ran off the buffer")
153
+
154
+
155
+ def _zigzag(v: int) -> int:
156
+ """ZigZag-decode a varint (0,1,2,3,… → 0,−1,1,−2,…)."""
157
+ return (v >> 1) ^ -(v & 1)
158
+
159
+
160
+ # Repeat-run value tags: tag byte → payload length. The payload holds the
161
+ # float64's TOP bytes (rest zero) — the compact forms cover round values.
162
+ _TAG_LEN = {0x50: 8, 0x1A: 2, 0x11: 1, 0x64: 0}
163
+
164
+
165
+ def _decode_record(b: bytes, ff: int) -> tuple[NDArray[np.float64], int] | None:
166
+ """Parse *and* decode the column record whose ``ff ff`` sits at ``ff``.
167
+
168
+ Grammar (validated against Origin's own CSV dumps): ``ff ff
169
+ <nrows:varint> 00`` then a segment list. Each segment opens with a ZigZag
170
+ varint:
171
+
172
+ * **−m** — m FPC-coded rows; ``0x0c`` and the stream follow *inline*, and
173
+ the predictor state starts FRESH for every stream (the plain column is
174
+ the one-segment case — the old "2·nrows−1 size-ish field" was really
175
+ ``zigzag(−nrows)``);
176
+ * **+k** — k rows of one repeated value; a value-spec tag follows:
177
+ ``0x50`` + float64, ``0x1a``/``0x11`` + the double's top 2/1 bytes
178
+ (rest zero — round values like 1.0, 2.0, 5.0), or bare ``0x64`` = 0.0.
179
+
180
+ Segments interleave freely (staircase logger columns alternate hold-runs
181
+ and FPC bursts); a fully constant column is one repeat segment with no
182
+ stream at all. Parsing and decoding fuse because an inline stream's byte
183
+ length is only known by decoding it. Returns ``(values, end)`` with
184
+ ``end`` just past the record's last byte, or None for anything that
185
+ doesn't sum exactly to ``nrows`` — dropped, never guessed at.
186
+ """
187
+ try:
188
+ nrows, p = _read_varint(b, ff + 2)
189
+ except CodecError:
190
+ return None
191
+ if not 2 <= nrows <= 50_000_000:
192
+ return None
193
+ if p >= len(b) or b[p] != 0x00:
194
+ return None
195
+ p += 1
196
+ parts: list[NDArray[np.float64]] = []
197
+ total = 0
198
+ while total < nrows: # each iteration adds ≥1 row, so this terminates
199
+ try:
200
+ raw, p = _read_varint(b, p)
201
+ except CodecError:
202
+ return None
203
+ count = _zigzag(raw)
204
+ if count > 0:
205
+ if total + count > nrows or p >= len(b):
206
+ return None
207
+ plen = _TAG_LEN.get(b[p])
208
+ if plen is None or p + 1 + plen > len(b):
209
+ return None
210
+ value = struct.unpack("<d", b"\x00" * (8 - plen) + b[p + 1 : p + 1 + plen])[0]
211
+ p += 1 + plen
212
+ parts.append(np.full(count, value, dtype=float))
213
+ total += count
214
+ elif count < 0:
215
+ m = -count
216
+ if total + m > nrows or p >= len(b) or b[p] != 0x0C:
217
+ return None
218
+ try:
219
+ vals, consumed = _decode(b[p + 1 : p + 1 + m * 9 + 16], m)
220
+ except CodecError:
221
+ return None
222
+ parts.append(vals)
223
+ p += 1 + consumed
224
+ total += m
225
+ else:
226
+ return None
227
+ return np.concatenate(parts), p
228
+
229
+
230
+ def _records(b: bytes) -> list[int]:
231
+ """Candidate record markers (``ff ff`` positions), in file order.
232
+
233
+ ``0xff 0xff`` also occurs *inside* FPC residual data, so this over-reports;
234
+ :func:`scan_columns` walks the list with a cursor that jumps past each
235
+ decoded record, so the false in-stream markers are skipped.
236
+ """
237
+ out: list[int] = []
238
+ ff = b.find(b"\xff\xff")
239
+ while ff >= 0:
240
+ # The header opens with `0a 05 <varint>` before ff ff. The varint is
241
+ # LEB128-shaped and GROWS with the record (a 120k-row column stores
242
+ # `c0 a9 07` = 120000, three bytes — the old fixed 1-2-byte gate
243
+ # silently rejected every large column's record; 2026-07-06 audit
244
+ # item #16, confirmed by the bigcolumn.opju specimen). Accept any
245
+ # 1..5-byte span that is a WELL-FORMED varint (continuation bytes
246
+ # have the high bit set, the final byte does not) — a structural
247
+ # check, not a size cap.
248
+ for k in (3, 4, 5, 6, 7):
249
+ if ff - k < 0 or b[ff - k] != 0x0A or b[ff - k + 1] != 0x05:
250
+ continue
251
+ varint = b[ff - k + 2 : ff]
252
+ if varint and all(v & 0x80 for v in varint[:-1]) and not varint[-1] & 0x80:
253
+ out.append(ff)
254
+ break
255
+ ff = b.find(b"\xff\xff", ff + 1)
256
+ return out
257
+
258
+
259
+ def _plausible(vals: NDArray[np.float64]) -> bool:
260
+ """Reject any column that shows a decode desync.
261
+
262
+ A wrong predictor shatters the float exponent field the same way a
263
+ non-double payload does, so the shared gate applies; all-NaN additionally
264
+ rejects here because a mis-located ``.opju`` record never legitimately
265
+ decodes to nothing but sentinels.
266
+ """
267
+ return plausible_column(vals, allow_all_nan=False)
268
+
269
+
270
+ def scan_columns(b: bytes) -> list[tuple[str, NDArray[np.float64]]]:
271
+ """Decode every ``<Book>_<Col>`` numeric column in a CPYUA ``.opju`` file.
272
+
273
+ Records are walked in file order with a cursor: each decoded stream advances
274
+ the cursor past its own bytes, so the false ``ff ff`` markers buried in long
275
+ residual streams are skipped. Every real record is labelled by the nearest
276
+ preceding length-prefixed dataset name (validated on the local corpus: the
277
+ owning ``<Book>_<Col>`` name always leads its record). Streams that fail the
278
+ codec or decode to garbage are dropped — never emitted.
279
+ """
280
+ names = [
281
+ (m.start(), m.group(0).decode("latin1"))
282
+ for m in _NAME.finditer(b)
283
+ if m.start() > 0 and b[m.start() - 1] == len(m.group(0))
284
+ ]
285
+ out: list[tuple[str, NDArray[np.float64]]] = []
286
+ cursor = 0
287
+ for marker in _records(b):
288
+ if marker < cursor: # a false marker inside an already-decoded record
289
+ continue
290
+ got = _decode_record(b, marker)
291
+ if got is None:
292
+ continue
293
+ vals, end = got
294
+ if not _plausible(vals):
295
+ continue
296
+ prev = [name for pos, name in names if pos < marker]
297
+ if prev:
298
+ out.append((prev[-1], vals))
299
+ cursor = end
300
+ return out
301
+
302
+
303
+ def tail_start(b: bytes) -> int:
304
+ """Byte offset just past the last decoded worksheet-data record.
305
+
306
+ Everything before this point is the datasets section (FPC streams, whose
307
+ residual bytes routinely contain byte pairs that coincidentally match the
308
+ windows-section designation markers). ``windows_opju`` uses this to bound
309
+ its marker search to the tail region, avoiding those false positives.
310
+ """
311
+ end = 0
312
+ cursor = 0
313
+ for marker in _records(b):
314
+ if marker < cursor: # a false marker inside an already-decoded record
315
+ continue
316
+ got = _decode_record(b, marker)
317
+ if got is None:
318
+ continue
319
+ cursor = got[1]
320
+ end = max(end, cursor, marker)
321
+ return end
322
+
323
+
324
+ # ── curve plot-style (item 35 follow-on: line vs scatter) ─────────────────────
325
+ #
326
+ # A compact-int property tag inside a CPYUA curve's own object body, found at
327
+ # a variable forward offset (22-44 bytes past the curve/column token's start
328
+ # -- never a fixed offset; see ``opju_curves.py``'s ``_CURVE_RE``/``_extract_
329
+ # curves_0x03`` and ``opju_curves_allcols.py``'s ``extract_curves_allcols``,
330
+ # both of which locate a curve token first and then call this on its start).
331
+ # Validated 135/147 real-corpus curve tokens, 0 disagreements, and 4/4 on the
332
+ # ``fig_pairs`` by-construction oracle (scatter/scatter/scatter/line, matching
333
+ # LabTalk's ``plot:=201``/``plot:=200``). Lives here, not in ``opju_curves.py``,
334
+ # because ``opju_curves_allcols.py`` needs it too and already sits *beneath*
335
+ # ``opju_curves.py`` in the import graph (the latter imports the former) --
336
+ # importing it the other way round would be circular. ``opju_codec`` is the
337
+ # lowest-level module both curve modules already import (``scan_columns``/
338
+ # ``_NAME``), so it carries no new dependency edge.
339
+ _STYLE_RE = re.compile(rb"\x8f\x01(.)\x83")
340
+ _STYLE_WINDOW = 400 # backstop only; the next-token bound below is the gate
341
+ _STYLE_BYTES = {0xC8: "line", 0xC9: "scatter"}
342
+ # The two curve-token shapes (opju_figure_curves' unified id token and
343
+ # opju_curves' 0x03-family token): the style search must STOP at the next
344
+ # curve token so a curve with no style tag of its own can never pick up its
345
+ # NEIGHBOR's tag. The old fixed 400-byte window was sized against the
346
+ # corpus-wide minimum inter-token gap (697 B) — corpus-shaped: a file packing
347
+ # curve objects closer than 400 B aliased the next curve's style (2026-07-06
348
+ # genericity audit). A false positive of this pattern only SHRINKS the search
349
+ # window (worst case an honest ``None``), never widens it.
350
+ _NEXT_TOKEN_RE = re.compile(rb"\x01\x01\x01\x80[\x01\x03]|\x01.\x01\x80\x03.\x00", re.DOTALL)
351
+
352
+
353
+ def curve_plot_style(b: bytes, token_start: int) -> str | None:
354
+ """Plot style ("line"/"scatter") of the curve token starting at
355
+ ``token_start``, or ``None`` when no ``8f 01 <style> 83`` tag is found
356
+ before the next curve token (or the 400-byte backstop), or the style
357
+ byte is unrecognized.
358
+
359
+ ~8% of real-corpus curve tokens (composite-window duplicate references)
360
+ legitimately have no tag in range -- this returns ``None`` for those
361
+ rather than fabricating a default.
362
+ """
363
+ hi = min(len(b), token_start + _STYLE_WINDOW)
364
+ nxt = _NEXT_TOKEN_RE.search(b, token_start + 8, hi)
365
+ if nxt is not None:
366
+ hi = nxt.start()
367
+ m = _STYLE_RE.search(b, token_start, hi)
368
+ if m is None:
369
+ return None
370
+ return _STYLE_BYTES.get(m.group(1)[0])