dsh-origin-bridge 0.4.5 → 0.4.7
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.
- package/README.md +337 -303
- package/asciiio.py +184 -17
- package/cordis.patch.yml +6 -2
- package/fixtures/j_utf16_bom.dat +0 -0
- package/fixtures/k_utf16_nobom.dat +0 -0
- package/fixtures/l_cp1252_micro.dat +3 -0
- package/fixtures/m_cp1252_degree.dat +3 -0
- package/fixtures/make_fixtures.py +15 -0
- package/fixtures/n_detection_limit.dat +4 -0
- package/fixtures/o_plusminus.dat +3 -0
- package/fixtures/p_unit_in_cell.dat +3 -0
- package/fixtures/q_thousands.csv +3 -0
- package/fixtures/r_decimal_comma.txt +3 -0
- package/fixtures/s_star_flag.dat +3 -0
- package/fixtures/t_paren_negative.dat +3 -0
- package/fixtures/u_overflow.dat +3 -0
- package/fixtures/v_row_labels.dat +3 -0
- package/fixtures/w_two_numbers_cell.csv +3 -0
- package/originlab.py +199 -28
- package/package.json +13 -5
- package/requirements.txt +2 -0
- package/server.py +26 -10
- package/smoke_test.py +24 -2
- package/test_asciiio.py +118 -0
- package/test_json_safety.py +103 -0
- package/test_paths.py +142 -0
- package/test_readfile.py +104 -0
package/asciiio.py
CHANGED
|
@@ -10,6 +10,7 @@ This reader sniffs the delimiter, handles BOM and GBK, skips comment lines, and
|
|
|
10
10
|
understands the common "long name / units" two-row header.
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
+
import codecs
|
|
13
14
|
import csv
|
|
14
15
|
import os
|
|
15
16
|
import re
|
|
@@ -100,13 +101,72 @@ def read_metadata(head, k):
|
|
|
100
101
|
return meta
|
|
101
102
|
|
|
102
103
|
|
|
104
|
+
_BOMS = ((codecs.BOM_UTF32_LE, "utf-32-le"), (codecs.BOM_UTF32_BE, "utf-32-be"),
|
|
105
|
+
(codecs.BOM_UTF16_LE, "utf-16-le"), (codecs.BOM_UTF16_BE, "utf-16-be"),
|
|
106
|
+
(codecs.BOM_UTF8, "utf-8-sig"))
|
|
107
|
+
|
|
108
|
+
# Some instrument software and Windows' "Unicode Text" save write UTF-16 with no
|
|
109
|
+
# BOM at all. Half of every byte is then 0x00, which no other codec survives.
|
|
110
|
+
_NUL_RATIO = 0.2
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def high_byte_runs(data):
|
|
114
|
+
"""Lengths of maximal runs of bytes >= 0x80.
|
|
115
|
+
|
|
116
|
+
GBK-family text pairs them: one Chinese character is two high bytes. cp1252 /
|
|
117
|
+
latin-1 punctuation shows up as isolated singles — µ, °, ±, ·. That is the only
|
|
118
|
+
honest way to tell "温度/C" from "T/°C", because gb18030 will decode *both*
|
|
119
|
+
without raising and silently mojibake the second one into 祍/癈.
|
|
120
|
+
"""
|
|
121
|
+
runs, cur = [], 0
|
|
122
|
+
for byte in data:
|
|
123
|
+
if byte >= 0x80:
|
|
124
|
+
cur += 1
|
|
125
|
+
elif cur:
|
|
126
|
+
runs.append(cur)
|
|
127
|
+
cur = 0
|
|
128
|
+
if cur:
|
|
129
|
+
runs.append(cur)
|
|
130
|
+
return runs
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def candidate_encodings(data):
|
|
134
|
+
order = list(ENCODINGS)
|
|
135
|
+
runs = high_byte_runs(data)
|
|
136
|
+
if runs and any(run % 2 for run in runs):
|
|
137
|
+
gb = order.index("gb18030")
|
|
138
|
+
singles = [enc for enc in ("cp1252", "latin-1") if enc in order]
|
|
139
|
+
for enc in singles:
|
|
140
|
+
order.remove(enc)
|
|
141
|
+
order[gb:gb] = singles
|
|
142
|
+
return order
|
|
143
|
+
|
|
144
|
+
|
|
103
145
|
def read_text(path):
|
|
104
146
|
"""Decode a data file, trying the encodings that actually occur in labs."""
|
|
147
|
+
with open(path, "rb") as fh:
|
|
148
|
+
data = fh.read()
|
|
149
|
+
for bom, enc in _BOMS:
|
|
150
|
+
if data.startswith(bom):
|
|
151
|
+
body = data[len(bom):]
|
|
152
|
+
if enc == "utf-8-sig":
|
|
153
|
+
return body.decode("utf-8"), enc
|
|
154
|
+
try:
|
|
155
|
+
return body.decode(enc), enc
|
|
156
|
+
except UnicodeDecodeError as exc:
|
|
157
|
+
raise ValueError("文件带 %s 字节头但解不开:%s(%s)" % (enc, path, exc))
|
|
158
|
+
if len(data) >= 8 and data.count(b"\x00") / float(len(data)) > _NUL_RATIO:
|
|
159
|
+
for enc in ("utf-16-le", "utf-16-be"):
|
|
160
|
+
try:
|
|
161
|
+
text = data.decode(enc)
|
|
162
|
+
except UnicodeDecodeError:
|
|
163
|
+
continue
|
|
164
|
+
if "\x00" not in text:
|
|
165
|
+
return text, "%s(无 BOM,按 NUL 分布判断)" % enc
|
|
105
166
|
last = None
|
|
106
|
-
for enc in
|
|
167
|
+
for enc in candidate_encodings(data):
|
|
107
168
|
try:
|
|
108
|
-
|
|
109
|
-
return fh.read(), enc
|
|
169
|
+
return data.decode(enc), enc
|
|
110
170
|
except (UnicodeDecodeError, LookupError) as exc:
|
|
111
171
|
last = exc
|
|
112
172
|
raise ValueError("无法解码 %s(试过 %s),最后错误:%s" % (path, "/".join(ENCODINGS), last))
|
|
@@ -126,13 +186,21 @@ def sniff_delimiter(rows):
|
|
|
126
186
|
width = max(widths)
|
|
127
187
|
if width < 2:
|
|
128
188
|
continue
|
|
129
|
-
# score:
|
|
189
|
+
# score: numeric columns first, then how many rows actually have that width,
|
|
190
|
+
# then FEWER columns. Ranking by width alone let a wrong comma split of a
|
|
191
|
+
# European "0,0;1,5" file beat the correct ';' because it made three columns.
|
|
192
|
+
consistent = sum(1 for g in grid if len(g) == width) / float(len(grid))
|
|
193
|
+
# The first row is a header candidate, so keep it out of the numeric ratio —
|
|
194
|
+
# otherwise a European "Zeit;Signal / 0,0;1,5" file scores every delimiter as
|
|
195
|
+
# non-numeric and the widest wrong split wins by default.
|
|
196
|
+
data = grid[1:] if len(grid) >= 2 else grid
|
|
130
197
|
hits = 0
|
|
131
198
|
for col in range(width):
|
|
132
|
-
vals = [g[col] for g in
|
|
133
|
-
if vals and sum(1 for v in vals if _is_number(v)
|
|
199
|
+
vals = [g[col] for g in data if len(g) > col]
|
|
200
|
+
if vals and sum(1 for v in vals if _is_number(v) or _CELL_DECCOMA.match(v.strip())) \
|
|
201
|
+
>= 0.8 * len(vals):
|
|
134
202
|
hits += 1
|
|
135
|
-
score = (hits, width)
|
|
203
|
+
score = (hits, round(consistent, 3), -width)
|
|
136
204
|
if score > best_score:
|
|
137
205
|
best, best_score = delim, score
|
|
138
206
|
if best is not None:
|
|
@@ -149,6 +217,92 @@ def sniff_delimiter(rows):
|
|
|
149
217
|
raise ValueError("识别不出分隔符,也没有多列空白对齐的数据")
|
|
150
218
|
|
|
151
219
|
|
|
220
|
+
_NUM_SRC = r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eEdD][+-]?\d+)?"
|
|
221
|
+
# Lab exports decorate their numbers. Dropping a whole column because one cell is
|
|
222
|
+
# decorated silently loses real data, so each shape below is recognised, unwrapped,
|
|
223
|
+
# and reported as a warning instead of quietly skipped.
|
|
224
|
+
_CELL_DL = re.compile(r"^(?:[<>]=?|≥|≤)\s*(%s)$" % _NUM_SRC) # <0.001, >1e6
|
|
225
|
+
_CELL_PM = re.compile(r"^(%s)\s*(?:±|\+/-|\+-)\s*\d*\.?\d+$" % _NUM_SRC) # 2.5±0.1
|
|
226
|
+
_CELL_UNIT = re.compile(r"^(%s)\s+\S+$" % _NUM_SRC) # "25 mV"
|
|
227
|
+
# Two numbers sharing one cell (a space-aligned dump read with the wrong delimiter)
|
|
228
|
+
# must not be dressed up as "number + unit", or the second one disappears quietly.
|
|
229
|
+
_CELL_TWO = re.compile(r"^(%s)\s+(%s)\s*$" % (_NUM_SRC, _NUM_SRC))
|
|
230
|
+
_CELL_FLAG = re.compile(r"^(%s)[*†‡°\s]+$" % _NUM_SRC) # "2.5*"
|
|
231
|
+
_CELL_THOUSANDS = re.compile(r"^[+-]?\d{1,3}(?:,\d{3})+(?:\.\d+)?$") # "1,234.5"
|
|
232
|
+
# European exports use the comma as the decimal mark, which only parses when the
|
|
233
|
+
# field separator is something else (usually ';').
|
|
234
|
+
_CELL_DECCOMA = re.compile(r"^[+-]?\d+,\d+$") # "0,5"
|
|
235
|
+
_CELL_PAREN = re.compile(r"^\((%s)\)$" % _NUM_SRC) # "(500)"
|
|
236
|
+
_MISSING_TOKENS = ("NA", "NAN", "-", "--", "NIL", "N/A", "NULL", "#N/A", "#DIV/0!")
|
|
237
|
+
|
|
238
|
+
CELL_KIND_NOTES = {
|
|
239
|
+
"检测限": "第 %d 列 %r 有 %d 个检测限标记(例如 <0.001),已按边界值代入",
|
|
240
|
+
"不确定度": "第 %d 列 %r 有 %d 个 ± 写法(例如 2.5±0.1),已取中心值",
|
|
241
|
+
"带单位的数": "第 %d 列 %r 有 %d 个数值把单位写在同一格(例如 25 mV),已取数值",
|
|
242
|
+
"一格两个数": "第 %d 列 %r 有 %d 个格子里挤了两个数(空格分隔),已取第一个;"
|
|
243
|
+
"若那本是两列,请把分隔符设为空格或改用制表符/CSV 再导",
|
|
244
|
+
"千分位": "第 %d 列 %r 有 %d 个千分位写法(例如 1,234.5),已去掉分隔逗号",
|
|
245
|
+
"小数逗号": "第 %d 列 %r 有 %d 个用逗号当小数点的写法(例如 0,5),已按小数解析",
|
|
246
|
+
"标记后缀": "第 %d 列 %r 有 %d 个带 * / † 标记的值,已按数值本身代入",
|
|
247
|
+
"括号负数": "第 %d 列 %r 有 %d 个用括号表示负数的写法(例如 (500)),已取负值",
|
|
248
|
+
"溢出": "第 %d 列 %r 有 %d 个数值超出浮点范围(例如 1e400),已按缺值处理",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _to_float(text):
|
|
253
|
+
return float(text.replace("d", "E").replace("D", "E"))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _finite(value):
|
|
257
|
+
return value == value and value not in (float("inf"), float("-inf"))
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def cell_number(cell, decimal_comma=False):
|
|
261
|
+
"""One cell -> (value or None, kind or None). kind is what we had to forgive.
|
|
262
|
+
|
|
263
|
+
Lab exports decorate their numbers: detection limits, uncertainties, a unit in
|
|
264
|
+
the same cell, thousands separators, outlier flags. Dropping the whole column
|
|
265
|
+
because one cell is decorated silently loses real data, so each shape is
|
|
266
|
+
recognised, unwrapped, and reported as a warning instead of quietly skipped.
|
|
267
|
+
"""
|
|
268
|
+
text = str(cell).strip().strip('"').strip("'")
|
|
269
|
+
if not text or text.upper() in _MISSING_TOKENS:
|
|
270
|
+
return None, None
|
|
271
|
+
if decimal_comma and _CELL_DECCOMA.match(text):
|
|
272
|
+
value = _to_float(text.replace(",", "."))
|
|
273
|
+
return (value, "小数逗号") if _finite(value) else (None, "溢出")
|
|
274
|
+
try:
|
|
275
|
+
value = _to_float(text)
|
|
276
|
+
except ValueError:
|
|
277
|
+
pass
|
|
278
|
+
else:
|
|
279
|
+
return (value, None) if _finite(value) else (None, "溢出")
|
|
280
|
+
if _CELL_THOUSANDS.match(text):
|
|
281
|
+
value = _to_float(text.replace(",", ""))
|
|
282
|
+
return (value, "千分位") if _finite(value) else (None, "溢出")
|
|
283
|
+
m = _CELL_PAREN.match(text)
|
|
284
|
+
if m:
|
|
285
|
+
value = -abs(_to_float(m.group(1)))
|
|
286
|
+
return (value, "括号负数") if _finite(value) else (None, "溢出")
|
|
287
|
+
m = _CELL_TWO.match(text)
|
|
288
|
+
if m:
|
|
289
|
+
# Checked before "number + unit": "2.5 3.5" is a mis-split column, not a
|
|
290
|
+
# measurement with a unit, and calling it a unit loses the second number
|
|
291
|
+
# without ever telling anyone.
|
|
292
|
+
value = _to_float(m.group(1))
|
|
293
|
+
return (value, "一格两个数") if _finite(value) else (None, "溢出")
|
|
294
|
+
for rx, kind in ((_CELL_DL, "检测限"), (_CELL_PM, "不确定度"),
|
|
295
|
+
(_CELL_UNIT, "带单位的数"), (_CELL_FLAG, "标记后缀")):
|
|
296
|
+
m = rx.match(text)
|
|
297
|
+
if m:
|
|
298
|
+
try:
|
|
299
|
+
value = _to_float(m.group(1))
|
|
300
|
+
except ValueError:
|
|
301
|
+
return None, None
|
|
302
|
+
return (value, kind) if _finite(value) else (None, "溢出")
|
|
303
|
+
return None, None
|
|
304
|
+
|
|
305
|
+
|
|
152
306
|
def parse(path, comment=None):
|
|
153
307
|
"""Return {columns:[{name,units,values}], rows, delimiters, encoding, warnings}."""
|
|
154
308
|
text, enc = read_text(path)
|
|
@@ -167,6 +321,10 @@ def parse(path, comment=None):
|
|
|
167
321
|
grid = [next(csv.reader([ln], delimiter=delim)) for ln in kept]
|
|
168
322
|
|
|
169
323
|
width = max(len(r) for r in grid)
|
|
324
|
+
# Deciding this here matters twice: the header/data split below and the cell
|
|
325
|
+
# reader both need to know whether a bare "0,5" is a European decimal number
|
|
326
|
+
# rather than two comma-separated fields.
|
|
327
|
+
decimal_comma = delim not in (",", None)
|
|
170
328
|
if len({len(r) for r in grid}) > 1:
|
|
171
329
|
warnings.append("行长度不一致,已按最宽 %d 列补齐" % width)
|
|
172
330
|
grid = [r + [""] * (width - len(r)) for r in grid]
|
|
@@ -174,7 +332,8 @@ def parse(path, comment=None):
|
|
|
174
332
|
# split leading header rows (rows that are mostly non-numeric) from data
|
|
175
333
|
head, body = [], []
|
|
176
334
|
for i, row in enumerate(grid):
|
|
177
|
-
numeric = sum(1 for c in row if _is_number(c)
|
|
335
|
+
numeric = sum(1 for c in row if _is_number(c)
|
|
336
|
+
or (decimal_comma and _CELL_DECCOMA.match(c.strip())))
|
|
178
337
|
if numeric >= max(1, int(0.6 * width)):
|
|
179
338
|
body = grid[i:]
|
|
180
339
|
break
|
|
@@ -205,22 +364,30 @@ def parse(path, comment=None):
|
|
|
205
364
|
columns = []
|
|
206
365
|
for c in range(width):
|
|
207
366
|
vals = []
|
|
367
|
+
kinds = {}
|
|
208
368
|
for row in body:
|
|
209
369
|
cell = row[c].strip()
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
vals.append(float(cell.replace("d", "E").replace("D", "E")))
|
|
215
|
-
except ValueError:
|
|
216
|
-
vals.append(None)
|
|
370
|
+
value, kind = cell_number(cell, decimal_comma=decimal_comma)
|
|
371
|
+
if kind:
|
|
372
|
+
kinds[kind] = kinds.get(kind, 0) + 1
|
|
373
|
+
vals.append(value)
|
|
217
374
|
live = sum(1 for v in vals if v is not None)
|
|
375
|
+
name = raw_names[c] or "col%d" % (c + 1)
|
|
376
|
+
# Report what we forgave even when the column ends up dropped: "全为空" alone
|
|
377
|
+
# hides that every cell was text (a row-label column) or overflowed.
|
|
378
|
+
for kind, count in sorted(kinds.items()):
|
|
379
|
+
warnings.append(CELL_KIND_NOTES[kind] % (c + 1, name, count))
|
|
218
380
|
if live == 0:
|
|
219
|
-
|
|
381
|
+
blanks = sum(1 for row in body if not row[c].strip())
|
|
382
|
+
if blanks == len(body):
|
|
383
|
+
continue # a genuinely empty trailing column, nothing to explain
|
|
384
|
+
warnings.append("第 %d 列 %r 没有一个能读成数值(%d 行),已跳过;"
|
|
385
|
+
"如果它是行标签/文本列属正常,要画它请选别的列"
|
|
386
|
+
% (c + 1, name, len(body)))
|
|
220
387
|
continue
|
|
221
388
|
columns.append({
|
|
222
389
|
"index": c + 1,
|
|
223
|
-
"name":
|
|
390
|
+
"name": name,
|
|
224
391
|
"units": units[c] or "",
|
|
225
392
|
"values": vals,
|
|
226
393
|
"numeric_rows": live,
|
package/cordis.patch.yml
CHANGED
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
# manual install -> set ORIGIN_BRIDGE_HOME to the folder holding server.py
|
|
18
18
|
# interpreter -> set ORIGIN_BRIDGE_PYTHON to your venv python.exe
|
|
19
19
|
#
|
|
20
|
-
# failOnStartupError: false keeps dsh bootable when
|
|
21
|
-
#
|
|
20
|
+
# failOnStartupError: false keeps dsh bootable when this entry cannot start at all —
|
|
21
|
+
# measured (2026-10-19) that means only a missing interpreter: the default command is
|
|
22
|
+
# `python` from PATH. A missing originpro does NOT hide the tools: all 27 still
|
|
23
|
+
# register and every call answers `missing_originpro` with the fix to run. Origin
|
|
24
|
+
# itself being closed is irrelevant to startup — the COM connection is made lazily
|
|
25
|
+
# inside the first tool call, not here.
|
|
22
26
|
|
|
23
27
|
- insert:
|
|
24
28
|
- id: mcp-origin-bridge
|
|
Binary file
|
|
Binary file
|
|
@@ -20,6 +20,21 @@ FILES = {
|
|
|
20
20
|
"g_na_and_D.dat": ("utf-8", "X\tY\tZ\n0\t1.5D+00\tNA\n1\t2.5d+00\t-\n2\tNA\t3.1\n"),
|
|
21
21
|
"h_gbk_units.dat": ("gbk", "温度\t电导\n25.0\t1.23\n30.0\t1.45\n"),
|
|
22
22
|
"i_bom_utf8.csv": ("utf-8-sig", "波长,吸光度\n200,0.12\n210,0.34\n"),
|
|
23
|
+
# Encodings and decorated numbers that come out of real instruments.
|
|
24
|
+
"j_utf16_bom.dat": ("utf-16", "X\tY\n0\t1.5\n1\t2.5\n"),
|
|
25
|
+
"k_utf16_nobom.dat": ("utf-16-le", "X\tY\n0\t1.5\n1\t2.5\n"),
|
|
26
|
+
"l_cp1252_micro.dat": ("cp1252", "T/V\ts/µs\n1\t2\n3\t4\n"),
|
|
27
|
+
"m_cp1252_degree.dat": ("cp1252", "T/°C\ty\n25\t1.2\n30\t1.4\n"),
|
|
28
|
+
"n_detection_limit.dat": ("utf-8", "x\ty\n1\t<0.001\n2\t0.5\n3\t>1e6\n"),
|
|
29
|
+
"o_plusminus.dat": ("utf-8", "x\ty\n1\t2.5±0.1\n2\t3.5 +/- 0.2\n"),
|
|
30
|
+
"p_unit_in_cell.dat": ("utf-8", "x\ty\n1\t25 mV\n2\t30 mV\n"),
|
|
31
|
+
"q_thousands.csv": ("utf-8", "time,signal\n0,\"1,234.5\"\n1,\"2,345.6\"\n"),
|
|
32
|
+
"r_decimal_comma.txt": ("utf-8", "Zeit;Signal\n0,0;1,5\n1,0;2,5\n"),
|
|
33
|
+
"s_star_flag.dat": ("utf-8", "x\ty\n1\t2.5*\n2\t3.5\n"),
|
|
34
|
+
"t_paren_negative.dat": ("utf-8", "x\ty\n1\t(500)\n2\t250\n"),
|
|
35
|
+
"u_overflow.dat": ("utf-8", "x\ty\n1\t1e400\n2\t1.2e308\n"),
|
|
36
|
+
"v_row_labels.dat": ("utf-8", "ch\tV\tI\nCh1\t1.0\t2.0\nCh2\t3.0\t4.0\n"),
|
|
37
|
+
"w_two_numbers_cell.csv": ("utf-8", "x,y\n1,\"2.5 3.5\"\n2,\"4.0 5.0\"\n"),
|
|
23
38
|
}
|
|
24
39
|
|
|
25
40
|
|