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,314 @@
1
+ """Origin graph templates (``.otp``/``.otpu``) -> a quantized ``GraphTemplate``
2
+ (gap-ecosystem plan item 5, decode-plan #21). GRAPH templates only (owner
3
+ decision): a graph template's saved style (axis limits, legend, per-curve
4
+ color/line-vs-scatter/width/marker) maps onto the frontend's ``GraphTemplate``
5
+ shape (``frontend/src/lib/figuredoc.ts`` -- ``name``/``style``/``overrides``/
6
+ ``seriesStyles``); workbook/analysis templates have no quantized counterpart
7
+ and are out of scope.
8
+
9
+ **Recon (2026-07-07).** Both template extensions are the SAME CPY container
10
+ family already documented in ``docs/origin_project_format.md`` sec 2 -- an
11
+ ``.otp`` opens with the ``CPYA`` magic (verified: the corpus's
12
+ ``SLD_DoubleY.otp`` is ``CPYA 4.3227``, an older sub-version of the same
13
+ family already handled), an ``.otpu`` with ``CPYUA`` (verified: all four
14
+ corpus ``.otpu`` files are ``CPYUA 4.3380``). Critically, the EXISTING figure
15
+ decoders already understand a template's bytes directly -- ``figures.
16
+ extract_figures``/``figures_opju.extract_figures_opju`` take raw file bytes,
17
+ not a parsed workbook, and a template's graph window(s) are laid out exactly
18
+ like a project's: axis ranges, log flags, titles, legend text/labels, frame
19
+ and page geometry all decoded verbatim, zero new code, against 4 of the 5
20
+ corpus templates (``SLD_DoubleY.otp``, ``PNR-SF.otpu``, ``SLDdouble.otpu``,
21
+ ``UnpolFresnelNR.otpu``). The 5th, ``PNR.otpu``, carries exactly one axis
22
+ anchor (``03 00 00 1f``) whose record matches NONE of the three existing
23
+ ``.opju`` axis-record forms (specimen / real / hybrid -- all return ``None``
24
+ at that offset): a genuinely new, undocumented 4th record shape. Per the
25
+ "conservative first decoder" scope, this is left undecoded and documented,
26
+ not chased -- ``read_origin_template`` degrades that one file to a
27
+ styles-only partial (see below) rather than guessing an axis record or
28
+ failing the whole file, since its curve-style tokens decode fine
29
+ independently.
30
+
31
+ **What does NOT decode via the existing path: curve style.** A template
32
+ carries no workbook columns, so ``opj_curves.extract_curves``/
33
+ ``opju_figure_curves.extract_curves_by_id`` -- which bind a curve to
34
+ ``(book, x, y)`` via a global column-id lookup built by scanning the
35
+ project's OWN column-storage blocks -- always come up with an empty id map
36
+ for a template file (there is no workbook to scan), so every curve is
37
+ silently dropped even though its raw style record is present on disk and
38
+ fully decodable. Confirmed by direct byte-level recon: every corpus template
39
+ carries real ``curve_style_color.style_fields``-decodable records (explicit
40
+ RGB colors, line/scatter, line width, symbol size) with NO book/column
41
+ resolution required -- the style lives entirely inside the curve's own
42
+ anchor record (``.otp``) / sparse id token (``.otpu``), the same record
43
+ ``curve_style_color.py`` already decodes for real projects. This module reuses
44
+ ONLY the style half of those two decoders (:func:`_template_curve_styles_opj`
45
+ / :func:`_template_curve_styles_opju`), never their book/column binding --
46
+ templates have no dataset to bind to, so book/x/y stay permanently absent by
47
+ design, not by gap.
48
+
49
+ **Mapping to ``GraphTemplate`` (honestly partial).** ``name`` is the file
50
+ stem; ``style`` stays the fixed string ``"default"`` (Origin templates carry
51
+ no quantized preset concept to recover). ``overrides`` comes from the
52
+ template's FIRST decoded graph layer only -- ``x_lim``/``y_lim`` (axis
53
+ range) and ``legend`` (``show`` + a nearest-quadrant ``loc``, mirroring
54
+ ``frontend/src/lib/originFigures.ts``'s ``originLegendPos``); a template with
55
+ >1 layer (e.g. a double-Y style like ``SLD_DoubleY.otp``/``SLDdouble.otpu``)
56
+ has no way to carry its 2nd layer's own Y range in this shape (``GraphTemplate``
57
+ itself has no multi-layer/y2 concept -- see ``figuredoc.ts``), so that layer's
58
+ style is simply not represented; this is a target-SHAPE limitation, not a
59
+ decode failure. ``seriesStyles`` is built from EVERY decoded curve style
60
+ record found in the file, in on-disk order, mirroring
61
+ ``originCurveSeriesStyle``'s color/line-vs-scatter/width/marker rule but
62
+ targeting the export-style shape (``frontend/src/lib/exportStyles.ts``'s
63
+ ``ExportSeriesStyle`` -- ``color``/``width``/``line``/``marker``/
64
+ ``marker_size``); since ``GraphTemplate.seriesStyles`` is ALREADY one flat
65
+ list with no per-layer grouping, collapsing a multi-layer template's curves
66
+ into one file-wide list matches the target shape's own limitation rather
67
+ than adding a new one. Undecodable per-curve properties (Origin's symbol
68
+ *shape* -- square/circle/triangle/... -- has no field in ``ExportSeriesStyle``,
69
+ only a boolean "draw a marker") and undecodable per-layer properties (grid
70
+ on/off, tick direction, font: no isolated on-disk field is known for any of
71
+ these -- see ``docs/origin_project_format.md`` sec 6.3's permanent-gaps list)
72
+ stay absent from the returned dict, never guessed.
73
+
74
+ Non-graph templates (a workbook/analysis template, or any file whose graph
75
+ layer(s) fail to decode at all) raise :class:`OriginProjectError` with what
76
+ was found, per the "an honest partial beats a guessed decoder" rule --
77
+ :func:`read_origin_template` never fabricates a template from nothing.
78
+ """
79
+
80
+ from __future__ import annotations
81
+
82
+ import math
83
+ import re
84
+ import struct
85
+ from pathlib import Path
86
+ from typing import Any
87
+
88
+ from quantized.io.origin_project.container import OriginProjectError, walk_blocks
89
+ from quantized.io.origin_project.curve_style_color import (
90
+ apply_increment_colors,
91
+ opju_style_record,
92
+ style_fields,
93
+ )
94
+ from quantized.io.origin_project.figures import extract_figures
95
+ from quantized.io.origin_project.figures_opju import extract_figures_opju
96
+ from quantized.io.origin_project.opj_curves import _CURVE_PREFIX, _DATAPLOT_MAGIC
97
+ from quantized.io.origin_project.opju_codec import curve_plot_style
98
+ from quantized.io.origin_project.opju_figure_curves import _CURVE_TOKEN
99
+
100
+ __all__ = ["read_origin_template"]
101
+
102
+ _HEX_RE = re.compile(r"^#[0-9A-Fa-f]{6}$")
103
+ _StyleFields = dict[str, str | float]
104
+
105
+
106
+ def _template_curve_styles_opj(b: bytes) -> list[_StyleFields]:
107
+ """Every decodable curve-anchor style record in a CPYA template, file-wide,
108
+ in on-disk order -- the style half of ``opj_curves.extract_curves`` (item
109
+ 11's ``01 00 00 00 <id>`` anchor immediately followed by the DataPlot
110
+ magic), deliberately WITHOUT its ``id_map``/``x_columns`` book/column
111
+ binding (a template has no workbook to build either from -- see module
112
+ docstring)."""
113
+ blocks = [(size, payload) for size, payload in walk_blocks(b) if size]
114
+ out: list[_StyleFields] = []
115
+ records: list[bytes | None] = []
116
+ for j in range(len(blocks) - 1):
117
+ _, payload = blocks[j]
118
+ if len(payload) < 6 or payload[:4] != _CURVE_PREFIX:
119
+ continue
120
+ _, next_payload = blocks[j + 1]
121
+ if next_payload[:8] != _DATAPLOT_MAGIC:
122
+ continue
123
+ out.append(style_fields(payload))
124
+ records.append(payload)
125
+ apply_increment_colors(out, records)
126
+ return out
127
+
128
+
129
+ def _template_curve_styles_opju(b: bytes) -> list[_StyleFields]:
130
+ """Same, for CPYUA templates: every unified id-token's sparse style record
131
+ (``opju_figure_curves``'s ``_CURVE_TOKEN``), reconstructed via
132
+ ``curve_style_color.opju_style_record`` and read with ``style_fields`` --
133
+ independent of ``opju_figure_curves.column_id_table`` (empty for a
134
+ template: no workbook column ever assigns the token's id)."""
135
+ out: list[_StyleFields] = []
136
+ records: list[bytes | None] = []
137
+ for m in _CURVE_TOKEN.finditer(b):
138
+ record = opju_style_record(b, m.start() + 3)
139
+ curve: _StyleFields = dict(style_fields(record)) if record is not None else {}
140
+ style = curve_plot_style(b, m.start())
141
+ if style:
142
+ curve["style"] = style
143
+ out.append(curve)
144
+ records.append(record)
145
+ apply_increment_colors(out, records)
146
+ return out
147
+
148
+
149
+ def _series_style(fields: _StyleFields) -> dict[str, Any] | None:
150
+ """One decoded curve's style fields -> an ``ExportSeriesStyle``-shaped
151
+ dict (``frontend/src/lib/exportStyles.ts``), mirroring
152
+ ``frontend/src/lib/originFigures.ts``'s ``originCurveSeriesStyle`` (the
153
+ already-shipped rule for applying a decoded Origin curve's style to a
154
+ plot) but targeting the *export* style shape a saved template carries:
155
+ "scatter" -> markers, no connecting line (``width: 0``); "line" -> a
156
+ solid line at the default 1.5pt width; a decoded ``lineWidth`` overrides
157
+ that default (never on a scatter curve -- Origin stores a latent line
158
+ width even on symbol-only plots); a decoded ``symbolSize`` sets
159
+ ``marker_size`` only once a marker is already on. No ``markerShape`` key
160
+ exists in ``ExportSeriesStyle``, so a decoded ``symbol`` only turns
161
+ ``marker`` on -- its glyph (square/circle/triangle/...) is not
162
+ representable in this target shape (documented gap, not a decode miss).
163
+ Returns ``None`` when nothing decoded, so the caller's list holds an
164
+ honest ``null`` slot rather than an empty object."""
165
+ out: dict[str, Any] = {}
166
+ style = fields.get("style")
167
+ if style == "scatter":
168
+ out["marker"] = True
169
+ out["width"] = 0
170
+ elif style == "line":
171
+ out["width"] = 1.5
172
+ color = fields.get("color")
173
+ if isinstance(color, str) and _HEX_RE.match(color):
174
+ out["color"] = color
175
+ if fields.get("symbol"):
176
+ out["marker"] = True
177
+ width = fields.get("lineWidth")
178
+ if isinstance(width, int | float) and width > 0 and style != "scatter":
179
+ out["width"] = width
180
+ size = fields.get("symbolSize")
181
+ if isinstance(size, int | float) and size > 0 and out.get("marker"):
182
+ out["marker_size"] = size
183
+ return out or None
184
+
185
+
186
+ def _finite_range(lo: Any, hi: Any) -> bool:
187
+ return (
188
+ isinstance(lo, int | float)
189
+ and isinstance(hi, int | float)
190
+ and math.isfinite(lo)
191
+ and math.isfinite(hi)
192
+ and lo != hi
193
+ )
194
+
195
+
196
+ def _axis_fraction(v: float, lo: float, hi: float, log: bool) -> float | None:
197
+ """Fraction of ``v`` along ``[lo, hi]``, log10-aware on a log axis --
198
+ mirrors ``frontend/src/lib/originFigures.ts``'s ``axisFraction`` (the
199
+ same model the backend used to decode the legend box position in the
200
+ first place, see ``annotation_marks.py``)."""
201
+ if log and lo > 0 and hi > 0 and v > 0:
202
+ a, b = math.log10(lo), math.log10(hi)
203
+ return (math.log10(v) - a) / (b - a) if b != a else None
204
+ return (v - lo) / (hi - lo) if hi != lo else None
205
+
206
+
207
+ def _template_legend(fig: dict[str, Any]) -> dict[str, Any] | None:
208
+ """``{"show": True, "loc": "<vert> <side>"}`` when the layer carries any
209
+ non-empty legend text or a decoded legend-box position, else ``None``.
210
+ ``loc`` maps the decoded position (data coords) to the nearest quadrant
211
+ -- the same corner logic as ``originFigures.ts``'s ``originLegendPos``,
212
+ landing on one of ``frontend/src/lib/figureOverrides.ts``'s
213
+ ``LEGEND_LOCS`` strings (``"upper right"``/``"upper left"``/
214
+ ``"lower left"``/``"lower right"``) rather than a guessed pixel anchor."""
215
+ labels = [t for t in (fig.get("legend_labels") or []) if t]
216
+ pos = fig.get("legend_pos")
217
+ if not labels and pos is None:
218
+ return None
219
+ out: dict[str, Any] = {"show": True}
220
+ if pos is not None:
221
+ fx = _axis_fraction(pos["x"], fig["x_from"], fig["x_to"], fig["x_log"])
222
+ fy = _axis_fraction(pos["y"], fig["y_from"], fig["y_to"], fig["y_log"])
223
+ if fx is not None and fy is not None and math.isfinite(fx) and math.isfinite(fy):
224
+ vert = "upper" if fy >= 0.5 else "lower"
225
+ side = "right" if fx >= 0.5 else "left"
226
+ out["loc"] = f"{vert} {side}"
227
+ return out
228
+
229
+
230
+ def _template_overrides(fig: dict[str, Any]) -> dict[str, Any] | None:
231
+ """``FigureOverrides``-shaped dict (``frontend/src/lib/figureOverrides.ts``)
232
+ from one figure-layer dict: ``x_lim``/``y_lim`` (only when both bounds are
233
+ finite and distinct) and ``legend``. Every other ``FigureOverrides`` key
234
+ (``grid``, ``ticks``, ``spines``, ``margins``, ``font_size``/``font_name``,
235
+ ``annotations``) has no isolated on-disk field this codebase decodes (see
236
+ ``docs/origin_project_format.md`` sec 6.3's permanent-gaps list) and stays
237
+ absent -- never defaulted."""
238
+ out: dict[str, Any] = {}
239
+ if _finite_range(fig.get("x_from"), fig.get("x_to")):
240
+ out["x_lim"] = [fig["x_from"], fig["x_to"]]
241
+ if _finite_range(fig.get("y_from"), fig.get("y_to")):
242
+ out["y_lim"] = [fig["y_from"], fig["y_to"]]
243
+ legend = _template_legend(fig)
244
+ if legend:
245
+ out["legend"] = legend
246
+ return out or None
247
+
248
+
249
+ _DECODE_ERRORS = (IndexError, ValueError, KeyError, struct.error)
250
+
251
+
252
+ def read_origin_template(path: Path) -> dict[str, Any]:
253
+ """Decode an Origin GRAPH template (``.otp``/``.otpu``) into a
254
+ ``GraphTemplate``-shaped dict (``frontend/src/lib/figuredoc.ts``):
255
+ ``name`` (the file stem), ``style`` (always ``"default"``), ``overrides``
256
+ (axis limits + legend from the first decoded layer, or ``None``), and
257
+ ``seriesStyles`` (one entry per decoded curve, file-wide, in on-disk
258
+ order, or ``None`` when no curve style decoded). See the module docstring
259
+ for the full recon + mapping rationale.
260
+
261
+ Raises :class:`OriginProjectError` (mapped to a 422 by the import route)
262
+ when ``path`` isn't a recognized template container, or when its magic
263
+ header doesn't match the extension's expected CPY family, or when NO
264
+ graph layer decodes at all (e.g. a workbook/analysis template -- out of
265
+ scope for this graph-style mapping) -- an honest failure, never a
266
+ fabricated template.
267
+ """
268
+ suffix = path.suffix.lower()
269
+ if suffix not in (".otp", ".otpu"):
270
+ raise OriginProjectError(
271
+ f"'{path.name}' is not an Origin graph template (expected .otp or .otpu)."
272
+ )
273
+ b = path.read_bytes()
274
+ # Magic-header checks stay OUTSIDE the decode try/except below: they raise
275
+ # OriginProjectError directly, which is itself a ValueError -- inside the
276
+ # try it would be caught by `_DECODE_ERRORS` and double-wrapped.
277
+ if suffix == ".otp":
278
+ if not b.startswith(b"CPYA"):
279
+ raise OriginProjectError(
280
+ f"'{path.name}' does not look like a CPYA .otp template (bad header)."
281
+ )
282
+ elif not b.startswith(b"CPYUA"):
283
+ raise OriginProjectError(
284
+ f"'{path.name}' does not look like a CPYUA .otpu template (bad header)."
285
+ )
286
+ try:
287
+ if suffix == ".otp":
288
+ figures = extract_figures(b)
289
+ curve_styles = _template_curve_styles_opj(b)
290
+ else:
291
+ figures = extract_figures_opju(b)
292
+ curve_styles = _template_curve_styles_opju(b)
293
+ except _DECODE_ERRORS as exc:
294
+ raise OriginProjectError(
295
+ f"'{path.name}' could not be decoded as an Origin graph template: {exc}"
296
+ ) from exc
297
+ if not figures and not curve_styles:
298
+ raise OriginProjectError(
299
+ f"no graph layer or curve style could be decoded from '{path.name}' -- it "
300
+ "may be a workbook/analysis template (out of scope for graph-style import) "
301
+ "or an unrecognized template form."
302
+ )
303
+ # A layer can fail to decode (an axis-record shape this codebase doesn't
304
+ # recognize yet -- see module docstring's PNR.otpu note) while curve style
305
+ # STILL decodes (it lives in an independent record); overrides then stays
306
+ # None rather than the whole template failing -- an honest partial, not a
307
+ # guess (per-curve style is real either way; only axis/legend is missing).
308
+ series_styles = [_series_style(f) for f in curve_styles] or None
309
+ return {
310
+ "name": path.stem,
311
+ "style": "default",
312
+ "overrides": _template_overrides(figures[0]) if figures else None,
313
+ "seriesStyles": series_styles,
314
+ }