dsh-origin-bridge 0.4.1 → 0.4.3

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
@@ -25,15 +25,59 @@ def _is_number(token):
25
25
  return bool(t) and bool(_NUM.match(t))
26
26
 
27
27
 
28
+ _NAME_KEYWORDS = ("long name", "longname", "name", "display", "l")
29
+ _UNIT_KEYWORDS = ("units", "unit", "u")
30
+ # "Potential/V", "电流 (mA)", "Z'/ohm": instruments pack the unit into the name.
31
+ _NAME_UNIT = re.compile(r"^(?P<name>[^/\(\[]+?)\s*(?:/\s*|\(|\[)\s*(?P<units>[^)\]]+?)\s*[\)\]]?$")
32
+ _PAIR = re.compile(r"^\s*(?P<key>[^=:]{2,40}?)\s*[=:]\s*(?P<value>.+?)\s*$")
33
+
34
+
35
+ def _row_cells(row, width):
36
+ return [str(row[c] if c < len(row) else "").strip() for c in range(width)]
37
+
38
+
39
+ def _all_names(row, width):
40
+ cells = _row_cells(row, width)
41
+ return bool(cells) and all(cells) and not any(_is_number(c) for c in cells)
42
+
43
+
44
+ def _units_like(row, width):
45
+ """A units row is short text ("s", "mV", "-") with no name-like slashes."""
46
+ cells = [c for c in _row_cells(row, width) if c]
47
+ return bool(cells) and not any(_is_number(c) for c in cells) \
48
+ and all(len(c) <= 6 and "/" not in c and "(" not in c for c in cells)
49
+
50
+
51
+ def split_name_units(text):
52
+ m = _NAME_UNIT.match(str(text).strip())
53
+ if not m:
54
+ return str(text).strip(), ""
55
+ return m.group("name").strip(), m.group("units").strip()
56
+
57
+
28
58
  def _pick_name_row(head, width):
29
- """The long-name row is the one with the most distinct tokens.
59
+ """Which header row carries the column long names?
30
60
 
31
- Instrument dumps often start with a repeated filler row ("Sample Sample
32
- Sample") or a caption, so taking head[0] blindly names every column 'Sample'.
61
+ Origin's own exports use a labelled block (Long Name / Units / Comments), so
62
+ that row wins. Instrument dumps (CH Instruments .txt and friends) put a page
63
+ of settings first and the column line immediately above the data, so the last
64
+ all-text row is the name row. Taking head[0] blindly named every column
65
+ "Jan. 31" / 2011 15:12:13 on those files.
33
66
  """
67
+ for i, row in enumerate(head):
68
+ cells = _row_cells(row, width)
69
+ if cells and cells[0].lower() in _NAME_KEYWORDS and _all_names(row, width):
70
+ return i
71
+ if head and _all_names(head[-1], width):
72
+ # "Time Signal Flag" followed by "s mV -": the short row is the units row.
73
+ if len(head) >= 2 and _all_names(head[-2], width) and _units_like(head[-1], width):
74
+ return len(head) - 2
75
+ return len(head) - 1
76
+ if len(head) >= 2 and _all_names(head[-2], width) and _units_like(head[-1], width):
77
+ return len(head) - 2
34
78
  best_i, best_score = 0, None
35
79
  for i, row in enumerate(head):
36
- toks = [row[c].strip() for c in range(width)]
80
+ toks = _row_cells(row, width)
37
81
  uniq = len({t for t in toks if t})
38
82
  score = uniq * 10 + sum(1 for t in toks if t)
39
83
  if uniq <= 1 and width > 1:
@@ -43,6 +87,19 @@ def _pick_name_row(head, width):
43
87
  return best_i
44
88
 
45
89
 
90
+ def read_metadata(head, k):
91
+ """Key = Value / Key: Value lines above the column row, kept for provenance."""
92
+ meta = {}
93
+ for row in head[:k]:
94
+ line = ", ".join(c for c in row if c).strip()
95
+ m = _PAIR.match(line)
96
+ if m and not _is_number(m.group("key")):
97
+ meta.setdefault(m.group("key").strip(), m.group("value"))
98
+ if len(meta) >= 40:
99
+ break
100
+ return meta
101
+
102
+
46
103
  def read_text(path):
47
104
  """Decode a data file, trying the encodings that actually occur in labs."""
48
105
  last = None
@@ -85,6 +142,10 @@ def sniff_delimiter(rows):
85
142
  width = max(len(g) for g in grid)
86
143
  if width >= 2:
87
144
  return None
145
+ if width == 1:
146
+ # "识别不出分隔符" would send the reader chasing encoding or comment
147
+ # prefixes; the real reason is that a single column cannot make a plot.
148
+ raise ValueError("文件只有一列数据:至少需要两列(x 和 y)才能解析成数据表")
88
149
  raise ValueError("识别不出分隔符,也没有多列空白对齐的数据")
89
150
 
90
151
 
@@ -122,16 +183,24 @@ def parse(path, comment=None):
122
183
  raise ValueError("找不到数值数据行:%s(只有表头?)" % path)
123
184
 
124
185
  k = _pick_name_row(head, width) if head else 0
125
- longs = [head[k][c].strip() for c in range(width)] if head else [""] * width
186
+ raw_names = _row_cells(head[k], width) if head else [""] * width
126
187
  if k + 1 < len(head):
127
- units = [head[k + 1][c].strip() for c in range(width)]
128
- elif k > 0:
129
- units = [head[k - 1][c].strip() for c in range(width)]
188
+ units = _row_cells(head[k + 1], width)
189
+ elif k > 0 and head[k - 1] and head[k - 1][0].strip().lower() in _UNIT_KEYWORDS:
190
+ units = _row_cells(head[k - 1], width)
130
191
  else:
131
192
  units = [""] * width
132
- extra = len(head) - 2 if len(head) > 2 else max(0, len(head) - 1)
193
+ names = []
194
+ for c in range(width):
195
+ nm, un = split_name_units(raw_names[c])
196
+ names.append(nm)
197
+ if un and not units[c]:
198
+ units[c] = un
199
+ raw_names = names
200
+ metadata = read_metadata(head, k)
201
+ extra = k if metadata else max(0, len(head) - 2)
133
202
  if extra:
134
- warnings.append("忽略了 %d 行额外表头(只取长名/单位两行)" % extra)
203
+ warnings.append("忽略了 %d 行文件说明(仪器参数已放进 metadata)" % extra)
135
204
 
136
205
  columns = []
137
206
  for c in range(width):
@@ -147,11 +216,11 @@ def parse(path, comment=None):
147
216
  vals.append(None)
148
217
  live = sum(1 for v in vals if v is not None)
149
218
  if live == 0:
150
- warnings.append("第 %d 列 %r 解析后全为空,已跳过" % (c + 1, longs[c] or "col%d" % (c + 1)))
219
+ warnings.append("第 %d 列 %r 解析后全为空,已跳过" % (c + 1, raw_names[c] or "col%d" % (c + 1)))
151
220
  continue
152
221
  columns.append({
153
222
  "index": c + 1,
154
- "name": longs[c] or "col%d" % (c + 1),
223
+ "name": raw_names[c] or "col%d" % (c + 1),
155
224
  "units": units[c] or "",
156
225
  "values": vals,
157
226
  "numeric_rows": live,
@@ -165,5 +234,6 @@ def parse(path, comment=None):
165
234
  "rows": len(body),
166
235
  "header_rows": len(head),
167
236
  "columns": columns,
237
+ "metadata": metadata,
168
238
  "warnings": warnings,
169
239
  }
package/docs/preview.png CHANGED
Binary file
package/edge_checks.py CHANGED
@@ -173,6 +173,16 @@ def main():
173
173
  print("SKIP node launcher (node not on PATH)")
174
174
 
175
175
  bad = [n for n, ok in results if not ok]
176
+ # Same teardown as smoke_test: reclaim only the windowless instances this run
177
+ # started; an older one may belong to a live dsh session.
178
+ late = [r for r in S.originlab._process_rows()
179
+ if not r["window"] and str(r.get("started") or "") >= S.STARTED_AT]
180
+ for row in late:
181
+ S.originlab._close_pid(row["pid"])
182
+ left = [r for r in S.originlab._process_rows()
183
+ if not r["window"] and str(r.get("started") or "") >= S.STARTED_AT]
184
+ check("run leaves no Origin instance", not left, left)
185
+
176
186
  print("\n%d/%d passed" % (len(results) - len(bad), len(results)))
177
187
  if bad:
178
188
  print("FAILED:", ", ".join(bad))