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,258 @@
1
+ """The 0x01-subtype ``.opju`` curve token — the second, all-columns curve
2
+ encoding for ordinary, single-curve *default-dialog* graphs (item 35, the
3
+ "third encoding" search — this closes it, 2026-07-04).
4
+
5
+ The shipped ``opju_curves.py`` decodes one curve-token family (subtype
6
+ ``0x03``), reverse-engineered from custom-styled/multi-curve/Select-Data
7
+ graphs. A prior session searched specifically for how *ordinary* single-
8
+ curve default-dialog graphs (``RockingCurve`` ``Graph1``/``Graph2``, all of
9
+ ``XAS``, all of ``UnpolPlots``, most of ``"Fixed Lambdas SI"``) encode their
10
+ Y column and came up empty — three hypotheses chased, none validated (see
11
+ ``opju_curves.py``'s docstring, "The third encoding search — negative
12
+ result"). This module is the found fourth hypothesis, now validated end to
13
+ end against the real ``plots.json`` oracle on every one of those files.
14
+
15
+ **The token.** A whole-file scan for ``[[byte]] 01 01 01 80 01 [[byte]]``
16
+ (the discovery regex ``rb"[\\x00-\\xff]\\x01\\x01\\x01\\x80([\\x01\\x03])(.)"``
17
+ with the subtype fixed to ``0x01``) finds exactly the shape the earlier
18
+ session already isolated in ``RockingCurve``'s ``Graph1``/``Graph2`` objects
19
+ and confirmed *was* a real Origin field (the 4.3811 re-save of the same
20
+ project rewrites the identical slot into the canonical ``0x03`` shape while
21
+ preserving the same numeric value) — but could not decode, because it was
22
+ being read through the ``0x03`` family's ordinal map:
23
+
24
+ ```
25
+ <flag:1> 01 01 01 80 01 <val:1>
26
+ ```
27
+
28
+ Same family as the shipped token (``<flag> 01 01 01 80 03 <y_ord> 00``),
29
+ same fixed ``01 01`` "konst" pair, but subtype byte ``0x01`` instead of
30
+ ``0x03``, and **no fixed ``0x00`` terminator** — confirmed by dumping every
31
+ hit's context across the full real corpus (below): the 7 bytes above are
32
+ followed by a short, subtype-specific tail (``83 01 <byte> 82 01 01 80 01``
33
+ for XAS; ``83 07 <2 bytes> 00 00 0b 00`` for RockingCurve; several other
34
+ shapes for UnpolPlots/"Fixed Lambdas SI") that never repeats the fixed
35
+ ``0x00`` the ``0x03`` family always carries. No consistent terminator or
36
+ tail structure was found *across files* (each file's tail shape differs),
37
+ so none is folded into the matcher — the 7-byte prefix above is already
38
+ 100% precise by itself (see "Validation" below), and adding an unproven
39
+ tail constraint would only risk *losing* matches, never gaining precision.
40
+ The ``<flag>`` byte was checked across every hit and, like the ``0x03``
41
+ family's own flag byte, shows no correlation with column choice — it
42
+ clusters loosely in ``0xae``-``0xc4`` (a per-curve creation-order/style
43
+ counter, consistent with the shipped family's documented flag semantics)
44
+ but is never used as a match constraint.
45
+
46
+ **The counting convention — the actual bug in every earlier refutation.**
47
+ `opju_curves.py`'s ``_global_column_map`` (the shipped ordinal map) counts
48
+ *only* columns ``opju_codec.scan_columns`` actually FPC-decoded, skipping
49
+ any book with zero decodable columns entirely (e.g. an unused default
50
+ "Book1"). Decoding the 0x01 token's ``<val>`` through that map is exactly
51
+ what the earlier session tried and rejected (``RockingCurve``'s ``Nb!B``
52
+ decoded to ``Nb!C`` — wrong). The real convention is different: ``<val>``
53
+ is a 1-based ordinal counted cumulatively across **every allocated column
54
+ of every workbook, including empty/undecoded books and empty/undecoded
55
+ columns**, in file book-appearance order. Re-decoding through this all-
56
+ columns map (below) resolves every hit exactly:
57
+
58
+ * ``XAS`` (``Book1``=2 cols, an EMPTY/undecoded default book, then ``Co``=3,
59
+ ``bl11YIGPy032``=3, ``bl11YIGPy033``=3): ``<val>`` = 5, 8, 11 resolve to
60
+ exactly ``Co!C``, ``bl11YIGPy032!C``, ``bl11YIGPy033!C`` — the file's
61
+ full ``plots.json`` oracle, 3/3.
62
+ * ``RockingCurve`` (``NbAu``=7 cols incl. an undecoded ``G``, ``Nb``=5,
63
+ ``NbAl``=3): ``<val>`` = 9, 14 (each found twice — the composite
64
+ ``Graph3`` re-references) resolve to ``Nb!B``, ``NbAl!B`` — exactly the
65
+ 2 oracle pairs the shipped ``0x03`` path could not reach (it already
66
+ recovers ``NbAu!D``/``NbAu!F`` from the multi-curve ``NbAuRocking``
67
+ layer), bringing this file to 4/4.
68
+ * ``UnpolPlots`` (``Book1``=2, ``J315NdNiO3STO``=3, ``J315NdNiO3ST1``=9,
69
+ ``PrNiO3STOprof``=3, ``PrNiO3STOrefl``=9): 16 hits collapse to the 8
70
+ unique oracle pairs (each doubled by a composite-window re-reference),
71
+ 8/8.
72
+ * ``"Fixed Lambdas SI"`` (``Book1``=2, then 10 PNR books x 11 cols each):
73
+ 28 hits collapse to exactly the file's 14 unique oracle pairs (each
74
+ doubled), 14/14.
75
+
76
+ No book in this corpus needed anything beyond the FPC-decoded-only map's
77
+ *complement* — the all-columns map — to resolve every hit; every value
78
+ that decodes lands on a real, contiguous, allocated column.
79
+
80
+ **Building the all-columns map without ground truth.** ``opju_codec._NAME``
81
+ (``rb"[A-Za-z][\\w ]{0,40}_[A-Za-z0-9]{1,4}(?:@\\d{1,2})?"``) matches a
82
+ length-prefixed dataset name for *every* allocated column — including
83
+ empty ones (``XAS``'s ``Book1_A``/``Book1_B``, never decoded by
84
+ ``scan_columns`` because ``Book1`` carries no data) — but also binary
85
+ noise (arbitrary byte runs that happen to look like ``t_R``, ``G_L``,
86
+ ``U_5``). :func:`_allocated_column_map` filters this down to a clean,
87
+ GT-matching book/column inventory with three checks, all confirmed
88
+ necessary and sufficient across the four real-corpus files (see
89
+ ``test_realdata_allocated_column_map_matches_index`` for the exact
90
+ book/count comparison against each file's independently-exported
91
+ ``index.json``):
92
+
93
+ 1. Reuse ``scan_columns``'s own length-prefix anchor (``b[m.start()-1] ==
94
+ len(match)``) — the same check that makes ``_NAME`` usable at all.
95
+ 2. Keep only matches whose column suffix is **pure letters, 1-2 chars**
96
+ (``[A-Z]{1,2}``) and drop any ``@N`` sheet-suffixed match entirely (this
97
+ map does not track the extra-sheet pseudo-books the FPC-decoded map
98
+ does — no multi-sheet book in this corpus needed one).
99
+ 3. Group by book (everything before the last ``_``) and require the
100
+ resulting column-letter *set* to be an exact contiguous run starting at
101
+ ``A`` (``{A}``, ``{A,B}``, ``{A,B,C}``, …) — this is what rejects the
102
+ noise matches, which never land on a clean contiguous run.
103
+
104
+ Book order is first-appearance order of a *surviving* book's name records
105
+ in the byte stream (matches every stem's ``index.json`` book order
106
+ exactly). Column letters use the standard spreadsheet base-26 scheme
107
+ (``A``=1..``Z``=26, ``AA``=27, … — length-then-lexicographic).
108
+
109
+ **No designation gate — a deliberate, checked difference from the ``0x03``
110
+ path.** The shipped path drops any resolved column unless
111
+ ``windows_opju``'s independently-validated designation is exactly ``"Y"``
112
+ (never ``"X"``/``"Y-error"``), because that path's whole-file regex alone
113
+ is not curve-exclusive enough (the ``__BCO`` boilerplate false positive).
114
+ Applying the same gate here was checked against every one of the 27 unique
115
+ oracle-confirmed bindings this token resolves and would **wrongly reject
116
+ four of them**: ``UnpolPlots``' ``J315NdNiO3ST1!H``/``PrNiO3STOrefl!H``
117
+ ("dR Fresnel") and ``"Fixed Lambdas SI"``'s ``PNRNbAl80nm!J``/
118
+ ``PNRNbAu100nm!J`` ("dSA") are genuinely plotted curves per ``plots.json``
119
+ whose column is independently designated ``"Y-error"``, not ``"Y"`` — the
120
+ project plots an uncertainty column as its own curve, which is a legitimate
121
+ Origin usage the designation gate cannot distinguish from the ``__BCO``
122
+ artifact. Since this token's raw 7-byte match is *already* 100% precise
123
+ file-wide with zero designation cross-check (confirmed by scanning every
124
+ ``.opju`` in the corpus, both real-corpus files and every specimen —
125
+ zero hits anywhere except the four files that need them, see
126
+ ``tools/origin_trial/score_curve_bindings.py``), adding the ``0x03`` path's
127
+ designation gate here would only lose true positives for no precision
128
+ gain. The only safety check applied is structural: an ordinal that exceeds
129
+ the map's total column count for its file, or that the cumulative map
130
+ simply has no entry for, is dropped — never guessed.
131
+
132
+ **Attribution.** Reuses the same ``[anchor, next_anchor)`` per-figure
133
+ window scoping the ``0x03`` path uses (see ``figures_opju.py`` /
134
+ ``opju_curves.extract_curves``). Every hit in this corpus falls inside
135
+ some figure's window (none needed to be dropped for falling outside every
136
+ window), though — like the shipped path's own documented attribution gap
137
+ — a composite/last window can absorb tokens that structurally belong to
138
+ an earlier, already-closed window (``"Fixed Lambdas SI"``'s last anchor's
139
+ span runs to EOF and physically contains all 28 hits for both of its
140
+ book families). This is a known, pre-existing class of imprecision
141
+ (*which* figure a curve is attributed to), not a soundness one (the
142
+ ``(book, column)`` pair itself is never wrong) — see
143
+ ``opju_curves.py``'s "Known gap — per-figure attribution".
144
+
145
+ **Validation.** ``tools/origin_trial/score_curve_bindings.py`` re-run
146
+ after wiring this module in: precision 100% (0 wrong) on every stem,
147
+ aggregate recall 36/36 (100%), up from 11/36 (30.6%) before this change —
148
+ see ``docs/origin_project_format.md`` §6.2.1 for the full updated table.
149
+ """
150
+
151
+ from __future__ import annotations
152
+
153
+ import re
154
+ from collections import OrderedDict
155
+ from collections.abc import Mapping
156
+
157
+ from quantized.io.origin_project.opju_codec import _NAME, curve_plot_style
158
+
159
+ __all__ = ["extract_curves_allcols"]
160
+
161
+ # <flag:1> 01 01 01 80 01 <val:1> -- see module docstring. Unlike the shipped
162
+ # 0x03 family, no fixed terminator byte was found, so none is required here.
163
+ _CURVE_RE = re.compile(rb"[\x00-\xff]\x01\x01\x01\x80\x01(.)")
164
+
165
+ # A column suffix eligible for the all-columns map: pure letters, any width,
166
+ # no sheet suffix (that case is dropped by the caller before this check runs).
167
+ # No length cap: the old {1,2} silently discarded AAA+ columns of a >702-col
168
+ # book while the survivors still formed a contiguous A..ZZ run, shifting every
169
+ # later book's cumulative ordinal base (2026-07-06 genericity audit) —
170
+ # ``_letter_index`` already handles arbitrary-width bijective base-26.
171
+ _PURE_COLUMN = re.compile(r"[A-Z]+")
172
+
173
+
174
+ def _letter_index(letters: str) -> int:
175
+ """Standard spreadsheet column lettering: A=1, .., Z=26, AA=27, .."""
176
+ idx = 0
177
+ for ch in letters:
178
+ idx = idx * 26 + (ord(ch) - ord("A") + 1)
179
+ return idx
180
+
181
+
182
+ def _index_to_letters(n: int) -> str:
183
+ """Inverse of :func:`_letter_index`."""
184
+ out = ""
185
+ while n > 0:
186
+ n, rem = divmod(n - 1, 26)
187
+ out = chr(ord("A") + rem) + out
188
+ return out
189
+
190
+
191
+ def _allocated_column_map(b: bytes) -> OrderedDict[str, int]:
192
+ """``{book: total allocated column count}``, book-appearance order.
193
+
194
+ Counts EVERY allocated column of every workbook (including empty/
195
+ undecoded books and columns) -- the universe the 0x01 token's ``val``
196
+ counts over, unlike ``opju_curves._global_column_map``'s FPC-decoded-
197
+ only universe. See the module docstring's "Building the all-columns
198
+ map" section for the filter rule and its validation.
199
+ """
200
+ names = [
201
+ m.group(0).decode("latin1")
202
+ for m in _NAME.finditer(b)
203
+ if m.start() > 0 and b[m.start() - 1] == len(m.group(0))
204
+ ]
205
+ seen: OrderedDict[str, set[str]] = OrderedDict()
206
+ for name in names:
207
+ base, _, sheet = name.partition("@")
208
+ if sheet:
209
+ continue # drop sheet-suffixed matches -- see module docstring
210
+ book, _, col = base.rpartition("_")
211
+ if not book or not _PURE_COLUMN.fullmatch(col):
212
+ continue
213
+ seen.setdefault(book, set()).add(col)
214
+ out: OrderedDict[str, int] = OrderedDict()
215
+ for book, cols in seen.items():
216
+ n = len(cols)
217
+ if cols == {_index_to_letters(i) for i in range(1, n + 1)}:
218
+ out[book] = n
219
+ return out
220
+
221
+
222
+ def _cumulative_ordinals(book_counts: Mapping[str, int]) -> dict[int, tuple[str, str]]:
223
+ """1-based cumulative ordinal -> ``(book, column letter)`` over EVERY
224
+ allocated column of EVERY book (contrast ``opju_curves._global_column_map``,
225
+ which only counts FPC-decoded columns) -- see module docstring."""
226
+ out: dict[int, tuple[str, str]] = {}
227
+ cum = 0
228
+ for book, n in book_counts.items():
229
+ for i in range(1, n + 1):
230
+ cum += 1
231
+ out[cum] = (book, _index_to_letters(i))
232
+ return out
233
+
234
+
235
+ def extract_curves_allcols(
236
+ b: bytes, start: int, end: int, book_counts: Mapping[str, int]
237
+ ) -> list[dict[str, str | float]]:
238
+ """Every curve's ``{book, x, y}`` binding found via the 0x01-subtype
239
+ token in ``b[start:end)``.
240
+
241
+ No designation gate is applied (see module docstring — it would reject
242
+ genuine Y-error-plotted-as-curve bindings this token legitimately
243
+ finds); the only safety check is structural: a ``val`` with no entry in
244
+ the cumulative map (out of range, or the book/column doesn't exist) is
245
+ dropped, never guessed. ``x`` is the resolved book's first column
246
+ ("A") -- always present by construction, since :func:`_allocated_column_map`
247
+ only keeps books whose columns form a contiguous run starting at A.
248
+ """
249
+ ordmap = _cumulative_ordinals(book_counts)
250
+ out: list[dict[str, str | float]] = []
251
+ for m in _CURVE_RE.finditer(b, start, end):
252
+ info = ordmap.get(m.group(1)[0])
253
+ if info is None:
254
+ continue
255
+ book, y_col = info
256
+ style = curve_plot_style(b, m.start())
257
+ out.append({"book": book, "x": "A", "y": y_col, **({"style": style} if style else {})})
258
+ return out
@@ -0,0 +1,302 @@
1
+ """``.opju`` (CPYUA) figure->curve binding via the global column-id table.
2
+
3
+ **The discovery (2026-07-05, the Hc2 per-graph rework).** The two curve-token
4
+ "families" ``opju_curves.py`` / ``opju_curves_allcols.py`` decode by *counting
5
+ columns* (``<flag> 01 01 01 80 03 <y_ord> 00`` and ``… 80 01 <val>``) are in
6
+ fact ONE encoding, and the value is not an ordinal to count at all: it is the
7
+ plotted column's own **global, project-wide, creation-order serial id** — the
8
+ exact CPYUA analogue of the ``.opj`` curve-anchor id ``opj_curves.py`` decodes.
9
+ The token's id field is a tagged variable-width little-endian integer:
10
+
11
+ ```
12
+ <flag:1> 01 01 01 80 <width:01|03> <payload>
13
+ width 0x01 -> payload = <id:u8>
14
+ width 0x03 -> payload = <id:u16 LE> <flag:1> (3rd byte varies: 01/09/0b/
15
+ 0c/11/21 observed; not id)
16
+ ```
17
+
18
+ The old ``0x03``-family regex required the byte after the id byte to be
19
+ ``0x00`` — that byte is really the u16 id's HIGH byte, so the old reader
20
+ worked only on projects with < 256 columns and silently aliased ids mod 256
21
+ on larger ones (measured on ``Hc2 data.opju``: 1251+ allocated columns, 12 of
22
+ 14 decoded bindings wrong before this rework). The counting conventions the
23
+ old modules validated were an artifact: in a never-edited project, creation
24
+ order equals current layout order, so the cumulative all-columns ordinal
25
+ happens to equal the stored id. ``Hc2 data`` (heavily edited: columns added
26
+ to early books after later books existed, e.g. the ``Derivative Y1`` column
27
+ ``AH`` of the first Lockin book carrying id 132) breaks the count and proves
28
+ the id semantics.
29
+
30
+ **The id table.** Every worksheet column's own windows-section record stores
31
+ that id. Two record forms exist (both validated against the per-column
32
+ designation-marker runs ``windows_opju.py`` independently decodes):
33
+
34
+ ```
35
+ form A: 80 <serial> 01 10 80 03 <id:u16 LE> <pb> <fields…>
36
+ form B: 80 <serial> 07 10 01 00 00 <id:u16 LE> <pb> <fields…>
37
+ ```
38
+
39
+ ``<pb>`` is one uninterpreted byte (``03/09/0b/0c`` observed). ``<fields…>``
40
+ is a run of tagged fields ``<tag:0x80-0x9f> <len:u8> <payload:len>``:
41
+
42
+ * the column's **short name**: payload = optional designation prefix byte
43
+ (``0x03`` on X columns, ``0x02`` on Y-error columns) + the ASCII name;
44
+ identified as the first alnum-payload field whose *next* field's payload
45
+ opens with ``0x09``;
46
+ * a fixed ``<tag> 01 09`` separator field;
47
+ * the field carrying the 2-byte **plot-designation marker** as its payload
48
+ tail — ``21 51`` X / ``21 61`` Y / ``30 61`` Y-error, the same markers
49
+ ``windows_opju.py`` anchors label runs on; a Y column's marker field is
50
+ exactly 4 bytes — ``<x_partner_id:u16 LE> 21 61`` — giving the column's own
51
+ designated **X partner column id** (validated: Hc2's ``Derivative Y1``
52
+ column AH, id 132, carries partner id 131 = its sibling ``Derivative X1``
53
+ column AG, not the sheet's column A).
54
+
55
+ Records are attributed to their book by the containing **page span**: the
56
+ ``0a``-framed page headers ``tree_opju._OPJU_WIN_RE`` already enumerates
57
+ (byte-exact vs live COM on 5 corpus files) mark every window's start; a
58
+ column record belongs to the page (workbook) whose span it falls in.
59
+
60
+ **Form B semantics are unknown** beyond carrying the same id+fields: it is
61
+ rare (RockingCurve ``NbAu!D``/``NbAl!B``, UnpolPlots ``PrNiO3STOprof!B``/
62
+ ``PrNiO3STOrefl!I`` — exactly the four bindings the form-A-only table
63
+ missed) and never overlaps form A ids (checked corpus-wide: 0 overlaps in
64
+ 28 files). Both forms parse identically after the id.
65
+
66
+ **Validation (2026-07-05).**
67
+
68
+ * File-level ``plots.json`` oracle (7 stems, 36 unique pairs): 36/36 decoded,
69
+ 0 wrong — same aggregate as the shipped counting decoders, now via ids.
70
+ * Per-graph ``index.json`` oracle (``Hc2 data``, the first stem whose export
71
+ populated ``graphs[].layers[].plots``): decoded figures are matched to
72
+ oracle graphs by their *page name* (see ``figures_opju.py``); 7 of the 8
73
+ oracle graphs that exist as real graph pages bind exactly (Graph1/2/4/6/8/
74
+ 10/11 — every ``(book, column)`` set identical to the oracle, including
75
+ u16 ids > 255 and the id-132 later-added column). 0 wrong bindings.
76
+ * ``Graph5`` (single-curve, [A6221LockinD3]!I) is a **documented negative**:
77
+ its page span contains no curve token at all — its two DataPlot-magic
78
+ objects carry no recognisable id field (a diffed/duplicate-window form,
79
+ byte-dumped in the RE log). It decodes with ``curves == []`` (missing,
80
+ never guessed). Do NOT be tempted by the ``90 00 80 <tag> 01 89`` bytes in
81
+ its ``_202``/``_232`` sub-objects: that shape occurs in EVERY graph page of
82
+ the corpus with the constant 0x89 (and 0x02) regardless of what is plotted
83
+ — a style-boilerplate coincidence that happens to equal Graph5's true
84
+ column id 137, chased and refuted during this rework.
85
+ * Embedded fit-report graphs (the oracle's ``FitLine*``/``Residual*`` pages,
86
+ which are NOT ``0a``-framed pages): their report-sheet spans (Book2/Book3)
87
+ carry one canonical token per embedded layer anchor, resolving to the
88
+ fitted source column (Book2!C/D/E) — in-oracle, but per-graph attribution
89
+ for them is unverifiable (no page name), so they ship on unnamed figures
90
+ only. The fit-CURVE overlays (``FitNLCurveN!B`` etc.) are not
91
+ token-encoded anywhere and are honestly missing.
92
+
93
+ The old counting decoders stay in place as the fallback for byte streams
94
+ with no id table at all (synthetic fixtures; degraded files): see
95
+ ``figures_opju.extract_figures_opju``.
96
+ """
97
+
98
+ from __future__ import annotations
99
+
100
+ import re
101
+ from typing import NamedTuple
102
+
103
+ from quantized.io.origin_project.curve_style_color import (
104
+ apply_increment_colors,
105
+ opju_style_record,
106
+ style_fields,
107
+ )
108
+ from quantized.io.origin_project.opju_codec import curve_plot_style
109
+ from quantized.io.origin_project.tree_opju import iter_opju_windows
110
+
111
+ __all__ = ["ColumnIdTable", "column_id_table", "extract_curves_by_id", "opju_pages"]
112
+
113
+ # Column-record id field, form A / form B (see module docstring).
114
+ _ID_FORM_A = re.compile(rb"\x01\x10\x80\x03(..)", re.DOTALL)
115
+ _ID_FORM_B = re.compile(rb"\x07\x10\x01\x00\x00(..)", re.DOTALL)
116
+
117
+ # The unified curve token: <flag> 01 01 01 80 <width> <id…> (module docstring).
118
+ _CURVE_TOKEN = re.compile(rb"\x01\x01\x01\x80([\x01\x03])", re.DOTALL)
119
+
120
+ # Per-column plot-designation markers (same bytes windows_opju.py validates).
121
+ _MARKS = {b"\x21\x51": "X", b"\x21\x61": "Y", b"\x30\x61": "Y-error"}
122
+
123
+ _MAX_FIELDS = 10 # field-walk runaway backstop; real records resolve in <= 6
124
+ # Column short-name gate: no 16-char ceiling (the old {0,15} was a corpus
125
+ # maximum — a longer user-renamed short name made the column's id unresolvable
126
+ # and silently dropped every curve plotting it; 2026-07-06 genericity audit).
127
+ # The structural guards (the 09-separator field walk + designation marker)
128
+ # carry the precision; digit-led short names are legal in Origin.
129
+ _NAME_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_]*\Z")
130
+
131
+
132
+ class ColumnIdTable(NamedTuple):
133
+ """The decoded global column-id table of one ``.opju`` byte stream.
134
+
135
+ ``ids``: column id -> ``(book page name, column short name)``.
136
+ ``x_partner``: a Y column's id -> its designated X partner column's id.
137
+ ``book_x``: book -> its first X-designated column's short name.
138
+ ``book_pages``: page names that own >= 1 column record (i.e. workbook /
139
+ report-table pages — used by ``figures_opju`` to tell a graph page from
140
+ a book page when attaching window names to figures).
141
+ """
142
+
143
+ ids: dict[int, tuple[str, str]]
144
+ x_partner: dict[int, int]
145
+ book_x: dict[str, str]
146
+ book_pages: frozenset[str]
147
+
148
+
149
+ def opju_pages(b: bytes) -> list[tuple[int, str]]:
150
+ """Every ``0a``-framed page header as ``(offset, name)``, file order.
151
+
152
+ Reuses the exact enumeration ``tree_opju`` validated byte-exact against
153
+ live COM (first occurrence per name; the ``namelen+2`` length prefix
154
+ drives the name span, so arbitrary-length window names enumerate — a
155
+ missed header here would attribute every column in its span to the
156
+ previous page).
157
+ """
158
+ return iter_opju_windows(b)
159
+
160
+
161
+ def _walk_fields(b: bytes, p: int) -> list[bytes]:
162
+ """The tagged-field run at ``p``: payload per ``<tag 0x80-0x9f> <len>
163
+ <payload>`` field, stopping at the first byte that isn't a field tag."""
164
+ out: list[bytes] = []
165
+ n = len(b)
166
+ for _ in range(_MAX_FIELDS):
167
+ if p + 2 > n or not 0x80 <= b[p] <= 0x9F:
168
+ break
169
+ ln = b[p + 1]
170
+ payload = b[p + 2 : p + 2 + ln]
171
+ if len(payload) < ln:
172
+ break
173
+ out.append(payload)
174
+ p += 2 + ln
175
+ return out
176
+
177
+
178
+ def _parse_column_record(b: bytes, after_id: int) -> tuple[str, str | None, int | None] | None:
179
+ """Decode one column record's field run (starting just past the id).
180
+
181
+ Returns ``(short_name, designation, x_partner_id)`` or ``None`` when the
182
+ record doesn't resolve — dropped, never guessed. The name field must be
183
+ followed by the fixed ``09`` separator field AND a designation-marker
184
+ field within the next 3 fields (both checks together are what reject
185
+ coincidental byte runs; see module docstring).
186
+ """
187
+ fields = _walk_fields(b, after_id + 1) # +1: skip the uninterpreted <pb> byte
188
+ for i in range(len(fields) - 1):
189
+ payload = fields[i]
190
+ body = payload[1:] if payload[:1] in (b"\x02", b"\x03") else payload
191
+ try:
192
+ name = body.decode("ascii")
193
+ except UnicodeDecodeError:
194
+ continue
195
+ if not _NAME_RE.match(name) or fields[i + 1][:1] != b"\x09":
196
+ continue
197
+ for later in fields[i + 1 : i + 4]:
198
+ if len(later) >= 2 and later[-2:] in _MARKS:
199
+ desig = _MARKS[later[-2:]]
200
+ partner: int | None = None
201
+ if desig == "Y" and len(later) == 4:
202
+ partner = int.from_bytes(later[:2], "little")
203
+ return name, desig, partner
204
+ return None # name shape found but no designation marker: not a column record
205
+ return None
206
+
207
+
208
+ def column_id_table(b: bytes, pages: list[tuple[int, str]]) -> ColumnIdTable:
209
+ """Scan every page span for column records and build the global id table.
210
+
211
+ A record whose fields don't resolve is skipped; an id claimed twice with
212
+ *different* ``(book, column)`` is poisoned (removed entirely) — fail
213
+ closed, never guess. Records outside any page span cannot be attributed
214
+ to a book and are ignored.
215
+ """
216
+ ids: dict[int, tuple[str, str]] = {}
217
+ poisoned: set[int] = set()
218
+ x_partner: dict[int, int] = {}
219
+ book_x: dict[str, str] = {}
220
+ book_pages: set[str] = set()
221
+ bounds = [*pages, (len(b), "")]
222
+ for (start, book), (end, _next) in zip(bounds, bounds[1:], strict=False):
223
+ for pattern in (_ID_FORM_A, _ID_FORM_B):
224
+ for m in pattern.finditer(b, start, end):
225
+ cid = int.from_bytes(m.group(1), "little")
226
+ parsed = _parse_column_record(b, m.end())
227
+ if parsed is None:
228
+ continue
229
+ name, desig, partner = parsed
230
+ if cid in ids and ids[cid] != (book, name):
231
+ poisoned.add(cid)
232
+ continue
233
+ ids[cid] = (book, name)
234
+ book_pages.add(book)
235
+ if partner is not None:
236
+ x_partner[cid] = partner
237
+ if desig == "X" and book not in book_x:
238
+ book_x[book] = name
239
+ for cid in poisoned:
240
+ ids.pop(cid, None)
241
+ x_partner.pop(cid, None)
242
+ return ColumnIdTable(ids, x_partner, book_x, frozenset(book_pages))
243
+
244
+
245
+ def extract_curves_by_id(
246
+ b: bytes, start: int, end: int, table: ColumnIdTable
247
+ ) -> list[dict[str, str | float]]:
248
+ """Every curve binding in ``b[start:end)`` via the unified id token.
249
+
250
+ ``y`` resolves through ``table.ids`` (a token whose id is unknown is
251
+ dropped, never guessed). ``x`` is the Y column's own stored X-partner
252
+ when its record carried one, else the book's first X-designated column,
253
+ else ``"A"`` (the documented structural fallback). Deduped on
254
+ ``(book, y)`` in first-seen (token) order; ``style`` is attached when
255
+ ``opju_codec.curve_plot_style`` finds the ``8f 01 <style> 83`` tag
256
+ (falling back to the reconstructed record's own style byte), and
257
+ ``color``/``symbol`` when the token's sparse style record decodes
258
+ (``curve_style_color.py`` -- oracle-verified on the ``Hc2 data``/
259
+ ``RockingCurve``/``UnpolPlots`` ``curve_style.json`` oracle; auto/
260
+ undecodable fields stay absent, never defaulted).
261
+ """
262
+ out: list[dict[str, str | float]] = []
263
+ records: list[bytes | None] = []
264
+ seen: set[tuple[str, str]] = set()
265
+ for m in _CURVE_TOKEN.finditer(b, start, end):
266
+ width = m.group(1)[0]
267
+ if m.end() + width > len(b):
268
+ continue
269
+ if width == 1:
270
+ cid = b[m.end()]
271
+ else:
272
+ cid = int.from_bytes(b[m.end() : m.end() + 2], "little")
273
+ info = table.ids.get(cid)
274
+ if info is None:
275
+ continue
276
+ book, y_col = info
277
+ key = (book, y_col)
278
+ if key in seen:
279
+ continue
280
+ seen.add(key)
281
+ partner = table.x_partner.get(cid)
282
+ partner_info = table.ids.get(partner) if partner is not None else None
283
+ if partner_info is not None and partner_info[0] == book:
284
+ x_col = partner_info[1]
285
+ else:
286
+ x_col = table.book_x.get(book, "A")
287
+ curve: dict[str, str | float] = {"book": book, "x": x_col, "y": y_col}
288
+ # the token IS the sparse form of the .opj curve-anchor record; the
289
+ # id chunk's 0x80 tag sits 3 bytes into the regex match
290
+ record = opju_style_record(b, m.start() + 3)
291
+ if record is not None:
292
+ curve.update(style_fields(record))
293
+ # the shipped 8f-tag reader stays authoritative for line/scatter
294
+ style = curve_plot_style(b, m.start())
295
+ if style:
296
+ curve["style"] = style
297
+ out.append(curve)
298
+ records.append(record)
299
+ # auto/increment placeholders resolve by group role + plot order
300
+ # (curve_style_color.apply_increment_colors, pixel-oracle-verified)
301
+ apply_increment_colors(out, records)
302
+ return out