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,359 @@
1
+ """Per-curve visual style (color / symbol kind / line-vs-scatter) for Origin
2
+ graph curves -- shared by BOTH containers (solved 2026-07-06 against the
3
+ 4-stem ``curve_style.json`` oracle: ``hc2convert`` (.opj), ``Hc2 data``,
4
+ ``RockingCurve``, ``UnpolPlots`` (.opju)).
5
+
6
+ **The key structural fact.** The ``.opj`` (CPYA) curve-anchor record (the
7
+ 519/515-byte block ``opj_curves.py`` reads the column id from) and the
8
+ ``.opju`` (CPYUA) curve token are the SAME fixed-layout record: CPYUA stores
9
+ it as a sparse serialization that elides zero runs. The grammar (derived by
10
+ aligning ``hc2convert.opj`` against ``Hc2 data.opju`` -- the same project in
11
+ both containers -- and validated byte-exact through record offset 310+):
12
+
13
+ ```
14
+ <tag 0x80..0xbf> <len:u8> <payload:len> skip (tag-0x80)+3 zero bytes, then
15
+ write len literal bytes
16
+ <0xc0..0xc3> <rep:1> write (tag-0xc0)+3 copies of rep
17
+ (the same count law the axis-record
18
+ RLE uses: c2=5, c3=6)
19
+ <len 0x01..0x7f> <payload:len> bare literal continuation
20
+ ```
21
+
22
+ Reconstructing the record from the token's own ``80 01/03 <id>`` chunk
23
+ (position 1 before it; the id lands at offset 4, exactly the ``.opj``
24
+ anchor's layout) reproduces the ``.opj`` record byte-for-byte, and the
25
+ stream always completes the record to exactly 519 bytes (chained zero-len
26
+ skip chunks, e.g. ``9a 00``, pad the tail) before the DataPlot body object
27
+ (``58 80 09 98 03 40 b3``) begins.
28
+
29
+ **Style fields inside the record** (same offsets in both containers;
30
+ landmark constants at 370/378/386/394/404/420/440/448/456/472/480/488
31
+ verified at identical offsets for both the 519- and 515-byte ``.opj``
32
+ variants -- the 4-byte size difference sits past offset 492):
33
+
34
+ * offset 23 -- **symbol kind** (Origin's symbol-gallery index; 0 = none).
35
+ Oracle-verified 49/49 (.opj) + 43/43 (.opju).
36
+ * offset 76 -- the **line-vs-scatter byte** (``0xc8`` line / ``0xc9``
37
+ scatter -- the very byte ``opju_codec.curve_plot_style``'s
38
+ ``8f 01 <style> 83`` tag writes: that tag is this record's sparse chunk
39
+ for offset 76). ``0xca`` (Moke) and ``0xe7``/``0xe9`` (PNR corpus) also
40
+ occur; unmapped -- omitted, never guessed.
41
+ * offsets 302-305 -- a constant ``0xFFFFFFF7`` "auto" sentinel u32.
42
+ * offsets 306-309 -- the **symbol color** (ocolor u32 LE), terminator
43
+ ``0xff`` at 310 (the validity gate: hc2convert's 24 non-oracle anchors
44
+ carrying ``0x1e`` there get no color, fail-closed).
45
+ * offsets 362-365 -- the **line color** (ocolor u32 LE, no terminator).
46
+
47
+ The plot's *effective* color (what the oracle's COM capture reports) is the
48
+ symbol color when symbol kind > 0, else the line color -- verified 48/49 on
49
+ ``hc2convert`` reachable plots (the 49th is the palette case below) and
50
+ 39/43 on the ``.opju`` stems (the other 4 are honestly auto-on-disk: error-
51
+ bar curves whose oracle reports the inherited effective black).
52
+
53
+ **On-disk ocolor encoding** (the low-level u32; three cases):
54
+
55
+ * high byte ``0x01`` -- direct RGB, COLORREF byte order: ``0x01BBGGRR``.
56
+ Verified: every one of the oracle's 96 type-1 plots decodes to its
57
+ ``color_rgb`` under this model.
58
+ * high byte ``0x00`` -- a **0-based** index into Origin's classic 24-color
59
+ palette. The oracle/LabTalk convention is 1-based (``1`` = black); disk
60
+ stores ``index-1`` (verified: hc2convert Graph2's black plot stores 0
61
+ where the oracle says 1; UnpolPlots' orange line stores 0x0e where the
62
+ oracle says 0x0f). :func:`raw_color` returns the ORACLE (1-based) form so
63
+ callers and tests compare against the oracle int directly.
64
+ * ``0xFFFFFFF7`` -- "auto/increment" (Origin assigns by plot order). The
65
+ oracle reports the *effective* resolved color (e.g. ``-4`` or an
66
+ inherited black); that resolution is Origin-side state we cannot decode,
67
+ so auto yields ``None`` -- the frontend palette default stands.
68
+
69
+ **Line width + symbol size (SOLVED 2026-07-06, 92/92 oracle-exact both
70
+ containers):**
71
+
72
+ * offset 21 -- **line width** u16 LE, in units of 1/500 pt (``1500`` = 3.0pt,
73
+ ``250`` = 0.5pt).
74
+ * offset 25 -- **symbol size** u16 LE, same 1/500-pt units (``4500`` = 9pt).
75
+
76
+ Both store the value LabTalk itself reports: when the user resizes a graph
77
+ window Origin *bakes* the rescale into these stored values (``795`` = 1.59pt
78
+ shown as "1.6"), so there is NO separate print-factor field to decode -- the
79
+ 2026-07-05 "layer print factor" model (§13.2 #1's old blocker) was an
80
+ artifact of reading two CONSTANT fields (the DataPlot-body 213-236 triple
81
+ and record offset 282, both fixed boilerplate corpus-wide) and mistaking
82
+ oracle/constant ratios for a scale. Exhaustive per-offset search across all
83
+ 31 width-varying oracle plots isolated offsets 21/25 as the only fields
84
+ that group plots exactly by width/size.
85
+
86
+ See ``tests/test_io_origin_curve_style.py`` for the synthetic + oracle
87
+ verification suite.
88
+ """
89
+
90
+ from __future__ import annotations
91
+
92
+ import struct
93
+
94
+ __all__ = [
95
+ "ORIGIN_PALETTE",
96
+ "SYSTEM_COLOR_LIST",
97
+ "apply_increment_colors",
98
+ "ocolor_to_rgb",
99
+ "opju_style_record",
100
+ "raw_color",
101
+ "style_fields",
102
+ ]
103
+
104
+ _RECORD_LEN = 519 # the .opj curve-anchor record length (515 variant differs past 492)
105
+ _AUTO = 0xFFFFFFF7 # the on-disk "auto/increment" color sentinel (-9 as i32)
106
+ _SYMBOL_COLOR_OFF = 306
107
+ _COLOR_TERM_OFF = 310
108
+ _LINE_COLOR_OFF = 362
109
+ _SYMBOL_KIND_OFF = 23
110
+ _STYLE_BYTE_OFF = 76
111
+ _LINE_WIDTH_OFF = 21 # u16 LE, 1/500 pt (see module docstring)
112
+ _SYMBOL_SIZE_OFF = 25 # u16 LE, 1/500 pt
113
+ _PT500_MAX = 50_000 # plausibility ceiling: 100 pt — reject junk, never guess
114
+
115
+ # Origin's classic 24-color list (LabTalk ``color()`` indices 1-24, here
116
+ # 0-indexed 0-23): black, red, green, blue, cyan, magenta, yellow, dark
117
+ # yellow, navy, purple, wine, olive, dark cyan, royal, orange, violet, pink,
118
+ # white, light gray, gray, light yellow, light cyan, light magenta, dark
119
+ # gray. Ported verbatim from Origin's documented default color list -- the
120
+ # calibrated values are intentional, do not "fix".
121
+ ORIGIN_PALETTE: tuple[str, ...] = (
122
+ "#000000", # 1 black
123
+ "#FF0000", # 2 red
124
+ "#00FF00", # 3 green
125
+ "#0000FF", # 4 blue
126
+ "#00FFFF", # 5 cyan
127
+ "#FF00FF", # 6 magenta
128
+ "#FFFF00", # 7 yellow
129
+ "#808000", # 8 dark yellow
130
+ "#000080", # 9 navy
131
+ "#800080", # 10 purple
132
+ "#800000", # 11 wine
133
+ "#008000", # 12 olive
134
+ "#008080", # 13 dark cyan
135
+ "#0000A0", # 14 royal
136
+ "#FF8000", # 15 orange
137
+ "#8000FF", # 16 violet
138
+ "#FF0080", # 17 pink
139
+ "#FFFFFF", # 18 white
140
+ "#C0C0C0", # 19 light gray
141
+ "#808080", # 20 gray
142
+ "#FFFF80", # 21 light yellow
143
+ "#80FFFF", # 22 light cyan
144
+ "#FF80FF", # 23 light magenta
145
+ "#404040", # 24 dark gray
146
+ )
147
+
148
+ # Origin's symbol-gallery indices (LabTalk ``set -k``): 1 square, 2 circle,
149
+ # 3 up-triangle, 4 down-triangle, 5 diamond, 6 cross(+), 7 cross(x),
150
+ # 8 star. Indices 1-3 are oracle-verified; 4-8 port the documented gallery
151
+ # order. Names match the frontend MarkerShape union so they pass through.
152
+ _SYMBOL_SHAPES = {
153
+ 1: "square",
154
+ 2: "circle",
155
+ 3: "triangle",
156
+ 4: "downtriangle",
157
+ 5: "diamond",
158
+ 6: "plus",
159
+ 7: "cross",
160
+ 8: "star",
161
+ }
162
+
163
+ # Same byte table opju_codec._STYLE_BYTES validated (fig_pairs oracle);
164
+ # 0xca/0xe7/0xe9 also occur in the corpus but are unmapped -- never guessed.
165
+ _CONNECT_STYLE = {0xC8: "line", 0xC9: "scatter"}
166
+
167
+ # ── auto/increment colours (2026-07-06, §13.2 #2) ────────────────────────────
168
+ #
169
+ # A curve whose colour field holds the EXACT u32 ``0x81010151`` is an
170
+ # "increment placeholder": Origin resolves it at render time by walking its
171
+ # active colour list. Pinned by-construction (style_group/style_ungrouped/
172
+ # style_group12 specimens, generate_specimens_style.py) with a RENDER-PIXEL
173
+ # oracle (expGraph PNG, sampled line colours — the COM ``layer.plotN.color``
174
+ # property reports only the group-level colour, so pixels are the only
175
+ # per-member ground truth):
176
+ #
177
+ # * record byte 6 carries the plot's group role: ``0x09`` standalone,
178
+ # ``0x29`` group head, ``0x19`` group member (byte-diff of the grouped vs
179
+ # ungrouped specimens, otherwise identical records).
180
+ # * a GROUPED placeholder takes SYSTEM_COLOR_LIST[k], k = index within its
181
+ # group (verified for k=0..11, twelve distinct colours, no wrap);
182
+ # * an UNGROUPED placeholder always renders the list's FIRST colour.
183
+ #
184
+ # SYSTEM_COLOR_LIST is Origin's default "System Color List" (2018+); the 12
185
+ # entries below are the pixel-verified ones. A project using a CUSTOM colour
186
+ # list is Origin-side state we cannot see — any 0x81-typed value OTHER than
187
+ # the observed 0x81010151 payload is left unresolved (None) rather than
188
+ # guessed, and members past index 11 are likewise left unresolved.
189
+ # Values are calibrated/verified — do not "fix".
190
+ SYSTEM_COLOR_LIST: tuple[str, ...] = (
191
+ "#515151", "#F14040", "#1A6FDF", "#37AD6B", "#B177DE", "#CC9900",
192
+ "#00CBCC", "#7D4E4E", "#8E8E00", "#FB6501", "#6699CC", "#6FB802",
193
+ ) # fmt: skip
194
+
195
+ _INCREMENT_PLACEHOLDER = 0x81010151
196
+ _GROUP_ROLE_OFF = 6
197
+ _GROUP_ROLES = {0x09: "standalone", 0x29: "head", 0x19: "member"}
198
+
199
+
200
+ def ocolor_to_rgb(raw: int) -> str | None:
201
+ """An Origin ocolor int (ORACLE/LabTalk form) -> ``"#RRGGBB"``, or
202
+ ``None`` for auto/unrecognized (never guessed).
203
+
204
+ Type 1 (high byte ``0x01``) is a direct COLORREF: ``0x01BBGGRR``.
205
+ Type 0 (high byte ``0x00``) is a 1-based classic-palette index (1-24) --
206
+ the LabTalk convention the ground-truth oracle uses; the on-disk field
207
+ is 0-based and :func:`raw_color` converts before returning.
208
+ """
209
+ raw &= 0xFFFFFFFF
210
+ kind = raw >> 24
211
+ if kind == 1: # direct RGB (COLORREF low 24 bits, BGR order)
212
+ r, g, b = raw & 0xFF, (raw >> 8) & 0xFF, (raw >> 16) & 0xFF
213
+ return f"#{r:02X}{g:02X}{b:02X}"
214
+ if kind == 0 and 1 <= raw <= len(ORIGIN_PALETTE): # classic palette, 1-based
215
+ return ORIGIN_PALETTE[raw - 1]
216
+ return None # auto/increment or an unrecognized type: no color, never guess
217
+
218
+
219
+ def raw_color(record: bytes) -> int | None:
220
+ """The plot's effective ocolor from a curve-anchor record, in ORACLE
221
+ (1-based-palette) form, or ``None`` (auto on disk / gates failed).
222
+
223
+ Reads the symbol color (offset 306) for symbol plots (kind > 0) and the
224
+ line color (offset 362) otherwise -- the rule the oracle verified 87/92.
225
+ Gated on the color-group terminator byte (``0xff`` at 310); a record
226
+ that fails it (or is too short) yields ``None``, never a guess.
227
+ """
228
+ if len(record) < _LINE_COLOR_OFF + 4 or record[_COLOR_TERM_OFF] != 0xFF:
229
+ return None
230
+ off = _SYMBOL_COLOR_OFF if record[_SYMBOL_KIND_OFF] > 0 else _LINE_COLOR_OFF
231
+ field = struct.unpack_from("<I", record, off)[0]
232
+ if field == _AUTO:
233
+ return None # auto/increment: resolved Origin-side, not decodable here
234
+ kind = field >> 24
235
+ if kind == 0:
236
+ return field + 1 if field < len(ORIGIN_PALETTE) else None # disk is 0-based
237
+ return field if kind == 1 else None
238
+
239
+
240
+ def style_fields(record: bytes) -> dict[str, str | float]:
241
+ """Decoded per-curve style keys from one curve-anchor record (raw ``.opj``
242
+ payload or :func:`opju_style_record` reconstruction): any of ``color``
243
+ (``"#RRGGBB"``), ``symbol`` (marker shape name), ``style``
244
+ (``"line"``/``"scatter"``), ``lineWidth`` / ``symbolSize`` (points, the
245
+ 1/500-pt u16 fields at offsets 21/25 — 92/92 oracle-exact). Undecodable
246
+ or implausible fields are simply absent, never defaulted."""
247
+ out: dict[str, str | float] = {}
248
+ if len(record) < _LINE_COLOR_OFF + 4:
249
+ return out
250
+ style = _CONNECT_STYLE.get(record[_STYLE_BYTE_OFF])
251
+ if style:
252
+ out["style"] = style
253
+ shape = _SYMBOL_SHAPES.get(record[_SYMBOL_KIND_OFF])
254
+ if shape:
255
+ out["symbol"] = shape
256
+ width500 = struct.unpack_from("<H", record, _LINE_WIDTH_OFF)[0]
257
+ if 0 < width500 <= _PT500_MAX:
258
+ out["lineWidth"] = width500 / 500.0
259
+ size500 = struct.unpack_from("<H", record, _SYMBOL_SIZE_OFF)[0]
260
+ if 0 < size500 <= _PT500_MAX:
261
+ out["symbolSize"] = size500 / 500.0
262
+ raw = raw_color(record)
263
+ if raw is not None:
264
+ rgb = ocolor_to_rgb(raw)
265
+ if rgb:
266
+ out["color"] = rgb
267
+ return out
268
+
269
+
270
+ def _effective_color_field(record: bytes) -> int | None:
271
+ """The raw u32 of the plot's effective colour field (symbol colour for
272
+ symbol plots, line colour otherwise), or ``None`` on a short record."""
273
+ if len(record) < _LINE_COLOR_OFF + 4:
274
+ return None
275
+ off = _SYMBOL_COLOR_OFF if record[_SYMBOL_KIND_OFF] > 0 else _LINE_COLOR_OFF
276
+ return int(struct.unpack_from("<I", record, off)[0])
277
+
278
+
279
+ def apply_increment_colors(
280
+ curves: list[dict[str, str | float]], records: list[bytes | None]
281
+ ) -> None:
282
+ """Resolve auto/increment placeholder colours in-place for one layer's
283
+ curves (plot order). See the SYSTEM_COLOR_LIST block comment for the
284
+ verified rule. ``records[i]`` is curve ``i``'s style record (``None``
285
+ when unavailable). Only a curve with NO decoded ``color``, whose
286
+ effective colour field is the exact ``0x81010151`` placeholder, and
287
+ whose group role byte is recognized, is filled — everything else is
288
+ left untouched (never guessed).
289
+ """
290
+ group_index: int | None = None # None = not inside a group
291
+ for curve, record in zip(curves, records, strict=True):
292
+ if record is None or len(record) <= _GROUP_ROLE_OFF:
293
+ group_index = None
294
+ continue
295
+ role = _GROUP_ROLES.get(record[_GROUP_ROLE_OFF])
296
+ if role == "head":
297
+ group_index = 0
298
+ elif role == "member":
299
+ group_index = group_index + 1 if group_index is not None else None
300
+ else: # standalone or unrecognized: any open group ends here
301
+ group_index = None
302
+ if "color" in curve or _effective_color_field(record) != _INCREMENT_PLACEHOLDER:
303
+ continue
304
+ if role == "standalone":
305
+ curve["color"] = SYSTEM_COLOR_LIST[0]
306
+ elif role in ("head", "member") and group_index is not None:
307
+ if group_index < len(SYSTEM_COLOR_LIST):
308
+ curve["color"] = SYSTEM_COLOR_LIST[group_index]
309
+ # past the verified list: leave unresolved, never wrap-guess
310
+
311
+
312
+ def opju_style_record(b: bytes, tag_pos: int) -> bytes | None:
313
+ """Reconstruct the 519-byte curve-anchor record from the CPYUA sparse
314
+ stream whose id chunk (``80 01/03 <id>``) starts at ``tag_pos``.
315
+
316
+ Follows the chunk grammar in the module docstring. Returns ``None``
317
+ unless the stream demonstrably completes the record (reaches offset
318
+ 519 exactly, as every validated real stream does) -- a partial
319
+ reconstruction could misread an unreached zero region as palette black,
320
+ which is exactly the wrong-color failure this gate forbids."""
321
+ buf = bytearray(_RECORD_LEN)
322
+ pos = 1 # the id chunk's skip starts after the record's offset-0 byte
323
+ p = tag_pos
324
+ n = len(b)
325
+ while p < n and pos < _RECORD_LEN:
326
+ t = b[p]
327
+ if 0x80 <= t <= 0xBF: # tagged chunk: skip zeros, then literal bytes
328
+ if p + 2 > n:
329
+ return None
330
+ ln = b[p + 1]
331
+ if p + 2 + ln > n:
332
+ return None
333
+ pos += (t - 0x80) + 3
334
+ payload = b[p + 2 : p + 2 + ln]
335
+ end = min(pos + ln, _RECORD_LEN)
336
+ if pos < _RECORD_LEN:
337
+ buf[pos:end] = payload[: end - pos]
338
+ pos += ln
339
+ p += 2 + ln
340
+ elif 0xC0 <= t <= 0xC3: # RLE run: (t-0xc0)+3 copies of the next byte
341
+ if p + 2 > n:
342
+ return None
343
+ run = (t - 0xC0) + 3
344
+ end = min(pos + run, _RECORD_LEN)
345
+ if pos < _RECORD_LEN:
346
+ buf[pos:end] = bytes([b[p + 1]]) * (end - pos)
347
+ pos += run
348
+ p += 2
349
+ elif 0 < t < 0x80: # bare literal continuation
350
+ if p + 1 + t > n:
351
+ return None
352
+ end = min(pos + t, _RECORD_LEN)
353
+ if pos < _RECORD_LEN:
354
+ buf[pos:end] = b[p + 1 : p + 1 + (end - pos)]
355
+ pos += t
356
+ p += 1 + t
357
+ else: # 0x00 / unknown escape: the stream ended before the record did
358
+ return None
359
+ return bytes(buf) if pos >= _RECORD_LEN else None
@@ -0,0 +1,108 @@
1
+ """Layer-frame and page geometry for Origin graph windows (§13.2 #7).
2
+
3
+ A graph *page* has a size in "page units"; each *layer* (panel) occupies a
4
+ frame rect on it. Both containers store the same quantities, in the same
5
+ units the annotation/legend object boxes use (``annotation_marks``), so
6
+ frame + page size place every panel of a multi-panel window and every
7
+ positioned object — the full faithful-layout geometry chain.
8
+
9
+ **`.opj` (CPYA)** — solved 2026-07-06 against the live-COM
10
+ ``layer_geometry.json`` oracle (``export_layer_geometry.py``):
11
+
12
+ * layer frame: u16 LE quad at layer-continuation payload offsets
13
+ **113/115/117/119** = (left, top, right, bottom) page units — 41/44
14
+ oracle layers exact (the 3 misses are Moke's LINKED composite layers
15
+ where COM itself reports out-of-page link-mode values);
16
+ * page size: u16 LE (width, height) pair at window-header payload offset
17
+ **35** — 39/39 oracle windows exact.
18
+
19
+ **`.opju` (CPYUA)**:
20
+
21
+ * layer frame: the marker ``12 00 20 22`` within ~300 bytes after the
22
+ layer's axis-record anchor, immediately followed by the same u16 quad —
23
+ 30/30 oracle layers exact where the marker exists; absent on some
24
+ composite/embedded layers (fail-closed ``None``);
25
+ * page size: near the page-header start, a u16 (width, height) pair with
26
+ variable framing — recovered by scanning the first bytes of the page
27
+ span for a plausible pair that CONTAINS every decoded layer frame
28
+ (a structural validation, not a fixed offset); ``None`` when no
29
+ candidate or several disagree.
30
+
31
+ Pure leaf: bytes in → ints out.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import struct
37
+
38
+ __all__ = ["opj_layer_frame", "opj_page_size", "opju_layer_frame", "opju_page_size"]
39
+
40
+ _OPJ_FRAME_OFFSET = 113
41
+ _OPJ_PAGE_SIZE_OFFSET = 35
42
+ _OPJU_FRAME_MARK = b"\x12\x00\x20\x22"
43
+ _OPJU_FRAME_SCAN = 300
44
+ _OPJU_PAGE_SCAN = 80
45
+
46
+
47
+ def _plausible_quad(q: tuple[int, int, int, int]) -> bool:
48
+ left, top, right, bottom = q
49
+ return left < right and top < bottom
50
+
51
+
52
+ def opj_layer_frame(layer_payload: bytes) -> tuple[int, int, int, int] | None:
53
+ """The ``.opj`` layer frame quad ``(left, top, right, bottom)`` in page
54
+ units, or ``None`` when missing/degenerate (older layer-block variants) —
55
+ callers fall back to the fraction-pair position model."""
56
+ if len(layer_payload) < _OPJ_FRAME_OFFSET + 8:
57
+ return None
58
+ quad = struct.unpack_from("<4H", layer_payload, _OPJ_FRAME_OFFSET)
59
+ if not _plausible_quad(quad):
60
+ return None
61
+ return int(quad[0]), int(quad[1]), int(quad[2]), int(quad[3])
62
+
63
+
64
+ def opj_page_size(header_payload: bytes) -> dict[str, int] | None:
65
+ """The graph page's (width, height) in page units from its window-header
66
+ payload, or ``None`` when implausible — never guessed."""
67
+ if len(header_payload) < _OPJ_PAGE_SIZE_OFFSET + 4:
68
+ return None
69
+ w, h = struct.unpack_from("<2H", header_payload, _OPJ_PAGE_SIZE_OFFSET)
70
+ if not (200 <= w <= 60000 and 200 <= h <= 60000):
71
+ return None
72
+ return {"width": int(w), "height": int(h)}
73
+
74
+
75
+ def opju_layer_frame(b: bytes, anchor: int, end: int) -> tuple[int, int, int, int] | None:
76
+ """The ``.opju`` layer frame quad for the axis record at ``anchor``, or
77
+ ``None`` when the marker isn't in range (composite/embedded layers)."""
78
+ j = b.find(_OPJU_FRAME_MARK, anchor, min(end, anchor + _OPJU_FRAME_SCAN))
79
+ if j < 0 or j + 4 + 8 > len(b):
80
+ return None
81
+ quad = struct.unpack_from("<4H", b, j + 4)
82
+ if not _plausible_quad(quad):
83
+ return None
84
+ return int(quad[0]), int(quad[1]), int(quad[2]), int(quad[3])
85
+
86
+
87
+ def opju_page_size(
88
+ b: bytes, page_start: int, frames: list[tuple[int, int, int, int]]
89
+ ) -> dict[str, int] | None:
90
+ """The ``.opju`` graph page's (width, height): the unique plausible u16
91
+ pair near the page-header start that CONTAINS every decoded layer frame
92
+ (right <= width, bottom <= height, within 8x of the frames' extent so an
93
+ absurdly large accidental pair can't win). ``None`` without frames to
94
+ validate against, or when no/multiple distinct candidates pass."""
95
+ if not frames:
96
+ return None
97
+ max_r = max(f[2] for f in frames)
98
+ max_b = max(f[3] for f in frames)
99
+ found: set[tuple[int, int]] = set()
100
+ hi = min(len(b) - 4, page_start + _OPJU_PAGE_SCAN)
101
+ for off in range(page_start, hi):
102
+ w, h = struct.unpack_from("<2H", b, off)
103
+ if max_r <= w <= 8 * max_r and max_b <= h <= 8 * max_b:
104
+ found.add((int(w), int(h)))
105
+ if len(found) != 1:
106
+ return None # ambiguous or absent: no page size, never guessed
107
+ w, h = next(iter(found))
108
+ return {"width": w, "height": h}