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.
- quantized/__init__.py +11 -0
- quantized/__main__.py +14 -0
- quantized/api.py +220 -0
- quantized/app.py +206 -0
- quantized/calc/__init__.py +8 -0
- quantized/calc/_clipfit.py +76 -0
- quantized/calc/_natural_neighbor.py +219 -0
- quantized/calc/aggregate.py +159 -0
- quantized/calc/backgrounds.py +353 -0
- quantized/calc/baseline.py +349 -0
- quantized/calc/batch_fit.py +148 -0
- quantized/calc/constants.py +27 -0
- quantized/calc/corrections.py +192 -0
- quantized/calc/crystallography.py +400 -0
- quantized/calc/diffusion.py +120 -0
- quantized/calc/electrical.py +248 -0
- quantized/calc/electrochemistry.py +176 -0
- quantized/calc/element_data.json +1 -0
- quantized/calc/element_data.py +59 -0
- quantized/calc/errors.py +246 -0
- quantized/calc/figure.py +417 -0
- quantized/calc/figure_break.py +152 -0
- quantized/calc/figure_categorical.py +156 -0
- quantized/calc/figure_corner.py +229 -0
- quantized/calc/figure_facets.py +127 -0
- quantized/calc/figure_field.py +137 -0
- quantized/calc/figure_hitmap.py +116 -0
- quantized/calc/figure_labels.py +62 -0
- quantized/calc/figure_map.py +287 -0
- quantized/calc/figure_overrides.py +159 -0
- quantized/calc/figure_page.py +266 -0
- quantized/calc/figure_scale.py +125 -0
- quantized/calc/figure_statplots.py +167 -0
- quantized/calc/figure_styles.py +131 -0
- quantized/calc/figure_ternary.py +239 -0
- quantized/calc/figure_ticks.py +217 -0
- quantized/calc/fit_autoguess.py +156 -0
- quantized/calc/fit_bootstrap.py +163 -0
- quantized/calc/fit_bumps.py +258 -0
- quantized/calc/fit_constraints.py +114 -0
- quantized/calc/fit_equation.py +264 -0
- quantized/calc/fit_findxy.py +80 -0
- quantized/calc/fit_models.py +195 -0
- quantized/calc/fit_models_special.py +189 -0
- quantized/calc/fit_odr.py +99 -0
- quantized/calc/fit_scan.py +243 -0
- quantized/calc/fit_stats.py +215 -0
- quantized/calc/fitting.py +199 -0
- quantized/calc/formula.py +90 -0
- quantized/calc/global_curve_fit.py +305 -0
- quantized/calc/global_fit.py +181 -0
- quantized/calc/interp2d.py +260 -0
- quantized/calc/linecut.py +269 -0
- quantized/calc/magnetic.py +414 -0
- quantized/calc/magnetometry.py +464 -0
- quantized/calc/map.py +228 -0
- quantized/calc/mcmc.py +177 -0
- quantized/calc/optics.py +228 -0
- quantized/calc/pawley.py +251 -0
- quantized/calc/peak_batch.py +128 -0
- quantized/calc/peak_fit.py +259 -0
- quantized/calc/peak_integrate.py +104 -0
- quantized/calc/peak_multifit.py +260 -0
- quantized/calc/peak_track.py +134 -0
- quantized/calc/peaks.py +298 -0
- quantized/calc/peakshapes.py +85 -0
- quantized/calc/plotting.py +147 -0
- quantized/calc/processing.py +232 -0
- quantized/calc/qspace.py +48 -0
- quantized/calc/reductions.py +155 -0
- quantized/calc/reductions_fft.py +383 -0
- quantized/calc/refl_sld_presets.json +1 -0
- quantized/calc/reflectivity.py +80 -0
- quantized/calc/registry.py +303 -0
- quantized/calc/relaxation.py +119 -0
- quantized/calc/report.py +253 -0
- quantized/calc/report_emit.py +227 -0
- quantized/calc/resample.py +142 -0
- quantized/calc/rsm.py +91 -0
- quantized/calc/rsm_analyze.py +245 -0
- quantized/calc/semiconductor.py +488 -0
- quantized/calc/sld.py +131 -0
- quantized/calc/sld_formula.py +138 -0
- quantized/calc/spectral.py +357 -0
- quantized/calc/statplots.py +214 -0
- quantized/calc/stats.py +399 -0
- quantized/calc/stats_anova2.py +202 -0
- quantized/calc/stats_anova_ext.py +338 -0
- quantized/calc/stats_dist.py +196 -0
- quantized/calc/stats_glm.py +245 -0
- quantized/calc/stats_multivar.py +289 -0
- quantized/calc/stats_roc.py +157 -0
- quantized/calc/stats_survival.py +261 -0
- quantized/calc/stats_tests.py +380 -0
- quantized/calc/substrates.py +181 -0
- quantized/calc/superconductor.py +359 -0
- quantized/calc/surface_fit.py +290 -0
- quantized/calc/surface_models.py +156 -0
- quantized/calc/thermal.py +119 -0
- quantized/calc/thin_film.py +425 -0
- quantized/calc/unit_convert.py +259 -0
- quantized/calc/units.py +80 -0
- quantized/calc/vacuum.py +290 -0
- quantized/calc/xray.py +169 -0
- quantized/cli.py +214 -0
- quantized/datastruct.py +153 -0
- quantized/io/__init__.py +11 -0
- quantized/io/_hdf5_layout.py +308 -0
- quantized/io/_jcamp_asdf.py +135 -0
- quantized/io/_xrdml_scan.py +291 -0
- quantized/io/base.py +82 -0
- quantized/io/bruker_brml.py +177 -0
- quantized/io/bruker_raw.py +158 -0
- quantized/io/cif.py +266 -0
- quantized/io/consolidated.py +122 -0
- quantized/io/delimited.py +222 -0
- quantized/io/excel.py +135 -0
- quantized/io/hdf5.py +192 -0
- quantized/io/import_filters.py +178 -0
- quantized/io/import_preview.py +262 -0
- quantized/io/jcamp.py +179 -0
- quantized/io/lakeshore.py +163 -0
- quantized/io/ncnr.py +278 -0
- quantized/io/netcdf.py +195 -0
- quantized/io/opus.py +231 -0
- quantized/io/origin.py +346 -0
- quantized/io/origin_com.py +194 -0
- quantized/io/origin_project/__init__.py +221 -0
- quantized/io/origin_project/annotation_marks.py +288 -0
- quantized/io/origin_project/container.py +262 -0
- quantized/io/origin_project/curve_style_color.py +359 -0
- quantized/io/origin_project/figure_geometry.py +108 -0
- quantized/io/origin_project/figure_layers.py +333 -0
- quantized/io/origin_project/figure_text.py +258 -0
- quantized/io/origin_project/figures.py +210 -0
- quantized/io/origin_project/figures_opju.py +440 -0
- quantized/io/origin_project/notes.py +302 -0
- quantized/io/origin_project/opj.py +459 -0
- quantized/io/origin_project/opj_curves.py +297 -0
- quantized/io/origin_project/opj_shapes.py +148 -0
- quantized/io/origin_project/opju.py +146 -0
- quantized/io/origin_project/opju_axis_real_form.py +418 -0
- quantized/io/origin_project/opju_axis_specimen_form.py +167 -0
- quantized/io/origin_project/opju_codec.py +370 -0
- quantized/io/origin_project/opju_curves.py +497 -0
- quantized/io/origin_project/opju_curves_allcols.py +258 -0
- quantized/io/origin_project/opju_figure_curves.py +302 -0
- quantized/io/origin_project/opju_figure_text.py +245 -0
- quantized/io/origin_project/opju_reports.py +129 -0
- quantized/io/origin_project/origin_richtext.py +145 -0
- quantized/io/origin_project/preview.py +132 -0
- quantized/io/origin_project/templates.py +314 -0
- quantized/io/origin_project/tree.py +379 -0
- quantized/io/origin_project/tree_opju.py +228 -0
- quantized/io/origin_project/windows.py +238 -0
- quantized/io/origin_project/windows_opju.py +393 -0
- quantized/io/origin_project/writer.py +156 -0
- quantized/io/origin_project/writer_blocks.py +282 -0
- quantized/io/qd.py +380 -0
- quantized/io/refl1d.py +132 -0
- quantized/io/registry.py +210 -0
- quantized/io/report_export.py +347 -0
- quantized/io/rigaku.py +100 -0
- quantized/io/sims.py +398 -0
- quantized/io/spc.py +311 -0
- quantized/io/xrd_csv.py +308 -0
- quantized/io/xrdml.py +394 -0
- quantized/jobs.py +173 -0
- quantized/plugins/__init__.py +50 -0
- quantized/plugins/contract.py +111 -0
- quantized/plugins/loader.py +394 -0
- quantized/plugins/steps.py +90 -0
- quantized/routes/__init__.py +7 -0
- quantized/routes/_bookcache.py +62 -0
- quantized/routes/_export_common.py +27 -0
- quantized/routes/_payload.py +58 -0
- quantized/routes/_uploadcache.py +59 -0
- quantized/routes/aggregate.py +46 -0
- quantized/routes/baseline.py +210 -0
- quantized/routes/books.py +117 -0
- quantized/routes/calc.py +56 -0
- quantized/routes/corrections.py +78 -0
- quantized/routes/crystallography.py +80 -0
- quantized/routes/diffusion.py +58 -0
- quantized/routes/electrical.py +101 -0
- quantized/routes/electrochemistry.py +83 -0
- quantized/routes/export.py +280 -0
- quantized/routes/export_facets.py +83 -0
- quantized/routes/export_figures.py +471 -0
- quantized/routes/export_page.py +125 -0
- quantized/routes/fitting.py +379 -0
- quantized/routes/fitting_bumps.py +97 -0
- quantized/routes/import_template.py +97 -0
- quantized/routes/import_wizard.py +150 -0
- quantized/routes/jobs_api.py +59 -0
- quantized/routes/magnetic.py +135 -0
- quantized/routes/magnetometry.py +133 -0
- quantized/routes/optics.py +98 -0
- quantized/routes/parsers.py +281 -0
- quantized/routes/peaks.py +184 -0
- quantized/routes/plot.py +103 -0
- quantized/routes/reductions.py +121 -0
- quantized/routes/reference.py +58 -0
- quantized/routes/reflectivity.py +91 -0
- quantized/routes/report_export.py +119 -0
- quantized/routes/rsm.py +136 -0
- quantized/routes/samples.py +32 -0
- quantized/routes/semiconductor.py +207 -0
- quantized/routes/sld.py +42 -0
- quantized/routes/spectral.py +54 -0
- quantized/routes/statplots.py +99 -0
- quantized/routes/stats.py +418 -0
- quantized/routes/stats_design.py +321 -0
- quantized/routes/substrates.py +46 -0
- quantized/routes/superconductor.py +139 -0
- quantized/routes/thermal.py +57 -0
- quantized/routes/thin_film.py +153 -0
- quantized/routes/vacuum.py +113 -0
- quantized/routes/xray.py +32 -0
- quantized/samples/demo_vsm.csv +42 -0
- quantized/server_launch.py +251 -0
- quantized/web/assets/JetBrainsMono-Bold-CUogYd9I.woff2 +0 -0
- quantized/web/assets/JetBrainsMono-Regular-CA-Os4ii.woff2 +0 -0
- quantized/web/assets/index-BHmmCL-x.js +27 -0
- quantized/web/assets/index-BiZzN7J6.css +1 -0
- quantized/web/index.html +13 -0
- quantized/web/loading.html +69 -0
- quantized_lab-0.8.0.dist-info/METADATA +122 -0
- quantized_lab-0.8.0.dist-info/RECORD +233 -0
- quantized_lab-0.8.0.dist-info/WHEEL +4 -0
- quantized_lab-0.8.0.dist-info/entry_points.txt +4 -0
- quantized_lab-0.8.0.dist-info/licenses/LICENSE +201 -0
- quantized_lab-0.8.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""``.opju`` (CPYUA) graph text-object recovery — axis titles / legend / annotations.
|
|
2
|
+
|
|
3
|
+
Solved 2026-07-05 (previously a documented limitation: ``figures_opju`` dumped
|
|
4
|
+
every recovered string into one flat ``annotations`` list). CPYUA graph pages
|
|
5
|
+
carry the SAME named child objects the ``.opj`` container has (``YL``/``XB``/
|
|
6
|
+
``YR``/``XT`` axis titles, ``Legend``, ``Text``/``TextN`` floating
|
|
7
|
+
annotations, ``__FRAMESRCDATAINFOS`` internals …), just in a different
|
|
8
|
+
framing. Two byte-level structures, validated across every graph page of the
|
|
9
|
+
5-file real ``.opju`` corpus (323 name→text pairs, 0 orphan texts, and — the
|
|
10
|
+
strongest check — every shared graph's routed titles equal to the
|
|
11
|
+
``hc2convert.opj`` Save-As conversion's independently decoded titles):
|
|
12
|
+
|
|
13
|
+
**Object-name header** — a tagged field whose payload opens ``0x10``,
|
|
14
|
+
immediately followed by the name field::
|
|
15
|
+
|
|
16
|
+
<tag 80-9f> 04 10 00 00 <xx> <ntag> <nlen> <name> (axis-title shape)
|
|
17
|
+
<tag 80-9f> 01 10 <ntag> <nlen> <name> (Legend/Text shape)
|
|
18
|
+
|
|
19
|
+
``tag`` varies (``8a``/``84``/``92`` observed), ``<xx>`` varies (``02``/
|
|
20
|
+
``04``/``82``/``84`` — uninterpreted), ``ntag`` is ``0x80`` or ``0x83``, and
|
|
21
|
+
``name`` is plain ASCII (min 2 chars — a 1-char floor admitted junk
|
|
22
|
+
single-letter "names" in trailing storage sections).
|
|
23
|
+
|
|
24
|
+
**Text content** — a 1-byte ``0x80`` field then a length-prefixed string::
|
|
25
|
+
|
|
26
|
+
<tag> 01 80 <tlen> <text … 00>
|
|
27
|
+
|
|
28
|
+
``tag`` is ``0x86`` or ``0xa8`` (both observed; a corpus scan accepting ANY
|
|
29
|
+
tag byte found zero additional matches that survive validation, so the
|
|
30
|
+
``0x80-0xbf`` tag range accepted here is not load-bearing — the validation
|
|
31
|
+
is: ``tlen`` exact, NUL terminator present, strict UTF-8, no control bytes
|
|
32
|
+
beyond CR/LF/TAB). Text is UTF-8 (e.g. Hc2's ``H\\-(c2⊥) (T)`` title and
|
|
33
|
+
``∥``/``⊥`` legend labels are raw multi-byte characters here, where the
|
|
34
|
+
ANSI ``.opj`` container stores ``\\(x22A5)`` escapes instead).
|
|
35
|
+
|
|
36
|
+
**Pairing** — objects are sequential: name header, a short style/format
|
|
37
|
+
field run (name→text distance 49-66 bytes across the whole corpus; bounded
|
|
38
|
+
at ``_MAX_NAME_TO_TEXT`` = 512 to allow longer style runs while still
|
|
39
|
+
failing closed), then the object's single text. Each text run therefore
|
|
40
|
+
pairs with the nearest preceding *unconsumed* header; a text with no such
|
|
41
|
+
header defaults to ``annotations`` (the same degrade ``.opj``'s
|
|
42
|
+
``_build_layer`` uses for text before its first resolvable header) — so a
|
|
43
|
+
regex-missed header can only ever yield a missing/annotation-demoted title,
|
|
44
|
+
never a WRONG one. Routing reuses ``figures.py``'s ``_object_bucket`` table
|
|
45
|
+
verbatim, and the title/legend/annotation cleanup runs through the exact
|
|
46
|
+
same pipeline (``_first_title``/``_parse_legend_labels``/
|
|
47
|
+
``_clean_annotations`` + ``clean_richtext``) so both containers' outputs
|
|
48
|
+
match character-for-character.
|
|
49
|
+
|
|
50
|
+
Undecoded/known negatives: an object holding MORE than one framed text run
|
|
51
|
+
would send its second run to ``annotations`` (never observed in the
|
|
52
|
+
corpus); a text longer than 255 bytes presumably uses a wider length
|
|
53
|
+
encoding never observed — such a run fails the NUL check and is dropped,
|
|
54
|
+
not truncated.
|
|
55
|
+
|
|
56
|
+
Pure library: bytes in → strings out. No fastapi/pydantic/routes imports.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import re
|
|
62
|
+
from typing import Any, NamedTuple
|
|
63
|
+
|
|
64
|
+
from quantized.io.origin_project.annotation_marks import (
|
|
65
|
+
_AUTO_TITLE,
|
|
66
|
+
_clean_annotations,
|
|
67
|
+
build_mark,
|
|
68
|
+
frac_to_data,
|
|
69
|
+
opju_text_fractions,
|
|
70
|
+
)
|
|
71
|
+
from quantized.io.origin_project.figure_text import (
|
|
72
|
+
_first_title,
|
|
73
|
+
_object_bucket,
|
|
74
|
+
_parse_legend_labels,
|
|
75
|
+
)
|
|
76
|
+
from quantized.io.origin_project.origin_richtext import clean_richtext
|
|
77
|
+
|
|
78
|
+
__all__ = ["FigureText", "routed_figure_text"]
|
|
79
|
+
|
|
80
|
+
# Object-name header: <tag> <plen 01|04> <payload[0]=0x10 ...> (module docstring).
|
|
81
|
+
_HDR_RE = re.compile(rb"[\x80-\x9f]([\x01\x04])\x10", re.DOTALL)
|
|
82
|
+
_NAME_TAGS = (0x80, 0x83)
|
|
83
|
+
_NAME_RE = re.compile(rb"[A-Za-z_][A-Za-z0-9_]{1,23}\Z") # >= 2 chars (docstring)
|
|
84
|
+
|
|
85
|
+
# Text content field: <tag> 01 80 <tlen> <text ... 00> (module docstring).
|
|
86
|
+
_TEXT_RE = re.compile(rb"[\x80-\xbf]\x01\x80", re.DOTALL)
|
|
87
|
+
|
|
88
|
+
# Upper bound on the style/format run between an object's name header and its
|
|
89
|
+
# own text (measured max 66 bytes corpus-wide; generous margin, still local).
|
|
90
|
+
_MAX_NAME_TO_TEXT = 512
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class FigureText(NamedTuple):
|
|
94
|
+
"""One graph layer's routed text, shaped exactly like the ``.opj`` fields."""
|
|
95
|
+
|
|
96
|
+
x_title: str
|
|
97
|
+
y_title: str
|
|
98
|
+
y2_title: str
|
|
99
|
+
legend_labels: list[str]
|
|
100
|
+
annotations: list[str]
|
|
101
|
+
# Positioned floating text ({"text", "x", "y"} in data coords, one per
|
|
102
|
+
# Text object, multi-line preserved) — see annotation_marks.py. Empty
|
|
103
|
+
# when the caller supplied no axis range or no position field decoded.
|
|
104
|
+
annotation_marks: list[dict[str, Any]]
|
|
105
|
+
# The Legend object's box top-left in data coords ({"x", "y"}) — the
|
|
106
|
+
# SAME position field every text object carries (§13.2 #3, 2026-07-06),
|
|
107
|
+
# read from the Legend name header. None when no legend, no axes, or
|
|
108
|
+
# the position field didn't decode (omitted, never guessed).
|
|
109
|
+
legend_pos: dict[str, float] | None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _object_headers(b: bytes, start: int, end: int) -> list[tuple[int, str]]:
|
|
113
|
+
"""Every object-name header in ``b[start:end)`` as ``(position, name)``."""
|
|
114
|
+
out: list[tuple[int, str]] = []
|
|
115
|
+
for m in _HDR_RE.finditer(b, start, end):
|
|
116
|
+
plen = m.group(1)[0]
|
|
117
|
+
if plen == 4 and b[m.start() + 3 : m.start() + 5] != b"\x00\x00":
|
|
118
|
+
continue # axis-title shape's payload is always 10 00 00 <xx>
|
|
119
|
+
p = m.start() + 2 + plen # the name field, right after the payload
|
|
120
|
+
if p + 2 > end or b[p] not in _NAME_TAGS:
|
|
121
|
+
continue
|
|
122
|
+
nlen = b[p + 1]
|
|
123
|
+
name = b[p + 2 : p + 2 + nlen]
|
|
124
|
+
if len(name) != nlen or not _NAME_RE.match(name):
|
|
125
|
+
continue
|
|
126
|
+
out.append((m.start(), name.decode("ascii")))
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _text_runs(b: bytes, start: int, end: int) -> list[tuple[int, str]]:
|
|
131
|
+
"""Every framed text run in ``b[start:end)`` as ``(position, text)``.
|
|
132
|
+
|
|
133
|
+
Validation is what carries the safety here (see module docstring): the
|
|
134
|
+
length byte must fit, the run must be NUL-terminated, decode as strict
|
|
135
|
+
UTF-8, and contain no control characters beyond CR/LF/TAB — anything
|
|
136
|
+
else is dropped, never repaired.
|
|
137
|
+
"""
|
|
138
|
+
out: list[tuple[int, str]] = []
|
|
139
|
+
for m in _TEXT_RE.finditer(b, start, end):
|
|
140
|
+
p = m.end()
|
|
141
|
+
if p >= end:
|
|
142
|
+
continue
|
|
143
|
+
tlen = b[p]
|
|
144
|
+
raw = b[p + 1 : p + 1 + tlen]
|
|
145
|
+
if len(raw) != tlen or tlen < 2 or raw[-1] != 0:
|
|
146
|
+
continue
|
|
147
|
+
try:
|
|
148
|
+
body = raw[:-1].decode("utf-8")
|
|
149
|
+
except UnicodeDecodeError:
|
|
150
|
+
continue
|
|
151
|
+
if any(ord(c) < 0x20 and c not in "\r\n\t" for c in body):
|
|
152
|
+
continue
|
|
153
|
+
out.append((m.start(), body))
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def routed_figure_text(
|
|
158
|
+
b: bytes,
|
|
159
|
+
start: int,
|
|
160
|
+
end: int,
|
|
161
|
+
axes: tuple[float, float, float, float] | None = None,
|
|
162
|
+
x_log: bool = False,
|
|
163
|
+
y_log: bool = False,
|
|
164
|
+
) -> FigureText | None:
|
|
165
|
+
"""Route one figure window's text objects into the ``.opj``-shaped buckets.
|
|
166
|
+
|
|
167
|
+
Returns ``None`` when the window holds no framed text run at all (legacy/
|
|
168
|
+
synthetic streams without CPYUA text objects) so the caller can degrade
|
|
169
|
+
to its historical flat-scrape ``annotations``. Otherwise: each text run
|
|
170
|
+
pairs with the nearest preceding unconsumed name header within
|
|
171
|
+
``_MAX_NAME_TO_TEXT`` bytes and routes via ``figures._object_bucket``
|
|
172
|
+
(``YL``→``y_title``, ``XB``→``x_title``, ``YR``→``y2_title``,
|
|
173
|
+
``Legend``→legend, ``Text*``/``Line*``→annotations, anything else →
|
|
174
|
+
dropped); an unpaired text defaults to ``annotations``. Multi-line runs
|
|
175
|
+
(CPYUA stores a whole legend/textbox as ONE ``\\r\\n``-joined string,
|
|
176
|
+
where ``.opj``'s byte scan naturally splits at the control bytes) are
|
|
177
|
+
split into lines first, so both containers feed identical strings into
|
|
178
|
+
the shared cleanup pipeline.
|
|
179
|
+
|
|
180
|
+
``axes`` is the layer's ``(x_from, x_to, y_from, y_to)``; when given,
|
|
181
|
+
every annotation-bucket text whose own name header carries the
|
|
182
|
+
fixed-distance position field (``annotation_marks.opju_text_fractions``)
|
|
183
|
+
also emits a positioned ``annotation_marks`` entry — the whole
|
|
184
|
+
multi-line object as ONE mark. Objects without the field (or an
|
|
185
|
+
unpaired text, which has no header to read from) stay text-only.
|
|
186
|
+
"""
|
|
187
|
+
texts = _text_runs(b, start, end)
|
|
188
|
+
if not texts:
|
|
189
|
+
return None
|
|
190
|
+
headers = _object_headers(b, start, end)
|
|
191
|
+
buckets: dict[str, list[str]] = {
|
|
192
|
+
"x_title": [],
|
|
193
|
+
"y_title": [],
|
|
194
|
+
"y2_title": [],
|
|
195
|
+
"legend": [],
|
|
196
|
+
"annotations": [],
|
|
197
|
+
}
|
|
198
|
+
marks: list[dict[str, Any]] = []
|
|
199
|
+
legend_pos: dict[str, float] | None = None
|
|
200
|
+
events = sorted(
|
|
201
|
+
[(pos, 0, name) for pos, name in headers] + [(pos, 1, text) for pos, text in texts]
|
|
202
|
+
)
|
|
203
|
+
pending: tuple[int, str] | None = None
|
|
204
|
+
for pos, kind, value in events:
|
|
205
|
+
if kind == 0: # a name header: becomes the pending object (one text each)
|
|
206
|
+
pending = (pos, value)
|
|
207
|
+
continue
|
|
208
|
+
header_pos: int | None = None
|
|
209
|
+
if pending is not None and pos - pending[0] <= _MAX_NAME_TO_TEXT:
|
|
210
|
+
bucket = _object_bucket(pending[1])
|
|
211
|
+
header_pos = pending[0]
|
|
212
|
+
else:
|
|
213
|
+
bucket = "annotations" # unpaired text: same default bucket .opj uses
|
|
214
|
+
pending = None
|
|
215
|
+
if bucket not in buckets:
|
|
216
|
+
continue
|
|
217
|
+
lines = [line for line in re.split(r"\r\n|[\r\n]", value) if line.strip()]
|
|
218
|
+
buckets[bucket].extend(lines)
|
|
219
|
+
if bucket == "annotations" and header_pos is not None and axes is not None:
|
|
220
|
+
mark = build_mark(
|
|
221
|
+
opju_text_fractions(b, header_pos), lines, *axes, x_log, y_log
|
|
222
|
+
)
|
|
223
|
+
if mark is not None:
|
|
224
|
+
marks.append(mark)
|
|
225
|
+
if bucket == "legend" and header_pos is not None and axes is not None:
|
|
226
|
+
fracs = opju_text_fractions(b, header_pos)
|
|
227
|
+
if fracs is not None and legend_pos is None:
|
|
228
|
+
lx, ly = frac_to_data(fracs[0], fracs[1], *axes, x_log, y_log)
|
|
229
|
+
legend_pos = {"x": lx, "y": ly}
|
|
230
|
+
|
|
231
|
+
def _title(lines: list[str]) -> str:
|
|
232
|
+
# .opj's _texts_in drops the %(?X)/%(?Y) auto-templates via its letter
|
|
233
|
+
# filter; the framed extraction sees them verbatim, so filter here.
|
|
234
|
+
return _first_title([t for t in lines if not _AUTO_TITLE.match(t)])
|
|
235
|
+
|
|
236
|
+
notes = [t for t in buckets["annotations"] if not _AUTO_TITLE.match(t) and "\\l(" not in t]
|
|
237
|
+
return FigureText(
|
|
238
|
+
x_title=_title(buckets["x_title"]),
|
|
239
|
+
y_title=_title(buckets["y_title"]),
|
|
240
|
+
y2_title=_title(buckets["y2_title"]),
|
|
241
|
+
legend_labels=_parse_legend_labels(buckets["legend"]),
|
|
242
|
+
annotations=[clean_richtext(a) for a in _clean_annotations(notes)[:12]],
|
|
243
|
+
annotation_marks=marks,
|
|
244
|
+
legend_pos=legend_pos,
|
|
245
|
+
)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Origin auto-generated report-sheet columns for ``.opju`` (CPYUA) — plan
|
|
2
|
+
item 4's report-sheet residue, the ``.opju`` sibling of ``opj.py``'s
|
|
3
|
+
``decode_report_strings`` (``container.py``).
|
|
4
|
+
|
|
5
|
+
Origin's FitLinear/NLFit X-Functions auto-generate report sheets ("FitNL1",
|
|
6
|
+
"FitLinearCurve1", …) whose cells hold a variable-length
|
|
7
|
+
``cell://<Section>.<Row>.<Field>`` reference string (e.g.
|
|
8
|
+
``"cell://Parameters.Slope.Value"``, naming *which* fit statistic that cell
|
|
9
|
+
represents) rather than a plain float64. ``.opj``'s version of this family
|
|
10
|
+
overflows a fixed 8-byte value area (``container.decode_inline_text`` has to
|
|
11
|
+
drop it); ``.opju`` stores the SAME conceptual content in its own record
|
|
12
|
+
framing, sharing ``opju_codec``'s ``0a 05 <…> ff ff <…>`` marker but taking a
|
|
13
|
+
completely different branch after it.
|
|
14
|
+
|
|
15
|
+
**The grammar** (pinned against ``specimens/fitreport2.opju`` — a known
|
|
16
|
+
linear fit, x=1..8, slope -1.5, intercept 9.5 — whose ``FitNL1`` report sheet
|
|
17
|
+
has 28 columns, ``FitNLCurve1`` has 11): a normal numeric record is ``ff ff
|
|
18
|
+
<varint> 00 <ZigZag-varint segments>`` (``opju_codec``'s grammar: the byte
|
|
19
|
+
right after the row-count varint is ``0x00``, then the segment list decodes
|
|
20
|
+
straight to float64s). A REPORT column instead has ``0x01`` at that exact
|
|
21
|
+
position — the discriminator, checked at the same byte offset the numeric
|
|
22
|
+
codec already tests. What follows is *not* ``opju_codec``'s FPC/repeat
|
|
23
|
+
segment grammar at all: a single ZigZag-varint segment count, then, if
|
|
24
|
+
negative (``-m``), ``m`` consecutive ``<len:u8><ASCII bytes>`` strings
|
|
25
|
+
(``len=0`` is a valid, blank report cell — most report columns hold only a
|
|
26
|
+
handful of populated rows out of the segment's ``m``). A positive segment
|
|
27
|
+
count was observed on 2 of ``FitNL1``'s 28 columns (its first two, with no
|
|
28
|
+
``cell://`` content at all) and its shape is not understood — those columns
|
|
29
|
+
are honestly dropped, never guessed at.
|
|
30
|
+
|
|
31
|
+
**Validation:** every one of ``FitNL1``'s 26 populated columns decodes
|
|
32
|
+
cleanly (the reference strings match exactly what
|
|
33
|
+
``tools/origin_trial/generate_specimens3.py``'s ``fitreport2`` generator
|
|
34
|
+
produced — ``Notes.*`` metadata, ``Input.R1/R2.C1..C4``,
|
|
35
|
+
``Parameters.A/B/xintercept.{Value,Error,tValue,Prob,Dependency}``,
|
|
36
|
+
``RegStats.C1.*``, ``Summary.R1.*``, ``ANOVAs.*``); the 2 non-text columns
|
|
37
|
+
are honestly dropped. **What this does NOT recover:** the fit's actual
|
|
38
|
+
computed numbers (e.g. Slope = -1.5) are not present as a plain float64
|
|
39
|
+
anywhere near these records (checked directly: neither raw nor FPC-compact
|
|
40
|
+
encodings of 9.5/-1.5 appear in the report-sheet byte range) — Origin
|
|
41
|
+
appears to cache them in a separate internal structure this module does not
|
|
42
|
+
decode. See ``docs/origin_project_format.md`` "Non-double column values" for
|
|
43
|
+
the full writeup and the byte-level trail.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
from quantized.io.origin_project.opju_codec import (
|
|
49
|
+
_NAME,
|
|
50
|
+
CodecError,
|
|
51
|
+
_read_varint,
|
|
52
|
+
_records,
|
|
53
|
+
_zigzag,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
__all__ = ["scan_report_columns"]
|
|
57
|
+
|
|
58
|
+
_PRINTABLE = frozenset(range(0x20, 0x7F))
|
|
59
|
+
_MAX_ROWS = 500 # generous ceiling for a report sheet's row count (real corpus: <= 11)
|
|
60
|
+
_MAX_STRLEN = 200 # generous ceiling for one reference string (real corpus: <= 34 chars)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _decode_report_record(b: bytes, marker: int) -> list[str] | None:
|
|
64
|
+
"""Decode one report-column record at a ``ff ff`` marker (``marker`` is
|
|
65
|
+
the byte offset of the first ``0xff``), or ``None`` if it isn't this
|
|
66
|
+
shape or fails validation.
|
|
67
|
+
|
|
68
|
+
Shares the row-count varint read with ``opju_codec._decode_record`` but
|
|
69
|
+
diverges the instant the following byte is ``0x01`` instead of ``0x00``
|
|
70
|
+
— the two codecs are mutually exclusive by construction (gated on the
|
|
71
|
+
same byte), so this never intercepts a record ``opju_codec.scan_columns``
|
|
72
|
+
would otherwise decode.
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
_row_count, p = _read_varint(b, marker + 2)
|
|
76
|
+
except CodecError:
|
|
77
|
+
return None
|
|
78
|
+
if p >= len(b) or b[p] != 0x01: # 0x00 = a plain numeric record, not ours
|
|
79
|
+
return None
|
|
80
|
+
p += 1
|
|
81
|
+
try:
|
|
82
|
+
raw, p = _read_varint(b, p)
|
|
83
|
+
except CodecError:
|
|
84
|
+
return None
|
|
85
|
+
count = _zigzag(raw)
|
|
86
|
+
if count >= 0:
|
|
87
|
+
return None # a positive/blank-fill segment -- shape not understood, honest drop
|
|
88
|
+
m = -count
|
|
89
|
+
if not 1 <= m <= _MAX_ROWS:
|
|
90
|
+
return None
|
|
91
|
+
rows: list[str] = []
|
|
92
|
+
for _ in range(m):
|
|
93
|
+
if p >= len(b):
|
|
94
|
+
return None
|
|
95
|
+
length = b[p]
|
|
96
|
+
p += 1
|
|
97
|
+
if length > _MAX_STRLEN or p + length > len(b):
|
|
98
|
+
return None
|
|
99
|
+
raw_bytes = b[p : p + length]
|
|
100
|
+
p += length
|
|
101
|
+
if not all(c in _PRINTABLE for c in raw_bytes):
|
|
102
|
+
return None
|
|
103
|
+
rows.append(raw_bytes.decode("latin1"))
|
|
104
|
+
return rows
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def scan_report_columns(b: bytes) -> list[tuple[str, list[str]]]:
|
|
108
|
+
"""Every ``<Book>_<Col>[@sheet]`` report-sheet column in a CPYUA file.
|
|
109
|
+
|
|
110
|
+
Reuses ``opju_codec``'s record-marker scan (``_records``) and the same
|
|
111
|
+
"nearest preceding length-prefixed dataset name" anchoring rule
|
|
112
|
+
``scan_columns`` uses; the two functions never compete for the same
|
|
113
|
+
marker (see ``_decode_report_record``'s docstring), so this can run as an
|
|
114
|
+
entirely independent second pass with no cursor coordination needed.
|
|
115
|
+
"""
|
|
116
|
+
names = [
|
|
117
|
+
(m.start(), m.group(0).decode("latin1"))
|
|
118
|
+
for m in _NAME.finditer(b)
|
|
119
|
+
if m.start() > 0 and b[m.start() - 1] == len(m.group(0))
|
|
120
|
+
]
|
|
121
|
+
out: list[tuple[str, list[str]]] = []
|
|
122
|
+
for marker in _records(b):
|
|
123
|
+
rows = _decode_report_record(b, marker)
|
|
124
|
+
if rows is None:
|
|
125
|
+
continue
|
|
126
|
+
prev = [name for pos, name in names if pos < marker]
|
|
127
|
+
if prev:
|
|
128
|
+
out.append((prev[-1], rows))
|
|
129
|
+
return out
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Origin LabTalk rich-text (escape-code) → plain display text.
|
|
2
|
+
|
|
3
|
+
Origin stores axis titles, legend labels, and text annotations with inline
|
|
4
|
+
formatting escapes (LabTalk "text object" syntax). Extracting the raw bytes
|
|
5
|
+
(``figures.py``) recovers the string *with* those escapes, e.g. an XRD x-axis
|
|
6
|
+
title comes back as ``2\\g(q \\(40))degrees)`` — which, shown verbatim, reads
|
|
7
|
+
as literal backslash noise rather than "2θ (degrees)". This module decodes the
|
|
8
|
+
display-affecting escapes so a recreated plot's labels match Origin.
|
|
9
|
+
|
|
10
|
+
Handled escapes:
|
|
11
|
+
``\\g(...)`` Symbol-font run → Greek letters (q→θ, a→α, m→μ, …)
|
|
12
|
+
``\\(NNN)`` insert the character with decimal code NNN
|
|
13
|
+
``\\(xHHHH)`` insert the character with hex code HHHH — Origin's
|
|
14
|
+
Unicode form (how a ``.opj``-container Save-As stores
|
|
15
|
+
non-ANSI characters, e.g. ``\\(x2225)`` → ∥; observed
|
|
16
|
+
live in hc2convert.opj's converted axis titles)
|
|
17
|
+
``\\+(...)`` / ``\\-(...)`` super-/sub-script → Unicode super/subscripts
|
|
18
|
+
``\\b(...)`` ``\\i(...)`` ``\\u(...)`` ``\\f:Font(...)`` ``\\c<n>(...)``
|
|
19
|
+
bold/italic/underline/font/colour → keep inner text
|
|
20
|
+
|
|
21
|
+
Left untouched: ``%(...)`` data-reference substitutions (e.g. a legend's
|
|
22
|
+
auto-label ``%(2)`` means "dataset 2's name" — a reference, not display text)
|
|
23
|
+
and any shape we don't recognise (we degrade to the raw string, never worse).
|
|
24
|
+
|
|
25
|
+
Pure library: str in → str out. No project imports.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
# Adobe/Origin "Symbol" font: Latin code point → the Greek glyph it displays.
|
|
31
|
+
_SYMBOL_GREEK: dict[str, str] = {
|
|
32
|
+
"a": "α", "b": "β", "g": "γ", "d": "δ", "e": "ε", "z": "ζ", "h": "η",
|
|
33
|
+
"q": "θ", "i": "ι", "k": "κ", "l": "λ", "m": "μ", "n": "ν", "x": "ξ",
|
|
34
|
+
"o": "ο", "p": "π", "r": "ρ", "s": "σ", "t": "τ", "u": "υ", "f": "φ",
|
|
35
|
+
"c": "χ", "y": "ψ", "w": "ω", "v": "ς", "j": "ϕ",
|
|
36
|
+
"A": "Α", "B": "Β", "G": "Γ", "D": "Δ", "E": "Ε", "Z": "Ζ", "H": "Η",
|
|
37
|
+
"Q": "Θ", "I": "Ι", "K": "Κ", "L": "Λ", "M": "Μ", "N": "Ν", "X": "Ξ",
|
|
38
|
+
"O": "Ο", "P": "Π", "R": "Ρ", "S": "Σ", "T": "Τ", "U": "Υ", "F": "Φ",
|
|
39
|
+
"C": "Χ", "Y": "Ψ", "W": "Ω",
|
|
40
|
+
} # fmt: skip
|
|
41
|
+
_GREEK_TABLE = {ord(k): v for k, v in _SYMBOL_GREEK.items()}
|
|
42
|
+
_SUP = str.maketrans("0123456789+-=()n", "⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾ⁿ")
|
|
43
|
+
_SUB = str.maketrans("0123456789+-=()", "₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _codepoint(code: str) -> int | None:
|
|
47
|
+
"""The character code of a ``\\(...)`` escape body: decimal (``40``) or
|
|
48
|
+
hex with an ``x`` prefix (``x2225`` — Origin's Unicode form, see the
|
|
49
|
+
module docstring). ``None`` when the body is neither shape, or falls
|
|
50
|
+
outside Unicode's assignable range (malformed → literal-paren fallback).
|
|
51
|
+
"""
|
|
52
|
+
if code.isdigit():
|
|
53
|
+
value = int(code)
|
|
54
|
+
elif code[:1] in ("x", "X") and len(code) > 1:
|
|
55
|
+
try:
|
|
56
|
+
value = int(code[1:], 16)
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
else:
|
|
60
|
+
return None
|
|
61
|
+
return value if 0 <= value <= 0x10FFFF and not 0xD800 <= value <= 0xDFFF else None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _apply_run(control: str, inner: str) -> str:
|
|
65
|
+
"""Render a ``\\<control>(inner)`` run's already-decoded inner text."""
|
|
66
|
+
if control == "g":
|
|
67
|
+
return inner.translate(_GREEK_TABLE)
|
|
68
|
+
if control == "+":
|
|
69
|
+
return inner.translate(_SUP)
|
|
70
|
+
if control == "-":
|
|
71
|
+
return inner.translate(_SUB)
|
|
72
|
+
# b / i / u / f:Font / c<n> / anything else: drop the styling, keep text.
|
|
73
|
+
return inner
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _render(s: str) -> str:
|
|
77
|
+
out: list[str] = []
|
|
78
|
+
i, n = 0, len(s)
|
|
79
|
+
while i < n:
|
|
80
|
+
c = s[i]
|
|
81
|
+
if c != "\\":
|
|
82
|
+
out.append(c)
|
|
83
|
+
i += 1
|
|
84
|
+
continue
|
|
85
|
+
# \(NNN) / \(xHHHH) — a character-code escape (atomic: its own parens).
|
|
86
|
+
if i + 1 < n and s[i + 1] == "(":
|
|
87
|
+
close = s.find(")", i + 2)
|
|
88
|
+
code = s[i + 2 : close] if close != -1 else ""
|
|
89
|
+
cp = _codepoint(code) if close != -1 else None
|
|
90
|
+
if cp is not None:
|
|
91
|
+
out.append(chr(cp))
|
|
92
|
+
i = close + 1
|
|
93
|
+
else:
|
|
94
|
+
out.append("(") # malformed — treat as a literal paren
|
|
95
|
+
i += 2
|
|
96
|
+
continue
|
|
97
|
+
# \<control>(...) — a formatting run; control is the text up to '('.
|
|
98
|
+
k = s.find("(", i + 1)
|
|
99
|
+
if k == -1: # dangling backslash-escape (e.g. a lone "\n"): drop the "\"
|
|
100
|
+
out.append(s[i + 1] if i + 1 < n else "")
|
|
101
|
+
i += 2
|
|
102
|
+
continue
|
|
103
|
+
control = s[i + 1 : k]
|
|
104
|
+
j, depth = k + 1, 1 # find the matching ')' of the run's opening '('
|
|
105
|
+
while j < n and depth:
|
|
106
|
+
if s[j] == "\\" and j + 1 < n:
|
|
107
|
+
if s[j + 1] == "(": # nested \(NNN) — skip the whole atom
|
|
108
|
+
inner_close = s.find(")", j + 2)
|
|
109
|
+
j = inner_close + 1 if inner_close != -1 else j + 2
|
|
110
|
+
continue
|
|
111
|
+
j += 2 # other escaped char
|
|
112
|
+
continue
|
|
113
|
+
if s[j] == "(":
|
|
114
|
+
depth += 1
|
|
115
|
+
elif s[j] == ")":
|
|
116
|
+
depth -= 1
|
|
117
|
+
if depth == 0:
|
|
118
|
+
break
|
|
119
|
+
j += 1
|
|
120
|
+
if depth != 0:
|
|
121
|
+
# Unterminated run — malformed; bail so clean_richtext() returns the
|
|
122
|
+
# raw string unchanged rather than Symbol-mapping the rest of the line.
|
|
123
|
+
raise ValueError("unterminated Origin rich-text run")
|
|
124
|
+
out.append(_apply_run(control, _render(s[k + 1 : j])))
|
|
125
|
+
i = j + 1
|
|
126
|
+
return "".join(out)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def clean_richtext(s: str) -> str:
|
|
130
|
+
"""Decode Origin rich-text escapes in ``s`` to plain display text.
|
|
131
|
+
|
|
132
|
+
Idempotent-ish and total: strings with no ``\\`` escapes return unchanged,
|
|
133
|
+
and any parse error degrades to the raw input (never makes it worse).
|
|
134
|
+
|
|
135
|
+
>>> clean_richtext("2\\\\g(q \\\\(40))degrees)")
|
|
136
|
+
'2θ (degrees)'
|
|
137
|
+
>>> clean_richtext("Intensity (arb. units)")
|
|
138
|
+
'Intensity (arb. units)'
|
|
139
|
+
"""
|
|
140
|
+
if "\\" not in s:
|
|
141
|
+
return s
|
|
142
|
+
try:
|
|
143
|
+
return _render(s)
|
|
144
|
+
except Exception:
|
|
145
|
+
return s
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Row-decimated preview ``DataStruct`` for the lazy per-book import transport
|
|
2
|
+
(``ORIGIN_FILE_DECODE_PLAN`` #38).
|
|
3
|
+
|
|
4
|
+
Mirrors the frontend's min/max-per-bucket envelope-preserving downsample
|
|
5
|
+
(``frontend/src/lib/downsample.ts``, used for Library sparklines) but picks
|
|
6
|
+
whole ROWS -- every channel moves together -- instead of decimating one
|
|
7
|
+
channel's points independently. The result is a genuine (just smaller)
|
|
8
|
+
``DataStruct``: any consumer that only reads ``.time``/``.values``/``.labels``/
|
|
9
|
+
``.units``/``.metadata`` keeps working on it unchanged, which is the whole
|
|
10
|
+
point of the "pending dataset carries a real but preview-sized `data`" design
|
|
11
|
+
(see ``routes/parsers.py``'s ``_book_preview_payload``).
|
|
12
|
+
|
|
13
|
+
Pure layer -- no fastapi/pydantic imports (enforced by test_repo_integrity).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
|
|
20
|
+
from quantized.datastruct import DataStruct
|
|
21
|
+
|
|
22
|
+
__all__ = ["decimate_datastruct"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _trim_trailing_padding(ds: DataStruct) -> DataStruct:
|
|
26
|
+
"""Drop trailing rows that carry no real data, mirroring the frontend's
|
|
27
|
+
``dropTrailingEmptyRows`` (``frontend/src/lib/plotdata.ts``). Origin's
|
|
28
|
+
over-allocated worksheet storage leaves "allocated but unfilled" rows at
|
|
29
|
+
the END of a book (verified byte-for-byte across ~10 PNR-corpus books,
|
|
30
|
+
e.g. Book15: 19 of 180 rows), in two shapes:
|
|
31
|
+
|
|
32
|
+
- not plottable at all -- ``.time`` is non-finite, or (when there are
|
|
33
|
+
value channels) every value in the row is also non-finite, or
|
|
34
|
+
- over-allocated-storage padding -- ``.time`` AND every value in the
|
|
35
|
+
row read exactly finite ``0.0`` simultaneously. This is a "point", not
|
|
36
|
+
a gap, so the rule above doesn't catch it, and left in, it resets the
|
|
37
|
+
independent axis back to 0 at the tail (breaking x-ascending) and
|
|
38
|
+
collapses a sparkline/preview built from the raw row order.
|
|
39
|
+
|
|
40
|
+
Only trims a contiguous run off the END; interior gaps are left in place.
|
|
41
|
+
Returns ``ds`` unchanged (same object) when there is no prunable tail.
|
|
42
|
+
"""
|
|
43
|
+
n = ds.n_points
|
|
44
|
+
if n == 0:
|
|
45
|
+
return ds
|
|
46
|
+
time = ds.time
|
|
47
|
+
values = ds.values
|
|
48
|
+
has_y = ds.n_channels > 0
|
|
49
|
+
|
|
50
|
+
def plottable(i: int) -> bool:
|
|
51
|
+
if not np.isfinite(time[i]):
|
|
52
|
+
return False
|
|
53
|
+
if not has_y:
|
|
54
|
+
return True
|
|
55
|
+
return bool(np.any(np.isfinite(values[i, :])))
|
|
56
|
+
|
|
57
|
+
def all_zero_row(i: int) -> bool:
|
|
58
|
+
if not has_y:
|
|
59
|
+
return False
|
|
60
|
+
if time[i] != 0:
|
|
61
|
+
return False
|
|
62
|
+
return bool(np.all(values[i, :] == 0))
|
|
63
|
+
|
|
64
|
+
end = n
|
|
65
|
+
while end > 0 and (not plottable(end - 1) or all_zero_row(end - 1)):
|
|
66
|
+
end -= 1
|
|
67
|
+
if end == n:
|
|
68
|
+
return ds
|
|
69
|
+
return DataStruct(
|
|
70
|
+
time=ds.time[:end],
|
|
71
|
+
values=ds.values[:end, :],
|
|
72
|
+
labels=ds.labels,
|
|
73
|
+
units=ds.units,
|
|
74
|
+
metadata=ds.metadata,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def decimate_datastruct(ds: DataStruct, target_points: int = 200) -> DataStruct:
|
|
79
|
+
"""Row-decimate ``ds`` to about ``target_points`` rows.
|
|
80
|
+
|
|
81
|
+
First prunes a trailing "allocated but unfilled" padding run (see
|
|
82
|
+
:func:`_trim_trailing_padding`) so a pending dataset's preview never
|
|
83
|
+
carries the padding into the thumbnail -- the fix for a preview whose
|
|
84
|
+
sparkline collapsed toward (0, 0) instead of tracing the real curve.
|
|
85
|
+
|
|
86
|
+
Then buckets the (pruned) row range into ``target_points // 2``
|
|
87
|
+
contiguous spans and keeps, from each span, the row holding the MINIMUM
|
|
88
|
+
and the row holding the MAXIMUM value of the densest channel (most finite
|
|
89
|
+
values) -- so a sparkline built from the result still shows real
|
|
90
|
+
spikes/dips a plain stride sample would step over. Every retained row
|
|
91
|
+
keeps ALL of its channels (they were picked together), so the output is a
|
|
92
|
+
normal, if smaller, ``DataStruct``.
|
|
93
|
+
|
|
94
|
+
Returns the (possibly padding-trimmed) ``ds`` unchanged when it already
|
|
95
|
+
has at most ``target_points`` rows or has no channels at all (an
|
|
96
|
+
empty-data pseudo-book -- nothing to pick extrema from, and nothing to
|
|
97
|
+
save by decimating zero columns).
|
|
98
|
+
"""
|
|
99
|
+
ds = _trim_trailing_padding(ds)
|
|
100
|
+
n = ds.n_points
|
|
101
|
+
if n <= target_points or ds.n_channels == 0:
|
|
102
|
+
return ds
|
|
103
|
+
|
|
104
|
+
finite_counts = np.count_nonzero(np.isfinite(ds.values), axis=0)
|
|
105
|
+
densest = int(np.argmax(finite_counts))
|
|
106
|
+
y = ds.values[:, densest]
|
|
107
|
+
|
|
108
|
+
bucket_count = max(1, target_points // 2)
|
|
109
|
+
edges = np.linspace(0, n, bucket_count + 1)
|
|
110
|
+
keep: set[int] = set()
|
|
111
|
+
for b in range(bucket_count):
|
|
112
|
+
start, end = int(edges[b]), int(edges[b + 1])
|
|
113
|
+
if start >= end:
|
|
114
|
+
continue
|
|
115
|
+
seg = y[start:end]
|
|
116
|
+
finite = np.isfinite(seg)
|
|
117
|
+
if not finite.any():
|
|
118
|
+
keep.add(start) # nothing finite in this span -> keep a placeholder row
|
|
119
|
+
continue
|
|
120
|
+
local = np.flatnonzero(finite)
|
|
121
|
+
seg_finite = seg[local]
|
|
122
|
+
keep.add(start + int(local[int(np.argmin(seg_finite))]))
|
|
123
|
+
keep.add(start + int(local[int(np.argmax(seg_finite))]))
|
|
124
|
+
|
|
125
|
+
idx = np.fromiter(sorted(keep), dtype=np.int64)
|
|
126
|
+
return DataStruct(
|
|
127
|
+
time=ds.time[idx],
|
|
128
|
+
values=ds.values[idx, :],
|
|
129
|
+
labels=ds.labels,
|
|
130
|
+
units=ds.units,
|
|
131
|
+
metadata=ds.metadata,
|
|
132
|
+
)
|