dsh-origin-bridge 0.4.1 → 0.4.2
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 +75 -14
- package/asciiio.py +82 -12
- package/cases/catalogue.py +636 -0
- package/cases/data.py +120 -0
- package/docs/preview.png +0 -0
- package/edge_checks.py +10 -0
- package/originlab.py +413 -46
- package/package.json +80 -74
- package/render_realdata.py +99 -0
- package/run_cases.py +304 -0
- package/scripts/make_preview.py +77 -0
- package/server.py +604 -577
- package/smoke_test.py +26 -4
- package/test_lifecycle.py +232 -0
package/README.md
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
14
|
<a href="#安装"><img alt="platform" src="https://img.shields.io/badge/platform-Windows%20%2B%20Origin%202021%2B-blue"></a>
|
|
15
|
-
<img alt="tests" src="https://img.shields.io/badge/tests-
|
|
15
|
+
<img alt="tests" src="https://img.shields.io/badge/tests-231%2B%20passing-brightgreen">
|
|
16
16
|
<img alt="license" src="https://img.shields.io/badge/license-MIT-lightgrey">
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
19
|
<p align="center">
|
|
20
|
-
<img src="docs/preview.png" width="640" alt="
|
|
20
|
+
<img src="docs/preview.png" width="640" alt="两条曲线、轴标题齐全的 Origin 成图">
|
|
21
21
|
</p>
|
|
22
22
|
|
|
23
|
-
>
|
|
23
|
+
> 上图由 `scripts/make_preview.py` 生成:导入 → 画两条曲线 → 配色与符号 → 关掉图例 → 导出 PNG,全部走本插件自己的引擎调用,轴标题与图例可见性都读回校验后才导出。数据是 `sample.dat` 的合成序列,不含任何真实实验数据。
|
|
24
24
|
|
|
25
25
|
---
|
|
26
26
|
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
|
|
31
31
|
- **数据不出本机。** 没有 API key、没有云端组件,dsh 拉起一个本地 Python 进程,通过 OriginLab 官方的 [`originpro`](https://pypi.org/project/originpro/) 驱动你机器上的 Origin。
|
|
32
32
|
- **每一步都有证据。** Origin 的 X-Function 普遍"返回成功但什么都没发生"。这里画图后读回轴范围与数据跨度比对,导出后校验文件存在/体积/文件头魔数,样式逐项读回——读不回的进 `failed` 列表,不会假装成功。
|
|
33
|
-
- **不是 74 个工具,是
|
|
34
|
-
- **不是玩具。**
|
|
33
|
+
- **不是 74 个工具,是 27 个。** 每个都有实测用途,工具描述里写着实测限制,模型不会踩坑。
|
|
34
|
+
- **不是玩具。** 231–237 项自动化测试(本机带真实工作站 .bin 样本时 237)覆盖协议滥用、边界条件、坏句柄、真实数据文件;originpro 的十几个文档陷阱全部记录在案并绕开。
|
|
35
35
|
|
|
36
36
|
## 它能做什么
|
|
37
37
|
|
|
@@ -42,15 +42,60 @@
|
|
|
42
42
|
| "给这条谱图加个图例,放右上角" | `origin_legend`(文字/位置/可见性全部读回) |
|
|
43
43
|
| "这组 EIS 数据拟合一下" | `origin_fit`(gauss / lorentz / voigt / expdec1… 预设,参数可固定) |
|
|
44
44
|
| "这是 CHI 工作站导出的 cv.bin" | `origin_import` 直接读二进制,电势轴按头部方法参数重建 |
|
|
45
|
+
| "这是工作站导出的 txt,前面一大段参数" | `origin_import` 自动跳过参数头,紧贴数据的那行(`Potential/V, Current/A`)当表头,单位拆到列的 Units 里,参数进 `metadata` |
|
|
45
46
|
| "做一张 XPS 图" | `origin_chart_render`(40 个科研预设:轴标题/图类型/建议拟合) |
|
|
46
47
|
| "热力图显示这个矩阵" | `origin_matrix`(heat_map / cmap / mesh / contline,Origin 自带图模) |
|
|
47
48
|
|
|
48
|
-
数据源支持:`.dat` `.csv` `.txt` `.tsv`(自动嗅探分隔符、GBK、BOM、多行表头、Fortran 指数、NA/-)、`.xls` `.xlsx
|
|
49
|
+
数据源支持:`.dat` `.csv` `.txt` `.tsv`(自动嗅探分隔符、GBK、BOM、多行表头、Fortran 指数、NA/-)、`.xls` `.xlsx`、电化学工作站(CH Instruments 等)导出的 `.txt`(几十行仪器说明自动收进 `metadata`,不会把首行日期当成列名),以及头部为 `80 F2 1B 00` 的电化学工作站二进制 `.bin`。
|
|
49
50
|
|
|
50
51
|
## 安装
|
|
51
52
|
|
|
52
53
|
要求:Windows、Origin/OriginPro **2021 或更高**(`originpro` 的硬性下限,实测 2018 不可用)、Python 3.10+。
|
|
53
54
|
|
|
55
|
+
### 方式 A:用 `dsh plugin` 从 npm 装(推荐)
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
dsh plugin --profile desktop add dsh-origin-bridge
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
两个包名指向同一个包,装哪个都行:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
dsh plugin --profile desktop add dsh-origin-bridge # 正式包
|
|
65
|
+
dsh plugin --profile desktop add @caob23/dsh-origin-bridge # 转发壳,依赖上面那个
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
本包是 bundle 包(`package.json` 的 `dsh.bundle.patch` 指向根目录的 `cordis.patch.yml`),`dsh plugin` 装完会自动把它加进 profile 的 `dsh.profile.bundles`,**重启 dsh 即加载**,不用再手写补丁。`dsh plugin` 转发给 pnpm,所以 pnpm 要在 PATH 上。
|
|
69
|
+
|
|
70
|
+
**npm 只管分发,不管你的 Python 环境**——Origin 依赖必须自己装进 dsh 实际使用的那个解释器:
|
|
71
|
+
|
|
72
|
+
```powershell
|
|
73
|
+
pip install originpro numpy # 国内网络慢就加 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
包内补丁的默认取值是 `python`(PATH 上那个)和 `profiles/desktop`。依赖装在 venv 里、或者你用的是别的 profile,就设这两个用户环境变量(设完要重开终端、再彻底重启 dsh):
|
|
77
|
+
|
|
78
|
+
```powershell
|
|
79
|
+
setx ORIGIN_BRIDGE_PYTHON "C:\path\to\.venv\Scripts\python.exe"
|
|
80
|
+
setx ORIGIN_BRIDGE_HOME "$env:USERPROFILE\.dsh\profiles\web\node_modules\dsh-origin-bridge"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
装完自检(路径按你的 profile 名改):
|
|
84
|
+
|
|
85
|
+
```powershell
|
|
86
|
+
Test-Path "$env:USERPROFILE\.dsh\profiles\desktop\node_modules\dsh-origin-bridge\server.py"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
彻底退出 dsh 再打开,新会话里说"列出 origin 开头的工具",应看到 27 个 `mcp__origin_bridge__origin_*`。卸载:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
dsh plugin --profile desktop remove dsh-origin-bridge
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
> `failOnStartupError: false` 是刻意的:Origin 没开或有模态框卡住 COM 时,只让工具这一轮不注册,不把 dsh 一起拖崩。
|
|
96
|
+
|
|
97
|
+
### 方式 B:本地 clone(要跑测试或改代码时)
|
|
98
|
+
|
|
54
99
|
```powershell
|
|
55
100
|
cd C:\path\to\dsh-origin-bridge
|
|
56
101
|
python -m venv .venv
|
|
@@ -62,12 +107,19 @@ pip install -r requirements.txt # 国内网络慢就加 -i https://pypi.tun
|
|
|
62
107
|
|
|
63
108
|
```powershell
|
|
64
109
|
.\.venv\Scripts\python.exe smoke_test.py # 97 项,看到 ALL GOOD 才算过
|
|
65
|
-
.\.venv\Scripts\python.exe edge_checks.py #
|
|
110
|
+
.\.venv\Scripts\python.exe edge_checks.py # 26 项边界
|
|
66
111
|
.\.venv\Scripts\python.exe test_asciiio.py # 24 项,不需要 Origin
|
|
67
112
|
.\.venv\Scripts\python.exe test_binio.py # 24 项起,不需要 Origin;有真实 .bin 时会跟厂商 .txt 逐点对账
|
|
113
|
+
.\.venv\Scripts\python.exe test_lifecycle.py # 20 项,不需要 Origin:实例分类/回收的安全边界
|
|
68
114
|
```
|
|
69
115
|
|
|
70
|
-
|
|
116
|
+
### 不是 dsh,是别的 MCP 客户端
|
|
117
|
+
|
|
118
|
+
npm 装完会生成 `origin-bridge` 这个可执行入口(`bin/origin-bridge.mjs`),stdio 直连,配置里把 `command` 指向它或指向 `node_modules/dsh-origin-bridge/bin/origin-bridge.mjs`,`serverName` 保持 `origin_bridge` 即可,工具名仍是 `mcp__origin_bridge__origin_*`。
|
|
119
|
+
|
|
120
|
+
## 挂进 dsh(只针对方式 B)
|
|
121
|
+
|
|
122
|
+
走方式 A 的话这一段可以整个跳过——`dsh plugin` 已经替你写好了。下面是本地 clone 时的两种接法。
|
|
71
123
|
|
|
72
124
|
自动(推荐,会先备份再写入):
|
|
73
125
|
|
|
@@ -93,7 +145,7 @@ powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile desktop
|
|
|
93
145
|
toolCallTimeoutMs: 180000
|
|
94
146
|
```
|
|
95
147
|
|
|
96
|
-
然后**彻底退出 dsh 再打开**(配置只在启动时读一次)。新会话里说"列出 origin 开头的工具"应能看到
|
|
148
|
+
然后**彻底退出 dsh 再打开**(配置只在启动时读一次)。新会话里说"列出 origin 开头的工具"应能看到 27 个 `mcp__origin_bridge__origin_*`。
|
|
97
149
|
|
|
98
150
|
> 三个必须对的点,都是踩过的坑:
|
|
99
151
|
> 1. **必须以 `- insert:` 开头。** 直接写 `- id: 新名字` 是"覆盖下层已存在条目"的语法,对全新 id 会被 dsh **静默忽略**——症状就是改了配置、重启了、什么都没发生。
|
|
@@ -102,9 +154,9 @@ powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile desktop
|
|
|
102
154
|
>
|
|
103
155
|
> `failOnStartupError: false` 别省:Origin 没装或卡在弹窗时,它保证 dsh 照常启动,只是那一次不注册工具。
|
|
104
156
|
|
|
105
|
-
|
|
157
|
+
不想用 `install_dsh.ps1` 也不用 npm 的话,可以把整个目录拷进 `<DSH_HOME>\profiles\<profile>\node_modules\dsh-origin-bridge\`,仓库自带的 `cordis.patch.yml` 就是 bundle 层补丁——但这只是方式 A 的手动等价物,路径与解释器仍靠 `ORIGIN_BRIDGE_HOME` / `ORIGIN_BRIDGE_PYTHON` 指定,正常情况请直接走方式 A。卡片图标与显示名来自 `icon.svg` 和 `locale/en.json`、`locale/zh.json`。
|
|
106
158
|
|
|
107
|
-
## 工具(
|
|
159
|
+
## 工具(27 个)
|
|
108
160
|
|
|
109
161
|
| 工具 | 用途 |
|
|
110
162
|
|---|---|
|
|
@@ -132,6 +184,8 @@ powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile desktop
|
|
|
132
184
|
| `origin_close_pages` | 清理攒下来的页面(长会话里这是"无效指针"的根因),支持 dry_run |
|
|
133
185
|
| `origin_labtalk` | 逃生舱:原始 LabTalk + 强制读回 |
|
|
134
186
|
| `origin_exit` | 关 Origin,可选先存盘 |
|
|
187
|
+
| `origin_instances` | 清点机器上的 Origin 实例:后台无窗口(客户端退出后的残留)vs 前台有窗口(可能是用户自己的项目),并标出哪些是本进程起的 |
|
|
188
|
+
| `origin_reclaim` | 回收残留实例:**只关没有窗口的**;有窗口的一律不动,只在返回里提示用户自己关。`close_background:false` 只看不动手 |
|
|
135
189
|
| `origin_figure` | 主路径:导入→画图→轴标题→图例→图片+opju 一次完成 |
|
|
136
190
|
|
|
137
191
|
## 工作站 `.bin` 是怎么回事
|
|
@@ -152,6 +206,10 @@ Origin Bridge 的做法:
|
|
|
152
206
|
|
|
153
207
|
**为什么返回句柄而不是 Origin 引用字符串。** `[Book1]Sheet1!` 这类引用对大小写、空格、活动页状态都敏感,写错了不报错只静默失败。引擎内部持有真实对象,把 `ws-1` / `gr-2` 交给模型原样传回。
|
|
154
208
|
|
|
209
|
+
**为什么进程探测失败要报 `probe: "unavailable"` 而不是空列表。** `origin_instances` / `origin_reclaim` 靠读进程表区分"没人看的后台实例"和"用户正开着的窗口"。如果 powershell 不可用或超时就让它们返回空集合,调用方读到的是"没有残留、没有前台窗口"——一个假的清白结论。宁可说"我没看清、这次什么都没做",也不能把"未知"报成"没事"。
|
|
210
|
+
|
|
211
|
+
**为什么连接时不自动回收既存的无窗口实例。** 被 dsh 驱动的那个 Origin **本身就是无窗口的**:可见性只说明"没有人在屏幕前看它",不说明"没有客户端在用它"。开机时顺手杀掉所有无窗口实例,会直接砍掉用户当前会话正在跑的任务。所以只有显式 `origin_reclaim` 才动手,且只动无窗口的;有窗口的一律留给用户自己关。
|
|
212
|
+
|
|
155
213
|
**两条画图路径,各自实测过。** 单 x/单 y 走 LabTalk `plotxy`——那是 Origin 工具栏自己的路径,主题、配色、自动缩放都对。多条曲线 / 指定层 / 指定模板走 `layer.add_plot()` + `layer.rescale()`:`add_plot` 不 rescale 就是 0..10 空轴,但 rescale 之后颜色/符号/线宽全部正确,且**只有它能精确投到某一层**——`plotxy` 给限定范围时返回 True 却画到别的页/层上去了。
|
|
156
214
|
|
|
157
215
|
**为什么每次写入都读回。** `plot()` 读回轴范围和数据跨度比对,不符就报 `plot_axis_degenerate`;`origin_export` 校验文件存在、体积、文件头魔数;`style()` / `legend()` 每项读回,读不进的进 `failed`。
|
|
@@ -193,6 +251,7 @@ Origin Bridge 的做法:
|
|
|
193
251
|
- 中文/网盘路径导出历史上不稳,导出建议 ASCII 本地路径。
|
|
194
252
|
- Origin 以管理员运行而 Python 不是(或反之)时 COM 连不上,两侧权限要一致。
|
|
195
253
|
- 长会话攒太多页面会让 COM 桥报"无效指针"。`origin_close_pages` 能清,根治是 `origin_exit` 后重连。
|
|
254
|
+
- **Origin 实例不会跟着客户端退出**:originpro 需要时自己拉一个 Origin,Python 进程结束那个实例还在。攒够就会报"实例超过上限"。现在插件在进程退出时回收**自己起的、且没有窗口的**实例(`atexit`),历史残留用 `origin_reclaim` 清;有窗口的实例永远不动,因为那可能是你正开着的项目。
|
|
196
255
|
- 统计批处理(t 检验 / ANOVA / PCA / 生存分析)没有,originpro 本身也不提供。
|
|
197
256
|
- **无并发保护**:两个会话同时驱动 Origin 会互相踩。
|
|
198
257
|
- 40 个图表预设只取元数据;预设自带的 `render_template()` 会先 `op.new()`——那会把用户正在编辑的工程整个丢掉,所以这里换成自己的渲染路径。
|
|
@@ -201,8 +260,9 @@ Origin Bridge 的做法:
|
|
|
201
260
|
|
|
202
261
|
| 现象 | 先查 |
|
|
203
262
|
|---|---|
|
|
204
|
-
| dsh 里看不到工具 | 配置是不是漏了 `- insert:`;`name:` 有没有写成 `@deepseek-ai/dsh-mcp-client`;有没有彻底退出重启 dsh;`python server.py --tools` 能否列出
|
|
263
|
+
| dsh 里看不到工具 | 配置是不是漏了 `- insert:`;`name:` 有没有写成 `@deepseek-ai/dsh-mcp-client`;有没有彻底退出重启 dsh;`python server.py --tools` 能否列出 27 个 |
|
|
205
264
|
| `connection_error` | Origin 是否 ≥2021;`pip show originpro`;任务管理器里 `Origin64.exe` 是否只有一个 |
|
|
265
|
+
| 报"实例超过上限" | `origin_instances` 看有几个、哪些没窗口;`origin_reclaim` 关残留的后台实例;有窗口的那些要用户自己关(可能有未保存的东西) |
|
|
206
266
|
| `import_empty` | 先 `origin_read_file` 看结构;空格/多行表头已由自研解析器处理,走到这个报错说明文件本身没有数值行 |
|
|
207
267
|
| `com_timeout` | Origin 窗口是否卡在对话框/许可证弹窗 |
|
|
208
268
|
| `无效指针` | 页面攒太多:`origin_close_pages`,或 `origin_exit` 让它重开 |
|
|
@@ -213,7 +273,7 @@ Origin Bridge 的做法:
|
|
|
213
273
|
|
|
214
274
|
```
|
|
215
275
|
dsh-origin-bridge/
|
|
216
|
-
├── server.py # 同步 stdio JSON-RPC,
|
|
276
|
+
├── server.py # 同步 stdio JSON-RPC,27 个工具的分发与 schema
|
|
217
277
|
├── originlab.py # 引擎:COM 专用线程、句柄表、每个操作及其读回验证
|
|
218
278
|
├── asciiio.py # 健壮的 ASCII 读取(分隔符嗅探 / GBK / 多行表头)
|
|
219
279
|
├── binio.py # 工作站 .bin:单测量通道 + 方法参数重建电势轴
|
|
@@ -222,7 +282,8 @@ dsh-origin-bridge/
|
|
|
222
282
|
├── install_dsh.ps1 # 一键装入 dsh(备份 + 写 profile 层 + 自检)
|
|
223
283
|
├── icon.svg # 卡片图标(scripts/make_icon.py 生成)
|
|
224
284
|
├── locale/*.json # dsh 卡片显示名与描述
|
|
225
|
-
|
|
285
|
+
├── scripts/make_preview.py # 重新生成 docs/preview.png(关掉图例后才导出)
|
|
286
|
+
└── test_*.py / smoke_test.py / edge_checks.py # 231–237 项测试
|
|
226
287
|
```
|
|
227
288
|
|
|
228
289
|
重新生成图标:`python scripts/make_icon.py`(只需要 numpy,PNG 由 stdlib 手写)。
|
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
|
-
"""
|
|
59
|
+
"""Which header row carries the column long names?
|
|
30
60
|
|
|
31
|
-
|
|
32
|
-
|
|
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 =
|
|
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
|
-
|
|
186
|
+
raw_names = _row_cells(head[k], width) if head else [""] * width
|
|
126
187
|
if k + 1 < len(head):
|
|
127
|
-
units =
|
|
128
|
-
elif k > 0:
|
|
129
|
-
units =
|
|
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
|
-
|
|
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
|
|
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,
|
|
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":
|
|
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
|
}
|