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/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 ENCODINGS:
167
+ for enc in candidate_encodings(data):
107
168
  try:
108
- with open(path, "r", encoding=enc, newline="") as fh:
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: how many columns are numeric in >80% of rows, tie-broken by width
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 grid if len(g) > col]
133
- if vals and sum(1 for v in vals if _is_number(v)) >= 0.8 * len(vals):
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
- if not cell or cell.upper() in ("NA", "NAN", "-", "--"):
211
- vals.append(None)
212
- continue
213
- try:
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
- warnings.append("第 %d 列 %r 解析后全为空,已跳过" % (c + 1, raw_names[c] or "col%d" % (c + 1)))
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": raw_names[c] or "col%d" % (c + 1),
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 Origin is missing or a modal
21
- # dialog stalls the COM server; the tools just do not register that session.
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
@@ -0,0 +1,3 @@
1
+ T/V s/�s
2
+ 1 2
3
+ 3 4
@@ -0,0 +1,3 @@
1
+ T/�C y
2
+ 25 1.2
3
+ 30 1.4
@@ -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
 
@@ -0,0 +1,4 @@
1
+ x y
2
+ 1 <0.001
3
+ 2 0.5
4
+ 3 >1e6
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 2.5±0.1
3
+ 2 3.5 +/- 0.2
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 25 mV
3
+ 2 30 mV
@@ -0,0 +1,3 @@
1
+ time,signal
2
+ 0,"1,234.5"
3
+ 1,"2,345.6"
@@ -0,0 +1,3 @@
1
+ Zeit;Signal
2
+ 0,0;1,5
3
+ 1,0;2,5
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 2.5*
3
+ 2 3.5
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 (500)
3
+ 2 250
@@ -0,0 +1,3 @@
1
+ x y
2
+ 1 1e400
3
+ 2 1.2e308
@@ -0,0 +1,3 @@
1
+ ch V I
2
+ Ch1 1.0 2.0
3
+ Ch2 3.0 4.0
@@ -0,0 +1,3 @@
1
+ x,y
2
+ 1,"2.5 3.5"
3
+ 2,"4.0 5.0"