dsh-origin-bridge 0.0.0-stage → 0.4.0
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/LICENSE +21 -0
- package/README.md +233 -2
- package/asciiio.py +169 -0
- package/bin/origin-bridge.mjs +31 -0
- package/binio.py +208 -0
- package/client_check.py +61 -0
- package/cordis.patch.yml +45 -0
- package/docs/preview.png +0 -0
- package/edge_checks.py +183 -0
- package/fixtures/a_tab.dat +4 -0
- package/fixtures/b_csv.csv +4 -0
- package/fixtures/c_semicolon.txt +3 -0
- package/fixtures/d_space.dat +3 -0
- package/fixtures/e_comment_lines.dat +7 -0
- package/fixtures/f_multi_header.dat +5 -0
- package/fixtures/g_na_and_D.dat +4 -0
- package/fixtures/h_gbk_units.dat +3 -0
- package/fixtures/i_bom_utf8.csv +3 -0
- package/fixtures/make_fixtures.py +35 -0
- package/icon.svg +10 -0
- package/index.js +15 -0
- package/install_dsh.ps1 +198 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/originlab.py +1506 -0
- package/package.json +72 -4
- package/requirements.txt +2 -0
- package/sample.dat +25 -0
- package/scripts/make_icon.py +155 -0
- package/server.py +577 -0
- package/smoke_test.py +420 -0
- package/templates_catalog.py +754 -0
- package/test_asciiio.py +89 -0
- package/test_binio.py +151 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Origin Bridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,234 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="icon.svg" width="128" height="128" alt="Origin Bridge">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<h1 align="center">Origin Bridge</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
用自然语言驱动<b>本机</b> Origin / OriginPro:<br>
|
|
9
|
+
丢给它一个 <code>.dat</code> / <code>.csv</code> / Excel / 电化学工作站 <code>.bin</code>,<br>
|
|
10
|
+
拿回一张发表级图片和一个<b>能在 Origin 里继续编辑</b>的 <code>.opju</code> 工程。
|
|
11
|
+
</p>
|
|
12
|
+
|
|
13
|
+
<p align="center">
|
|
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-170%2B%20passing-brightgreen">
|
|
16
|
+
<img alt="license" src="https://img.shields.io/badge/license-MIT-lightgrey">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<img src="docs/preview.png" width="640" alt="两条曲线、图例、轴标题齐全的 Origin 成图">
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
> 上图由测试套件自动生成:一次 `origin_figure` 调用完成 导入 → 画图 → 配色 → 图例 → 导出 PNG + 保存 .opju。
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## 为什么是它
|
|
28
|
+
|
|
29
|
+
市面上把 Origin 接进聊天的尝试,要么要求把数据上传到云端,要么是个 200 行的玩具。Origin Bridge 反着来:
|
|
30
|
+
|
|
31
|
+
- **数据不出本机。** 没有 API key、没有云端组件,dsh 拉起一个本地 Python 进程,通过 OriginLab 官方的 [`originpro`](https://pypi.org/project/originpro/) 驱动你机器上的 Origin。
|
|
32
|
+
- **每一步都有证据。** Origin 的 X-Function 普遍"返回成功但什么都没发生"。这里画图后读回轴范围与数据跨度比对,导出后校验文件存在/体积/文件头魔数,样式逐项读回——读不回的进 `failed` 列表,不会假装成功。
|
|
33
|
+
- **不是 74 个工具,是 25 个。** 每个都有实测用途,工具描述里写着实测限制,模型不会踩坑。
|
|
34
|
+
- **不是玩具。** 170–176 项自动化测试(本机带真实工作站 .bin 样本时 176)覆盖协议滥用、边界条件、坏句柄、真实数据文件;originpro 的十几个文档陷阱全部记录在案并绕开。
|
|
35
|
+
|
|
36
|
+
## 它能做什么
|
|
37
|
+
|
|
38
|
+
| 你说 | 它做 |
|
|
39
|
+
|---|---|
|
|
40
|
+
| "把 `D:\data\spectrum.dat` 画成散点连线,X 轴 Time (s),导出到 `D:\out`" | `origin_figure`:导入 → 画图 → 轴标题 → PNG + .opju |
|
|
41
|
+
| "这两个文件画在一张图上,双 Y 轴" | `origin_layer`(右 Y 轴)+ `origin_plot`(往指定层画) |
|
|
42
|
+
| "给这条谱图加个图例,放右上角" | `origin_legend`(文字/位置/可见性全部读回) |
|
|
43
|
+
| "这组 EIS 数据拟合一下" | `origin_fit`(gauss / lorentz / voigt / expdec1… 预设,参数可固定) |
|
|
44
|
+
| "这是 CHI 工作站导出的 cv.bin" | `origin_import` 直接读二进制,电势轴按头部方法参数重建 |
|
|
45
|
+
| "做一张 XPS 图" | `origin_chart_render`(40 个科研预设:轴标题/图类型/建议拟合) |
|
|
46
|
+
| "热力图显示这个矩阵" | `origin_matrix`(heat_map / cmap / mesh / contline,Origin 自带图模) |
|
|
47
|
+
|
|
48
|
+
数据源支持:`.dat` `.csv` `.txt` `.tsv`(自动嗅探分隔符、GBK、BOM、多行表头、Fortran 指数、NA/-)、`.xls` `.xlsx`、以及头部为 `80 F2 1B 00` 的电化学工作站二进制 `.bin`。
|
|
49
|
+
|
|
50
|
+
## 安装
|
|
51
|
+
|
|
52
|
+
要求:Windows、Origin/OriginPro **2021 或更高**(`originpro` 的硬性下限,实测 2018 不可用)、Python 3.10+。
|
|
53
|
+
|
|
54
|
+
```powershell
|
|
55
|
+
cd C:\path\to\dsh-origin-bridge
|
|
56
|
+
python -m venv .venv
|
|
57
|
+
.\.venv\Scripts\Activate.ps1
|
|
58
|
+
pip install -r requirements.txt # 国内网络慢就加 -i https://pypi.tuna.tsinghua.edu.cn/simple
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
先确认能独立跑通(会自己拉起 Origin):
|
|
62
|
+
|
|
63
|
+
```powershell
|
|
64
|
+
.\.venv\Scripts\python.exe smoke_test.py # 97 项,看到 ALL GOOD 才算过
|
|
65
|
+
.\.venv\Scripts\python.exe edge_checks.py # 25 项边界
|
|
66
|
+
.\.venv\Scripts\python.exe test_asciiio.py # 24 项,不需要 Origin
|
|
67
|
+
.\.venv\Scripts\python.exe test_binio.py # 24 项起,不需要 Origin;有真实 .bin 时会跟厂商 .txt 逐点对账
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 挂进 dsh
|
|
71
|
+
|
|
72
|
+
自动(推荐,会先备份再写入):
|
|
73
|
+
|
|
74
|
+
```powershell
|
|
75
|
+
powershell -ExecutionPolicy Bypass -File .\install_dsh.ps1 -Profile desktop
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
手动:编辑 `C:\Users\<你>\.dsh\profiles\desktop\cordis.patch.yml`(**先备份**),在文件**末尾**追加:
|
|
79
|
+
|
|
80
|
+
```yaml
|
|
81
|
+
- insert:
|
|
82
|
+
- id: mcp-origin-bridge
|
|
83
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
84
|
+
config:
|
|
85
|
+
serverName: origin_bridge
|
|
86
|
+
transport: stdio
|
|
87
|
+
command: 'C:\path\to\dsh-origin-bridge\.venv\Scripts\python.exe'
|
|
88
|
+
args: ['-u', '-X', 'utf8', 'C:\path\to\dsh-origin-bridge\server.py']
|
|
89
|
+
env:
|
|
90
|
+
PYTHONIOENCODING: utf-8
|
|
91
|
+
PYTHONUNBUFFERED: '1'
|
|
92
|
+
failOnStartupError: false
|
|
93
|
+
toolCallTimeoutMs: 180000
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
然后**彻底退出 dsh 再打开**(配置只在启动时读一次)。新会话里说"列出 origin 开头的工具"应能看到 25 个 `mcp__origin_bridge__origin_*`。
|
|
97
|
+
|
|
98
|
+
> 三个必须对的点,都是踩过的坑:
|
|
99
|
+
> 1. **必须以 `- insert:` 开头。** 直接写 `- id: 新名字` 是"覆盖下层已存在条目"的语法,对全新 id 会被 dsh **静默忽略**——症状就是改了配置、重启了、什么都没发生。
|
|
100
|
+
> 2. `name:` 那行必须是 `@deepseek-ai/dsh-mcp-client`(dsh 的 app 包里自带这个 loader),少了它条目不会被任何加载器接管。
|
|
101
|
+
> 3. `serverName` 只能是标识符:工具名是 `mcp__<serverName>__<tool>`,写成 `origin-plot` 这种带连字符的名字会得到没法寻址的工具。
|
|
102
|
+
>
|
|
103
|
+
> `failOnStartupError: false` 别省:Origin 没装或卡在弹窗时,它保证 dsh 照常启动,只是那一次不注册工具。
|
|
104
|
+
|
|
105
|
+
装成市场插件(可选):把整个目录放进 `<DSH_HOME>\profiles\desktop\node_modules\dsh-origin-bridge\`,仓库自带的 `cordis.patch.yml` 就是 bundle 层补丁;也可以用环境变量 `ORIGIN_BRIDGE_HOME` / `ORIGIN_BRIDGE_PYTHON` 覆盖路径。卡片图标与显示名来自 `icon.svg` 和 `locale/*.json`。
|
|
106
|
+
|
|
107
|
+
## 工具(25 个)
|
|
108
|
+
|
|
109
|
+
| 工具 | 用途 |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `origin_status` | 连接检查、Origin 版本、已开页面。**动手前先调它** |
|
|
112
|
+
| `origin_read_file` | 只解析文件不碰 Origin:编码、分隔符、表头行、每列长名/单位/有效行数;`.bin` 走另一条路线给出技术/点数/方法参数/轴来源 |
|
|
113
|
+
| `origin_import` | 导入 `.dat/.csv/.txt/.tsv/.xls/.xlsx/.wks` 和工作站二进制 `.bin`,返回 `ws-N` 句柄和列清单 |
|
|
114
|
+
| `origin_write` | 内联数据建表:窄表 `columns={列名:[值…]}`,宽表 `headers`+`rows`(一次写完) |
|
|
115
|
+
| `origin_formula` | 给列设计算公式(Fx)做派生列,读回算出的行数与首末值 |
|
|
116
|
+
| `origin_plot` | 单条或**多条**曲线;`template` 走 Origin 自带图模;`graph`+`layer` 往指定层画 |
|
|
117
|
+
| `origin_layer` | 加层(右 Y 轴、上下多面板),返回 Origin 给这层起的名字作为证据 |
|
|
118
|
+
| `origin_legend` | 图例:按列长名自动生成或写死,12 个位置,文字/可见性/坐标都读回 |
|
|
119
|
+
| `origin_style` | 轴标题、**对数轴**、范围与步长、逐曲线颜色/线宽/符号形状/大小/填充/透明度,**每项读回验证** |
|
|
120
|
+
| `origin_annotate` | 图上文字标注与参考线(数据坐标) |
|
|
121
|
+
| `origin_matrix` | 网格数据 → 矩阵 → **热力图 / 等高线 / 3D 彩色映射面**(模板驱动) |
|
|
122
|
+
| `origin_fit` | `linear`(标准版可用);预设 `gauss/lorentz/voigt/expdec1/expdec2/sine/power/logistic/boltzmann/doseresp/cubic`;或 `nlfitsing`+`func` |
|
|
123
|
+
| `origin_chart_presets` | 40 个科研图表预设(XPS/XRD/UV-Vis/荧光/循环伏安/Arrhenius…) |
|
|
124
|
+
| `origin_chart_preset` | 单个预设的完整约定:要哪些列、默认轴标题、建议拟合、轴向提示 |
|
|
125
|
+
| `origin_chart_check` | 拿数据列名对预设做 dry-run,不碰 Origin |
|
|
126
|
+
| `origin_chart_render` | 按预设一步出图(轴标题/图类型/图例/图片/opju) |
|
|
127
|
+
| `origin_export` | 出图,带文件存在/体积/文件头校验,PNG 还回读宽高 |
|
|
128
|
+
| `origin_view` | 把图渲染成小图以 **MCP image content 回传给模型自己看** |
|
|
129
|
+
| `origin_save_project` | 存 `.opju`(可编辑工程,不是截图) |
|
|
130
|
+
| `origin_open` | 打开已有工程 |
|
|
131
|
+
| `origin_inspect` | 读回页面/图层/曲线数与已分配句柄——**改图前先看它,别猜索引** |
|
|
132
|
+
| `origin_close_pages` | 清理攒下来的页面(长会话里这是"无效指针"的根因),支持 dry_run |
|
|
133
|
+
| `origin_labtalk` | 逃生舱:原始 LabTalk + 强制读回 |
|
|
134
|
+
| `origin_exit` | 关 Origin,可选先存盘 |
|
|
135
|
+
| `origin_figure` | 主路径:导入→画图→轴标题→图例→图片+opju 一次完成 |
|
|
136
|
+
|
|
137
|
+
## 工作站 `.bin` 是怎么回事
|
|
138
|
+
|
|
139
|
+
那种头部以 `80 F2 1B 00` 开头、写着 `Cyclic Voltammetry` / `A.C. Impedance` / `Amperometric i-t Curve` 的文件(CHI660E 一类工作站导出),尾部是**一个** float32 数组 = 测量通道(电流 A),**电势轴不在文件里**。
|
|
140
|
+
|
|
141
|
+
Origin Bridge 的做法:
|
|
142
|
+
|
|
143
|
+
1. 从头部"数据起点前 600 字节"的参数块读 Init/High/Low E、步长、段数;
|
|
144
|
+
2. 按"正向起步的三角波"重建电势轴,**重建点数必须与文件点数完全相等**才采信,否则退回 `axis=none` 并说明原因,绝不拿错的 X 轴画图;
|
|
145
|
+
3. i-t / CA / CP 的时间轴直接由采样间隔展开;EIS 的频率表是另一套参数,明说拿不到,不猜。
|
|
146
|
+
|
|
147
|
+
这条重建规则对着厂商自己导出的 `.txt` 逐点核过:5 个文件、每个 600 点,电势和电流 **0 处不符**。
|
|
148
|
+
|
|
149
|
+
## 设计取舍
|
|
150
|
+
|
|
151
|
+
**为什么不用 `mcp` SDK 的 stdio 传输。** SDK 建的是 asyncio `ProactorEventLoop`,其 self-pipe 在 Windows 上回落到 `socketpair()`(127.0.0.1 listen+connect+accept)。防火墙拦回环 accept 的机器上这一步永久阻塞 → initialize 永不返回 → 客户端等满 60 秒超时。`server.py` 用同步换行分隔 JSON-RPC,握手毫秒级,零事件循环依赖。
|
|
152
|
+
|
|
153
|
+
**为什么返回句柄而不是 Origin 引用字符串。** `[Book1]Sheet1!` 这类引用对大小写、空格、活动页状态都敏感,写错了不报错只静默失败。引擎内部持有真实对象,把 `ws-1` / `gr-2` 交给模型原样传回。
|
|
154
|
+
|
|
155
|
+
**两条画图路径,各自实测过。** 单 x/单 y 走 LabTalk `plotxy`——那是 Origin 工具栏自己的路径,主题、配色、自动缩放都对。多条曲线 / 指定层 / 指定模板走 `layer.add_plot()` + `layer.rescale()`:`add_plot` 不 rescale 就是 0..10 空轴,但 rescale 之后颜色/符号/线宽全部正确,且**只有它能精确投到某一层**——`plotxy` 给限定范围时返回 True 却画到别的页/层上去了。
|
|
156
|
+
|
|
157
|
+
**为什么每次写入都读回。** `plot()` 读回轴范围和数据跨度比对,不符就报 `plot_axis_degenerate`;`origin_export` 校验文件存在、体积、文件头魔数;`style()` / `legend()` 每项读回,读不进的进 `failed`。
|
|
158
|
+
|
|
159
|
+
**缺失值消毒。** Origin 用 `-1.23456789e-300` 表示 missing,直接吐给模型会被当成真实拟合参数。`sanitize()` 统一转成 `null`。
|
|
160
|
+
|
|
161
|
+
## originpro 1.1.15 / OriginPro 2024 实测陷阱(维护者必读)
|
|
162
|
+
|
|
163
|
+
这几条都是文档和实际行为不一致,靠读 API 文档写会静默出错:
|
|
164
|
+
|
|
165
|
+
| 陷阱 | 实际行为 | 处置 |
|
|
166
|
+
|---|---|---|
|
|
167
|
+
| `lay.add_plot(...)` | 返回 Plot 对象、不报错,但轴停在 0..10 的**空图** | 只在 `rescale()` 之后使用,并用轴范围读回反证 |
|
|
168
|
+
| `plotxy` 的 `plot:=` 代码 | 200 线、201 散点、202 线+符号、203 柱(**不是**常见的 201=line) | `PLOT_CODES` 常量,逐个导图目视确认 |
|
|
169
|
+
| `plotxy` 往指定层画 | 给限定范围返回 True,曲线却不在目标层(甚至多出一层重叠轴) | 指定层一律用 `add_plot` + `rescale` |
|
|
170
|
+
| `add_layer(t)` | t=0 BottomXLeftY、1 TopX、**2 RightY**、3 LeftY、4 TopXRightY、5 BottomXRightY、6 Inset | `LAYER_TYPES` 名字表 + 读回 `layer.name` 当证据 |
|
|
171
|
+
| `plot.set_cmd("w=2.5")` | 设线宽**静默无效**(`get_float('line.width')` 仍 0.5) | 用 `set_float('line.width', v)` 并读回比对 |
|
|
172
|
+
| `legend` X-Function | `legend 1;` 把图例写成 "111";`legend -s Graph1` 写成 "Graph1Graph1Graph1";`legend -p br` 不挪图例反而多出写着 "br" 的文本框 | `legend -r;` 自动生成,文字走 `layer.label('Legend').text`,位置写 `Legend.x/Legend.y`(该层**数据坐标**,锚点是图例中心) |
|
|
173
|
+
| `obj.is_open` | 含义是"没被最小化/隐藏",**不是**"页面还存在" | `close_pages` 用关闭后重新枚举页面名单作为证据 |
|
|
174
|
+
| `op.pages()` | 是**生成器**:迭代第二次静默返回空 | 一律 `list(...)` 落地 |
|
|
175
|
+
| `WSheet.set_formula(col, …)` | 文档写 1-offset,实际 **0-based**;传 `idx+1` 在 `SetStrProp` 上抛 NoneType | 传 0-based `idx`,列不够先扩列 |
|
|
176
|
+
| `WSheet.from_list2(data)` | 内层列表是**列**不是行 | `write_block` 内部转置 |
|
|
177
|
+
| `Axis.scale` / `lay.xscale` | setter 收 `'log10'`,getter 返回**整数** 2 | `SCALE_TYPES` 映射后按整数比对 |
|
|
178
|
+
| `lt_exec('layer.x.title$="…"')` | 返回 **False**;字符串赋值类 LabTalk 走不通 | 轴标题走对象模型 `origin_style` |
|
|
179
|
+
| `op.NLFit(name)` | **区分大小写**且无别名:`Gauss` 可用,`gauss1`/`Linear`/`Quadratic`/`FRADA` 直接抛 Invalid fitting function | `FIT_PRESETS` 只收逐个构造成功的名字 |
|
|
180
|
+
| `op.path('p')` | 返回工程**所在文件夹**,不是工程文件 | 自己用 `_project_file` 记录绑定 |
|
|
181
|
+
| `op.save(file)` | 语义是 Save-As;绑定后再存同名返回 False(不是失败) | 同路径改用无参 `op.save()`,并比对 mtime |
|
|
182
|
+
| COM 线程亲和 | 连接必须在**专用 COM 线程**上建立;在 stdio 读线程上连接会跨 apartment 复用对象 | `_resolve()` 只查句柄表不碰 COM;有"图工具作为首个调用"的回归项守着 |
|
|
183
|
+
| 已销毁对象的 `.name` | 留下 C 层待处理异常,之后一个不相干的 `len()` 才炸成 `SystemError` | 句柄登记时顺手存页面名,清理句柄表不再回摸 COM |
|
|
184
|
+
| 桥坏到一定程度 | `op.path()` 抛 "returned a result with an exception set",此时连 `op.exit()` 都跑不动;自动 `detach()/attach()` 会把可诊断的报错变成**进程直接崩** | 不自愈,抛 `connection_error` 并写明要外部重启 Origin |
|
|
185
|
+
| 工作站 `.bin` 的数组布局 | 看着像两列(各 N/2),其实厂商 `.txt` 里那 600 行电流正好等于整个数组——只有测量通道 | `binio.py` 按单通道 + 方法参数重建轴,点数相等做门禁 |
|
|
186
|
+
|
|
187
|
+
## 已知边界
|
|
188
|
+
|
|
189
|
+
- `originpro` 要求 Origin **2021+**。实测 OriginPro 2018C:能连、能存空工程,但导入返回 0 行、`add_plot` 直接打死连接器。
|
|
190
|
+
- 非线性拟合需要 **OriginPro** 授权;标准版调 `origin_fit(kind='gauss')` 会明确报 `fit_function_unavailable`。
|
|
191
|
+
- `.bin` 只覆盖 `80 F2 1B 00` 头的工作站导出;**EIS 的频率表在另一套参数里,所以 `.bin` 的 EIS 只有测量列**。Gamry / Autolab / Pine 等其它厂商二进制没做,也不猜。
|
|
192
|
+
- 图例位置是"按数据坐标摆放",对数轴上分数位置会偏;没有"避让数据点"的智能摆放。
|
|
193
|
+
- 中文/网盘路径导出历史上不稳,导出建议 ASCII 本地路径。
|
|
194
|
+
- Origin 以管理员运行而 Python 不是(或反之)时 COM 连不上,两侧权限要一致。
|
|
195
|
+
- 长会话攒太多页面会让 COM 桥报"无效指针"。`origin_close_pages` 能清,根治是 `origin_exit` 后重连。
|
|
196
|
+
- 统计批处理(t 检验 / ANOVA / PCA / 生存分析)没有,originpro 本身也不提供。
|
|
197
|
+
- **无并发保护**:两个会话同时驱动 Origin 会互相踩。
|
|
198
|
+
- 40 个图表预设只取元数据;预设自带的 `render_template()` 会先 `op.new()`——那会把用户正在编辑的工程整个丢掉,所以这里换成自己的渲染路径。
|
|
199
|
+
|
|
200
|
+
## 排障
|
|
201
|
+
|
|
202
|
+
| 现象 | 先查 |
|
|
203
|
+
|---|---|
|
|
204
|
+
| dsh 里看不到工具 | 配置是不是漏了 `- insert:`;`name:` 有没有写成 `@deepseek-ai/dsh-mcp-client`;有没有彻底退出重启 dsh;`python server.py --tools` 能否列出 25 个 |
|
|
205
|
+
| `connection_error` | Origin 是否 ≥2021;`pip show originpro`;任务管理器里 `Origin64.exe` 是否只有一个 |
|
|
206
|
+
| `import_empty` | 先 `origin_read_file` 看结构;空格/多行表头已由自研解析器处理,走到这个报错说明文件本身没有数值行 |
|
|
207
|
+
| `com_timeout` | Origin 窗口是否卡在对话框/许可证弹窗 |
|
|
208
|
+
| `无效指针` | 页面攒太多:`origin_close_pages`,或 `origin_exit` 让它重开 |
|
|
209
|
+
| 图片没出来 | `origin_inspect` 看 `plots` 是否为 0;换 ASCII 路径 |
|
|
210
|
+
| 图是空的但没报错 | 看返回里的 `proof_level`;`plot_axis_degenerate` 会主动抛出 |
|
|
211
|
+
|
|
212
|
+
## 给维护者
|
|
213
|
+
|
|
214
|
+
```
|
|
215
|
+
dsh-origin-bridge/
|
|
216
|
+
├── server.py # 同步 stdio JSON-RPC,25 个工具的分发与 schema
|
|
217
|
+
├── originlab.py # 引擎:COM 专用线程、句柄表、每个操作及其读回验证
|
|
218
|
+
├── asciiio.py # 健壮的 ASCII 读取(分隔符嗅探 / GBK / 多行表头)
|
|
219
|
+
├── binio.py # 工作站 .bin:单测量通道 + 方法参数重建电势轴
|
|
220
|
+
├── templates_catalog.py # 40 个科研图表预设(纯元数据)
|
|
221
|
+
├── cordis.patch.yml # dsh bundle 补丁(serverName: origin_bridge)
|
|
222
|
+
├── install_dsh.ps1 # 一键装入 dsh(备份 + 写 profile 层 + 自检)
|
|
223
|
+
├── icon.svg # 卡片图标(scripts/make_icon.py 生成)
|
|
224
|
+
├── locale/*.json # dsh 卡片显示名与描述
|
|
225
|
+
└── *_test.py / smoke_test.py / edge_checks.py # 170–176 项测试
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
重新生成图标:`python scripts/make_icon.py`(只需要 numpy,PNG 由 stdlib 手写)。
|
|
229
|
+
|
|
230
|
+
## 商标声明与许可
|
|
231
|
+
|
|
232
|
+
本项目是独立的第三方工具,与 OriginLab Corporation **无关联、无合作、未获认可**。Origin、OriginPro 为 OriginLab Corporation 的商标;本工具通过 OriginLab 官方公开发布的 `originpro` Python 包以正常方式使用其产品,未分发其任何二进制、图标或文档。
|
|
233
|
+
|
|
234
|
+
MIT © Origin Bridge Contributors
|
package/asciiio.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""Robust ASCII data reader for Origin-bound scientific files.
|
|
2
|
+
|
|
3
|
+
Why not just originpro's from_file: Origin's ASCII import is fussy about
|
|
4
|
+
multi-row headers, '#'/'//' comment lines and space-aligned columns, and it
|
|
5
|
+
fails by producing an empty sheet rather than an error. Why not the original
|
|
6
|
+
optima-plot read_dat: it splits on TAB only, so space- or comma-aligned .dat
|
|
7
|
+
files silently turn into a single NaN column.
|
|
8
|
+
|
|
9
|
+
This reader sniffs the delimiter, handles BOM and GBK, skips comment lines, and
|
|
10
|
+
understands the common "long name / units" two-row header.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import csv
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
ENCODINGS = ("utf-8-sig", "utf-8", "gb18030", "cp1252", "latin-1")
|
|
18
|
+
COMMENT_PREFIXES = ("#", "//", "!", ";")
|
|
19
|
+
_NUM = re.compile(r"^[+-]?(\d+\.?\d*|\.\d+)([eEdD][+-]?\d+)?$")
|
|
20
|
+
_DELIMS = ("\t", ",", ";", "|")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_number(token):
|
|
24
|
+
t = token.strip().replace("d", "E").replace("D", "E")
|
|
25
|
+
return bool(t) and bool(_NUM.match(t))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _pick_name_row(head, width):
|
|
29
|
+
"""The long-name row is the one with the most distinct tokens.
|
|
30
|
+
|
|
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'.
|
|
33
|
+
"""
|
|
34
|
+
best_i, best_score = 0, None
|
|
35
|
+
for i, row in enumerate(head):
|
|
36
|
+
toks = [row[c].strip() for c in range(width)]
|
|
37
|
+
uniq = len({t for t in toks if t})
|
|
38
|
+
score = uniq * 10 + sum(1 for t in toks if t)
|
|
39
|
+
if uniq <= 1 and width > 1:
|
|
40
|
+
score -= 50
|
|
41
|
+
if best_score is None or score > best_score:
|
|
42
|
+
best_i, best_score = i, score
|
|
43
|
+
return best_i
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def read_text(path):
|
|
47
|
+
"""Decode a data file, trying the encodings that actually occur in labs."""
|
|
48
|
+
last = None
|
|
49
|
+
for enc in ENCODINGS:
|
|
50
|
+
try:
|
|
51
|
+
with open(path, "r", encoding=enc, newline="") as fh:
|
|
52
|
+
return fh.read(), enc
|
|
53
|
+
except (UnicodeDecodeError, LookupError) as exc:
|
|
54
|
+
last = exc
|
|
55
|
+
raise ValueError("无法解码 %s(试过 %s),最后错误:%s" % (path, "/".join(ENCODINGS), last))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def sniff_delimiter(rows):
|
|
59
|
+
"""Pick the delimiter that maximises the number of consistent numeric columns."""
|
|
60
|
+
body = [r for r in rows if r.strip() and not r.lstrip().startswith(COMMENT_PREFIXES)]
|
|
61
|
+
if not body:
|
|
62
|
+
return None
|
|
63
|
+
best, best_score = None, (-1, -1)
|
|
64
|
+
for delim in _DELIMS:
|
|
65
|
+
grid = [next(csv.reader([line], delimiter=delim)) for line in body]
|
|
66
|
+
widths = {len(g) for g in grid}
|
|
67
|
+
if not widths:
|
|
68
|
+
continue
|
|
69
|
+
width = max(widths)
|
|
70
|
+
if width < 2:
|
|
71
|
+
continue
|
|
72
|
+
# score: how many columns are numeric in >80% of rows, tie-broken by width
|
|
73
|
+
hits = 0
|
|
74
|
+
for col in range(width):
|
|
75
|
+
vals = [g[col] for g in grid if len(g) > col]
|
|
76
|
+
if vals and sum(1 for v in vals if _is_number(v)) >= 0.8 * len(vals):
|
|
77
|
+
hits += 1
|
|
78
|
+
score = (hits, width)
|
|
79
|
+
if score > best_score:
|
|
80
|
+
best, best_score = delim, score
|
|
81
|
+
if best is not None:
|
|
82
|
+
return best
|
|
83
|
+
# fall back to whitespace alignment
|
|
84
|
+
grid = [line.split() for line in body]
|
|
85
|
+
width = max(len(g) for g in grid)
|
|
86
|
+
if width >= 2:
|
|
87
|
+
return None
|
|
88
|
+
raise ValueError("识别不出分隔符,也没有多列空白对齐的数据")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def parse(path, comment=None):
|
|
92
|
+
"""Return {columns:[{name,units,values}], rows, delimiters, encoding, warnings}."""
|
|
93
|
+
text, enc = read_text(path)
|
|
94
|
+
comments = COMMENT_PREFIXES if comment is None else (comment,)
|
|
95
|
+
raw_lines = text.splitlines()
|
|
96
|
+
kept = [ln for ln in raw_lines if ln.strip() and not ln.lstrip().startswith(comments)]
|
|
97
|
+
if not kept:
|
|
98
|
+
raise ValueError("文件里只有注释或空行:%s" % path)
|
|
99
|
+
|
|
100
|
+
warnings = []
|
|
101
|
+
delim = sniff_delimiter(kept)
|
|
102
|
+
if delim is None:
|
|
103
|
+
grid = [ln.split() for ln in kept]
|
|
104
|
+
warnings.append("按空白对齐解析(无显式分隔符)")
|
|
105
|
+
else:
|
|
106
|
+
grid = [next(csv.reader([ln], delimiter=delim)) for ln in kept]
|
|
107
|
+
|
|
108
|
+
width = max(len(r) for r in grid)
|
|
109
|
+
if len({len(r) for r in grid}) > 1:
|
|
110
|
+
warnings.append("行长度不一致,已按最宽 %d 列补齐" % width)
|
|
111
|
+
grid = [r + [""] * (width - len(r)) for r in grid]
|
|
112
|
+
|
|
113
|
+
# split leading header rows (rows that are mostly non-numeric) from data
|
|
114
|
+
head, body = [], []
|
|
115
|
+
for i, row in enumerate(grid):
|
|
116
|
+
numeric = sum(1 for c in row if _is_number(c))
|
|
117
|
+
if numeric >= max(1, int(0.6 * width)):
|
|
118
|
+
body = grid[i:]
|
|
119
|
+
break
|
|
120
|
+
head.append(row)
|
|
121
|
+
else:
|
|
122
|
+
raise ValueError("找不到数值数据行:%s(只有表头?)" % path)
|
|
123
|
+
|
|
124
|
+
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
|
|
126
|
+
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)]
|
|
130
|
+
else:
|
|
131
|
+
units = [""] * width
|
|
132
|
+
extra = len(head) - 2 if len(head) > 2 else max(0, len(head) - 1)
|
|
133
|
+
if extra:
|
|
134
|
+
warnings.append("忽略了 %d 行额外表头(只取长名/单位两行)" % extra)
|
|
135
|
+
|
|
136
|
+
columns = []
|
|
137
|
+
for c in range(width):
|
|
138
|
+
vals = []
|
|
139
|
+
for row in body:
|
|
140
|
+
cell = row[c].strip()
|
|
141
|
+
if not cell or cell.upper() in ("NA", "NAN", "-", "--"):
|
|
142
|
+
vals.append(None)
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
vals.append(float(cell.replace("d", "E").replace("D", "E")))
|
|
146
|
+
except ValueError:
|
|
147
|
+
vals.append(None)
|
|
148
|
+
live = sum(1 for v in vals if v is not None)
|
|
149
|
+
if live == 0:
|
|
150
|
+
warnings.append("第 %d 列 %r 解析后全为空,已跳过" % (c + 1, longs[c] or "col%d" % (c + 1)))
|
|
151
|
+
continue
|
|
152
|
+
columns.append({
|
|
153
|
+
"index": c + 1,
|
|
154
|
+
"name": longs[c] or "col%d" % (c + 1),
|
|
155
|
+
"units": units[c] or "",
|
|
156
|
+
"values": vals,
|
|
157
|
+
"numeric_rows": live,
|
|
158
|
+
})
|
|
159
|
+
if not columns:
|
|
160
|
+
raise ValueError("解析后没有任何有效数值列:%s" % path)
|
|
161
|
+
return {
|
|
162
|
+
"path": os.path.abspath(path),
|
|
163
|
+
"encoding": enc,
|
|
164
|
+
"delimiter": delim or "whitespace",
|
|
165
|
+
"rows": len(body),
|
|
166
|
+
"header_rows": len(head),
|
|
167
|
+
"columns": columns,
|
|
168
|
+
"warnings": warnings,
|
|
169
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Standalone launcher for MCP clients that are not DeepSeek Harness
|
|
3
|
+
// (Claude Desktop / Cursor / Codex / any stdio MCP client):
|
|
4
|
+
//
|
|
5
|
+
// npx dsh-origin-bridge
|
|
6
|
+
//
|
|
7
|
+
// Picks the interpreter from ORIGIN_BRIDGE_PYTHON, then the venv next to
|
|
8
|
+
// this package, then whatever `python` is on PATH.
|
|
9
|
+
import { spawn } from 'node:child_process';
|
|
10
|
+
import { existsSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const root = join(here, '..');
|
|
16
|
+
const server = join(root, 'server.py');
|
|
17
|
+
|
|
18
|
+
const candidates = [
|
|
19
|
+
process.env.ORIGIN_BRIDGE_PYTHON,
|
|
20
|
+
join(root, '.venv', 'Scripts', 'python.exe'),
|
|
21
|
+
join(root, '.venv', 'bin', 'python'),
|
|
22
|
+
'python',
|
|
23
|
+
].filter(Boolean);
|
|
24
|
+
|
|
25
|
+
const child = spawn(
|
|
26
|
+
candidates.find((c) => c === 'python' || existsSync(c)) || 'python',
|
|
27
|
+
['-u', server],
|
|
28
|
+
{ stdio: 'inherit', cwd: root },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
child.on('exit', (code) => process.exit(code ?? 0));
|
package/binio.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Reader for electrochemical-workstation binary exports (.bin).
|
|
2
|
+
|
|
3
|
+
The files this targets start with 80 F2 1B 00, carry a technique tag (CV / LSV /
|
|
4
|
+
IMP / i-t) plus its long name and a build string ("r0815"), and end with one
|
|
5
|
+
flat array of little-endian float32. The total element count is stored twice as
|
|
6
|
+
adjacent u32 fields inside the header, which is what makes the header length
|
|
7
|
+
derivable instead of guessed.
|
|
8
|
+
|
|
9
|
+
What is NOT claimed here: which of the two arrays is potential and which is
|
|
10
|
+
current. Nothing in the header identifies them, and on the sample set both
|
|
11
|
+
arrays often sit in the same numeric band, so guessing would silently mislabel
|
|
12
|
+
axes. The reader returns the arrays in file order plus the technique name and
|
|
13
|
+
lets the caller (or origin_view) decide.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import struct
|
|
18
|
+
|
|
19
|
+
MAGIC = b"\x80\xf2\x1b\x00"
|
|
20
|
+
MAX_HEADER = 2400
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class BinFormatError(ValueError):
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _u32(buf, off):
|
|
28
|
+
return struct.unpack_from("<I", buf, off)[0]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _technique(buf):
|
|
32
|
+
"""The short tag is not NUL padded: 'CV' is 2 bytes, 'LSV'/'IMP'/'i-t' 3, and
|
|
33
|
+
the u32 name length starts immediately after it, so locate that boundary."""
|
|
34
|
+
for p in range(8, 15):
|
|
35
|
+
if p + 4 > len(buf):
|
|
36
|
+
break
|
|
37
|
+
n = _u32(buf, p)
|
|
38
|
+
if not (4 <= n <= 40) or p + 4 + n > len(buf):
|
|
39
|
+
continue
|
|
40
|
+
raw = buf[p + 4:p + 4 + n]
|
|
41
|
+
if all(32 <= c < 127 for c in raw):
|
|
42
|
+
tag = buf[8:p].decode("latin-1").strip().rstrip("\x00")
|
|
43
|
+
build = ""
|
|
44
|
+
at = buf.find(b"r0", p + 4 + n, p + 4 + n + 200)
|
|
45
|
+
if at >= 0:
|
|
46
|
+
end = buf.find(b"\x00", at, at + 16)
|
|
47
|
+
if end > at:
|
|
48
|
+
build = buf[at:end].decode("latin-1")
|
|
49
|
+
return tag, raw.decode("latin-1"), build
|
|
50
|
+
return buf[8:12].split(b"\x00")[0].decode("latin-1").strip(), "", ""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _find_stream(buf):
|
|
54
|
+
"""Return (total_floats, data_offset) from the duplicated u32 length field."""
|
|
55
|
+
size = len(buf)
|
|
56
|
+
for off in range(16, min(1400, size - 12)):
|
|
57
|
+
n = _u32(buf, off)
|
|
58
|
+
if n != _u32(buf, off + 8) or not (2 <= n <= (size - off) // 4):
|
|
59
|
+
continue
|
|
60
|
+
start = size - 4 * n
|
|
61
|
+
if start <= off + 12 or start > MAX_HEADER:
|
|
62
|
+
continue
|
|
63
|
+
vals = struct.unpack_from("<%df" % n, buf, start)
|
|
64
|
+
if any(v != v or abs(v) > 1e15 for v in vals):
|
|
65
|
+
continue
|
|
66
|
+
return n, start
|
|
67
|
+
raise BinFormatError(
|
|
68
|
+
"找不到成对出现的 float32 计数域(文件可能是别的 .bin 格式,或已被截断)")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
PARAM_BLOCK_BACK = 600 # the method block sits 600 bytes before the data
|
|
72
|
+
P = {"init": 0, "high": 8, "low": 12, "segments": 28, "interval": 32,
|
|
73
|
+
"rate": 40, "quiet": 44}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _params(buf, header):
|
|
77
|
+
base = header - PARAM_BLOCK_BACK
|
|
78
|
+
if base < 16 or base + 48 > len(buf):
|
|
79
|
+
return {}
|
|
80
|
+
f = struct.Struct("<f")
|
|
81
|
+
out = {}
|
|
82
|
+
for key, off in P.items():
|
|
83
|
+
raw = f.unpack_from(buf, base + off)[0]
|
|
84
|
+
out[key] = raw if raw == raw and abs(raw) < 1e12 else 0.0
|
|
85
|
+
out["segments"] = int(round(out["segments"]))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def build_axis(params, n, positive_first=True):
|
|
90
|
+
"""Triangle wave between low and high, stepping by the sample interval.
|
|
91
|
+
|
|
92
|
+
Each leg emits its own step count and the switching potential belongs to the
|
|
93
|
+
NEXT leg, which is what reproduces the vendor's own text export point for
|
|
94
|
+
point (verified against ecsa100.txt: 600 points, reversals every 30).
|
|
95
|
+
"""
|
|
96
|
+
init, high, low = params["init"], params["high"], params["low"]
|
|
97
|
+
step = params["interval"]
|
|
98
|
+
if not (high > low > -1e9) or step <= 0:
|
|
99
|
+
return None, 0
|
|
100
|
+
out = []
|
|
101
|
+
e = init
|
|
102
|
+
d = 1 if positive_first else -1
|
|
103
|
+
guard = 0
|
|
104
|
+
while len(out) < n and guard < 100000:
|
|
105
|
+
guard += 1
|
|
106
|
+
limit = high if d > 0 else low
|
|
107
|
+
steps = int(round(abs(limit - e) / step))
|
|
108
|
+
if steps <= 0:
|
|
109
|
+
d = -d
|
|
110
|
+
continue
|
|
111
|
+
for k in range(steps):
|
|
112
|
+
if len(out) >= n:
|
|
113
|
+
break
|
|
114
|
+
out.append(e)
|
|
115
|
+
e = limit if k == steps - 1 else e + d * step
|
|
116
|
+
d = -d
|
|
117
|
+
return out, guard
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def expected_points(params, positive_first=True):
|
|
121
|
+
"""Points the declared method produces, used to confirm the sweep polarity."""
|
|
122
|
+
init, high, low = params["init"], params["high"], params["low"]
|
|
123
|
+
step = params["interval"]
|
|
124
|
+
if high <= low or step <= 0:
|
|
125
|
+
return 0
|
|
126
|
+
total = 0
|
|
127
|
+
e = init
|
|
128
|
+
d = 1 if positive_first else -1
|
|
129
|
+
for _ in range(max(1, int(params["segments"]))):
|
|
130
|
+
limit = high if d > 0 else low
|
|
131
|
+
steps = int(round(abs(limit - e) / step))
|
|
132
|
+
if steps <= 0:
|
|
133
|
+
d = -d
|
|
134
|
+
limit = high if d > 0 else low
|
|
135
|
+
steps = int(round(abs(limit - e) / step))
|
|
136
|
+
total += steps
|
|
137
|
+
e = limit
|
|
138
|
+
d = -d
|
|
139
|
+
return total
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def read_bin(path):
|
|
143
|
+
"""Decode a workstation .bin: the measured channel plus a reconstructed axis.
|
|
144
|
+
|
|
145
|
+
The file holds ONE float32 array -- the measured channel (current in
|
|
146
|
+
amperes, or impedance for EIS). The potential axis is not stored; it is
|
|
147
|
+
regenerated from the method block in the header. That reconstruction is
|
|
148
|
+
checked against the declared point count, so a wrong guess degrades to "no
|
|
149
|
+
axis" instead of silently plotting against the wrong X.
|
|
150
|
+
"""
|
|
151
|
+
with open(path, "rb") as fh:
|
|
152
|
+
buf = fh.read()
|
|
153
|
+
if not buf.startswith(MAGIC):
|
|
154
|
+
raise BinFormatError("文件头不是 %s,这不是电化学工作站的 .bin" % MAGIC.hex())
|
|
155
|
+
tag, name, build = _technique(buf)
|
|
156
|
+
nseg = _u32(buf, 4)
|
|
157
|
+
total, start = _find_stream(buf)
|
|
158
|
+
y = list(struct.unpack_from("<%df" % total, buf, start))
|
|
159
|
+
params = _params(buf, start)
|
|
160
|
+
warnings = []
|
|
161
|
+
axis, axis_name, axis_kind = None, "", "none"
|
|
162
|
+
|
|
163
|
+
if tag in ("CV", "LSV") and params:
|
|
164
|
+
fwd = expected_points(params, True) == total
|
|
165
|
+
rev = expected_points(params, False) == total
|
|
166
|
+
if fwd or rev:
|
|
167
|
+
axis, _ = build_axis(params, total, positive_first=fwd)
|
|
168
|
+
axis_name, axis_kind = "E / V", "reconstructed"
|
|
169
|
+
else:
|
|
170
|
+
warnings.append("按头部方法参数应有 %d 个点,文件里是 %d 个,电势轴没重建"
|
|
171
|
+
% (expected_points(params, True), total))
|
|
172
|
+
elif tag in ("i-t", "CA", "CP") and params.get("interval"):
|
|
173
|
+
axis = [i * params["interval"] for i in range(total)]
|
|
174
|
+
axis_name, axis_kind = "t / s", "uniform_from_interval"
|
|
175
|
+
else:
|
|
176
|
+
warnings.append("%s 的 X 轴不在这个文件里(EIS 的频率表是另一套参数)" % (tag or "?",))
|
|
177
|
+
|
|
178
|
+
if not name:
|
|
179
|
+
warnings.append("头部没解析出技术全称,只有短标签 %r" % tag)
|
|
180
|
+
|
|
181
|
+
def summary(values, label):
|
|
182
|
+
if not values:
|
|
183
|
+
return None
|
|
184
|
+
return {"name": label, "n": len(values), "first": values[0], "last": values[-1],
|
|
185
|
+
"min": min(values), "max": max(values)}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
"path": os.path.abspath(path),
|
|
189
|
+
"format": "electrochem-bin",
|
|
190
|
+
"magic": MAGIC.hex(),
|
|
191
|
+
"technique": tag,
|
|
192
|
+
"technique_name": name or tag,
|
|
193
|
+
"build": build,
|
|
194
|
+
"header_segments_field": nseg,
|
|
195
|
+
"header_bytes": start,
|
|
196
|
+
"points": total,
|
|
197
|
+
"method": {"init_E": params.get("init"), "high_E": params.get("high"),
|
|
198
|
+
"low_E": params.get("low"), "segments": params.get("segments"),
|
|
199
|
+
"step": params.get("interval"), "scan_rate_V_per_s": params.get("rate"),
|
|
200
|
+
"quiet_time_s": params.get("quiet")},
|
|
201
|
+
"axis": axis_kind,
|
|
202
|
+
"x": summary(axis, axis_name),
|
|
203
|
+
"y": summary(y, "I / A" if tag != "IMP" else "Z / ohm"),
|
|
204
|
+
"warnings": warnings,
|
|
205
|
+
"series": {"x": axis, "y": y},
|
|
206
|
+
"note": ("测量值只有一列(电流 A);X 轴由头部方法参数重建,"
|
|
207
|
+
"axis=reconstructed 表示重建点数与文件点数完全吻合。"),
|
|
208
|
+
}
|