Repository navigation
Expand file tree
/
Copy pathorigin_engine.py
More file actions
5761 lines (5250 loc) · 257 KB
/
Copy pathorigin_engine.py
File metadata and controls
5761 lines (5250 loc) · 257 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# -*- coding: utf-8 -*-
"""
DSH Origin 画图插件 —— 核心引擎
================================
基于 originpro(OriginLab 官方 Python 包,内部经 comtypes/COM 连接 Origin
自动化服务器)。提供连接、写数、画图、导出、并发加锁与错误处理。
并发/加锁策略(多 DSH 会话并发调用时的稳定性):
1. Origin 是"单实例 COM 自动化服务器":本机只有一个 Origin64.exe 进程,
所有 Python 脚本通过 COM 连到同一个实例;Origin 内部状态(活动页/活动
工作表)是全局共享的。
2. 引擎采用"专用 COM 线程"模型:所有 Origin 操作投递到唯一的工作线程
串行执行(任务队列)。这同时解决两个问题:
a) comtypes/OriginExt 的 COM 接口指针有线程亲和性——跨线程调用会报
"对象没有连接到服务器";单线程模型彻底规避;
b) 多会话并发天然串行化:任意时刻只有一个操作触碰 Origin。
3. 每次操作使用唯一命名空间(DSH_<8位hex>),不同调用互不踩踏数据。
4. 可选跨进程锁(Windows 命名互斥体,环境变量 ORIGIN_IPC_LOCK=1 开启):
覆盖"多个 DSH 实例/多个 profile 同时操作同一 Origin"的极端场景。
互斥体在 COM 线程内、执行操作前获取。
5. 出错永不崩溃:所有公开函数返回结构化 dict,异常被转换为
{"ok": false, "error": ...},并保证队列/锁状态一致。
"""
import json
import os
import queue
import re
import sys
import threading
import time
import traceback
import uuid
import origin_errors as oerr
import origin_analysis as oana
import plot_style as pst
# ---------------------------------------------------------------------------
# 常量与全局
# ---------------------------------------------------------------------------
DEFAULT_OUTPUT_DIR = os.path.join(os.path.expanduser("~"), "dsch_origin_plugin", "output")
_com_queue = queue.Queue() # 任务队列:所有 Origin 操作在此串行执行
_com_thread = None # 专用 COM 线程
_ipc_lock = None # 可选跨进程锁(Windows 命名互斥体)
_origin_app = None # 惰性连接的 originpro 句柄(仅 COM 线程访问)
_connected = False
# 画图类型 -> originpro add_plot type 参数(2D 基础)
PLOT_TYPES = {
"line": "l", # 折线
"scatter": "s", # 散点
"line_symbol": "y", # 线+符号
"column": "c", # 柱状
}
# 说明:box/bar 均改走 Origin 官方模板("box"/"bar"),不再依赖 plotxy 代码
# (plotxy 的 204/215 在部分 Origin 2026b 会渲染成面积图或不出图,真机验证)。
PLOT_XY_CODES = {}
# 等高线/3D matrix 图型 -> add_mplot type
MATRIX_PLOT_TYPES = {
"contour": 104, # 等高线
"contour_fill": 105, # 填充等高线
"3d_wire": 106, # 3D 线框
"3d_surface": 103, # 3D 表面(GLparafunc)
}
PLOT_TYPES_CN = {
"line": "折线", "scatter": "散点", "line_symbol": "线+符号", "column": "柱状",
"histogram": "直方图", "box": "箱线图", "bar": "条形图",
"contour": "等高线", "contour_fill": "填充等高线", "3d_wire": "3D线框",
"3d_surface": "3D表面",
}
# ---------------------------------------------------------------------------
# 领域模板注册表(origin_plot_template;每条都只用"真机验证过的原语"搭建)
# ---------------------------------------------------------------------------
# fit 支持的拟合类型提示(2026-09-16 补齐:此前模型名是"暗知识")
FIT_SUPPORTED_KINDS = ["linear", "ExpDec1", "ExpGrow1", "Gauss", "Lorentz",
"Boltzmann", "DoseResp", "MichaelisMenten", "Logistic",
"Poly2"]
PLOT_TEMPLATES = {
"stacked_spectra": {
"desc": "多谱线纵向堆叠偏移(XPS/UV-Vis/PL/FTIR 多样品对比)",
"data": "{'x': [..], 'spectra': {'谱线名': [..], ...}}",
"options": "offset='auto'|数值(相邻谱线间距);reverse_x=True 反转 X(结合能惯例)",
},
"xrd_pattern": {
"desc": "XRD 三件套:Observed 散点 + Calculated 线 + Difference 下移线(可加相刻线)",
"data": "{'two_theta': [..], 'observed': [..], 'calculated': [..], "
"'difference': 可选, 'phases': {'相名': [2θ位置...]} 可选}",
"options": "difference_offset='auto'(Difference 相对主谱下移量,默认 12% 谱高)",
},
"dual_y": {
"desc": "双 Y 轴图(左右轴各一条序列,DUALY 模板,缺失时结构化报错)",
"data": "{'x': [..], 'left': [..], 'right': [..], "
"'left_name': 可选, 'right_name': 可选}",
"options": "left_name/right_name 轴标题",
},
"forest": {
"desc": "森林图:效应量点 + 置信区间横线 + 零参考线(Meta 分析/回归系数)",
"data": "{'labels': [研究名...], 'effect': [..], 'ci_low': [..], 'ci_high': [..]}",
"options": "zero=0(参考线位置)",
},
"multi_panel": {
"desc": "多面板纵向堆叠(每面板一条序列,共享 X)",
"data": "{'x': [..] 可选(缺省用行号), 'panels': {'面板名': [..], ...}}",
"options": "无",
},
"cycle_overlay": {
"desc": "多曲线同图叠放 + 渐变色(CV 多圈 / 充放电多循环 / 多轮次对比)",
"data": "{'x': [..], 'series': {'曲线名': [..], ...}(≥2 条),"
"'legend_mode': 'all'|'first_last'|'none' 可选}",
"options": "x_title/y_title 覆盖轴标题",
},
"eis_nyquist": {
"desc": "电化学阻抗谱 Nyquist 图(-Z'' vs Z',等轴比 + 近正方形图层)",
"data": "{'z_real': [..], 'z_imag': [..]}",
"options": "x_title/y_title 覆盖轴标题",
},
}
# ---------------------------------------------------------------------------
# 已知版本风险矩阵(origin_status.capabilities 暴露;真机探针结论数据化)
# ---------------------------------------------------------------------------
CAPABILITY_KNOWN_RISKS = [
{"code": "plotxy_type_204_215", "severity": "high", "versions": ["2026b"],
"note": "LabTalk plotxy 图型代码 204/215 可能渲染成面积图或不出图",
"workaround": "box/bar 已内置改走 Origin 官方模板;请勿直接传 plotxy 代码"},
{"code": "multi_origin_instances", "severity": "high", "versions": ["all"],
"note": "多个 Origin64 进程会让 COM 连接错实例(LT_execute 报异常)",
"workaround": "只保留一个主实例;origin_status 返回进程数警告"},
{"code": "com_thread_affinity", "severity": "medium", "versions": ["all"],
"note": "comtypes/OriginExt 的 COM 接口指针有线程亲和性,跨线程调用报"
"「对象没有连接到服务器」",
"workaround": "引擎已用专用 COM 线程串行化,调用方无需处理"},
{"code": "modal_dialog_blocks_com", "severity": "medium", "versions": ["all"],
"note": "Origin 弹出模态对话框时 COM 调用会挂起",
"workaround": "自动化期间不要手动操作 Origin;关闭对话框后重试"},
{"code": "dualy_template_local", "severity": "low", "versions": ["all"],
"note": "双 Y 模板名因版本而异(2026 实测 doubley/righty 可用,dualy 不存在),"
"origin_plot_template=dual_y 会按 dualy/doubley/righty 顺序探测",
"workaround": "全部不可用时改用 origin_plot 双序列或 stacked_spectra"},
]
_ORIGIN_VERSION_LABELS = {10.15: "Origin 2024b"}
# ---------------------------------------------------------------------------
# 专用 COM 线程调度
# ---------------------------------------------------------------------------
class _TaskResult:
"""单次任务的结果槽:主线程等待、COM 线程填充。"""
def __init__(self):
self._evt = threading.Event()
self.value = None
self.exc = None
def set_result(self, v):
self.value = v
self._evt.set()
def set_exception(self, e):
self.exc = e
self._evt.set()
def wait(self, timeout=None):
"""等待完成;带 timeout 时返回是否在时限内完成(不抛出结果)。"""
if timeout is None:
self._evt.wait()
if self.exc is not None:
raise self.exc
return self.value
ok = self._evt.wait(timeout)
if ok and self.exc is not None:
raise self.exc
return ok
def _com_thread_loop():
"""专用 COM 线程主循环:串行执行所有 Origin 任务。"""
while True:
task = _com_queue.get()
if task is None:
return
fn, args, kwargs, done = task
try:
if _ipc_lock is not None:
with _ipc_lock:
done.set_result(fn(*args, **kwargs))
else:
done.set_result(fn(*args, **kwargs))
except Exception as e:
done.set_exception(e)
def _watchdog_origin_pids():
"""Origin 进程 PID 集合(tasklist 输出为 GBK,中文 Windows 实测)。"""
import subprocess as _sp
try:
out = _sp.run(
["tasklist", "/FI", "IMAGENAME eq Origin64.exe", "/FO", "CSV"],
capture_output=True).stdout.decode("gbk", errors="replace")
return {int(line.split('","')[1]) for line in out.splitlines()
if line.startswith('"Origin')}
except Exception:
return set()
def _watchdog_dismiss_modals():
"""枚举 Origin 的可见模态对话框并点击白名单按钮(看门狗,P0-1)。
返回 [{"title": 窗口标题, "clicked": 按钮文本}];找不到/点不掉的弹窗只记标题。
机制(2026-09-16 探针实证):EnumWindows 找 Origin PID 的 #32770 类
可见对话框 → EnumChildWindows 找按钮 → PostMessage(BM_CLICK) 异步点击。
"""
results = []
try:
import win32gui
import win32con
import win32process
except ImportError:
return results # 无 pywin32:看门狗降级(直接走超时分支)
pids = _watchdog_origin_pids()
if not pids:
return results
found = []
def _cb(h, _):
try:
if (win32gui.GetClassName(h) == "#32770"
and win32gui.IsWindowVisible(h)):
_, pid = win32process.GetWindowThreadProcessId(h)
if pid in pids:
found.append((h, win32gui.GetWindowText(h)))
except Exception:
pass
try:
win32gui.EnumWindows(_cb, None)
except Exception:
return results
for h, title in found:
entry = {"title": title, "clicked": None}
try:
buttons = []
win32gui.EnumChildWindows(
h, lambda b, _: buttons.append((b, win32gui.GetWindowText(b))),
None)
entry["buttons"] = [t for _, t in buttons]
for b, txt in buttons:
if txt in ("OK", "确定", "Yes", "是", "No", "否",
"Close", "关闭", "Cancel", "取消"):
win32gui.PostMessage(b, win32con.BM_CLICK, 0, 0)
entry["clicked"] = txt
break
except Exception:
pass
results.append(entry)
return results
def _watchdog_timeout_secs():
"""COM 软超时(秒):DSH_ORIGIN_DISPATCH_TIMEOUT 覆盖,默认 90。
依据:大项目(实测 800 页堆积)单次 close 枚举可达 59s——60s 会误杀
慢但正常的调用,取 90s(对齐 youngminsw 默认)。
"""
try:
return max(10.0, float(os.environ.get("DSH_ORIGIN_DISPATCH_TIMEOUT", 90)))
except (TypeError, ValueError):
return 90.0
def _watchdog_grace_secs():
"""点击对话框后再等待(秒):DSH_ORIGIN_WATCHDOG_GRACE,默认 15。"""
try:
return max(2.0, float(os.environ.get("DSH_ORIGIN_WATCHDOG_GRACE", 15)))
except (TypeError, ValueError):
return 15.0
def _run_on_com_thread(fn, *args, **kwargs):
"""把 fn 投递到专用 COM 线程执行并等待结果(线程安全,可被并发调用)。
P0-1 看门狗(2026-09-16):软超时后枚举 Origin 模态对话框并自动点击
白名单按钮(OK/确定/取消类);解除阻塞则正常返回并附
watchdog_dismissed;仍未解除则硬超时——重建 COM 线程自愈,返回
com_blocked_by_dialog 错误(含最后看到的对话框标题)。
"""
global _com_thread
if _com_thread is None or not _com_thread.is_alive():
t = threading.Thread(target=_com_thread_loop, name="origin-com", daemon=True)
t.start()
_com_thread = t
done = _TaskResult()
_com_queue.put((fn, args, kwargs, done))
# 阶段 1:软超时内正常等待
if done.wait(timeout=_watchdog_timeout_secs()):
return done.value
# 阶段 2:看门狗——尝试点掉 Origin 的模态对话框
dismissed = _watchdog_dismiss_modals()
if done.wait(timeout=_watchdog_grace_secs()):
v = done.value
# 恢复正常:原结果附加看门狗信息(dict 与 connect 的 (bool, dict) 都覆盖)
note = (f"本次调用曾被 Origin 模态对话框阻塞,看门狗已自动解除: {dismissed}")
if isinstance(v, dict):
v["watchdog_dismissed"] = dismissed
v["warning"] = (v.get("warning") + ";" if v.get("warning") else "") + note
elif (isinstance(v, tuple) and len(v) == 2 and isinstance(v[1], dict)):
v[1]["watchdog_dismissed"] = dismissed
v[1]["warning"] = ((v[1].get("warning") or "") + ";"
if v[1].get("warning") else "") + note
return v
# 阶段 3:硬超时——看门狗无效。旧 COM 线程仍堵在 Origin 内部调用上,
# 只有 Origin 进程结束该调用才会异常返回(随后队列自愈继续)。
titles = [d.get("title", "?") for d in dismissed]
if _autokill_enabled():
# isolated 会话(或显式开启):Origin 视为自动化残留,直接清理,
# COM 线程的下一次队列任务会收到异常并继续——系统自愈。
kill = _kill_residual_origin(wait_seconds=2.0)
return oerr.fail(
"com_blocked_by_dialog",
f"COM 调用被 Origin 模态对话框阻塞超过 "
f"{_watchdog_timeout_secs():.0f}+{_watchdog_grace_secs():.0f} 秒,"
f"看门狗未能解除(发现对话框: {titles or '未枚举到'})。"
f"已按 isolated 策略自动清理 Origin(killed={kill.get('killed')}),"
"请重试;Origin 将以干净状态重连。",
watchdog={"dismissed": dismissed, "autokill": kill},
timeout_s=_watchdog_timeout_secs() + _watchdog_grace_secs())
return oerr.fail(
"com_blocked_by_dialog",
f"COM 调用被 Origin 模态对话框阻塞超过 "
f"{_watchdog_timeout_secs():.0f}+{_watchdog_grace_secs():.0f} 秒,"
f"看门狗未能解除(发现对话框: {titles or '未枚举到'})。"
"请在 Origin 窗口手动关闭该对话框后原样重试;若 Origin 已无响应,"
"taskkill /F /IM Origin64.exe 后重连。",
watchdog={"dismissed": dismissed},
timeout_s=_watchdog_timeout_secs() + _watchdog_grace_secs())
def _configure_ipc_lock_if_requested():
"""按环境变量 ORIGIN_IPC_LOCK 启用跨进程命名互斥体锁(Windows)。"""
global _ipc_lock
if _ipc_lock is not None:
return
if os.environ.get("ORIGIN_IPC_LOCK", "").lower() in ("1", "true", "yes"):
try:
_ipc_lock = _WindowsNamedMutex("dsh_origin_plugin_mutex")
except Exception as e: # 失败不影响主流程,仅降级为单 COM 线程模型
_ipc_lock = None
sys.stderr.write(f"[origin_engine] 跨进程锁初始化失败,降级: {e}\n")
class _WindowsNamedMutex:
"""Windows 命名互斥体(跨进程互斥)。"""
def __init__(self, name):
import ctypes
self._kt = ctypes.windll.kernel32
self._handle = self._kt.CreateMutexW(None, False, name)
if not self._handle:
raise ctypes.WinError()
self._INFINITE = 0xFFFFFFFF
def __enter__(self):
res = self._kt.WaitForSingleObject(self._handle, self._INFINITE)
if res != 0: # WAIT_OBJECT_0 = 0
raise RuntimeError(f"跨进程锁等待失败 (WaitForSingleObject={res})")
def __exit__(self, *exc):
self._kt.ReleaseMutex(self._handle)
return False
def _synchronized(fn):
"""装饰器:公开函数 -> 投递到专用 COM 线程串行执行。
边界兜底:若 impl 返回旧式 {"ok": false, ...},升级为统一错误码结构
(error_code / recoverable / next_actions),保证 MCP 面格式始终一致。
每次调用自动附加 trace_id 与 duration_ms(#6),便于定位失败阶段。
"""
def wrapper(*args, **kwargs):
_configure_ipc_lock_if_requested()
t0 = time.time()
result = _run_on_com_thread(fn, *args, **kwargs)
elapsed_ms = int((time.time() - t0) * 1000)
trace_id = f"t{uuid.uuid4().hex[:12]}"
# 常规 dict 返回直接附加;connect() 的 (bool, dict) 元组附加到第二元素
if isinstance(result, dict):
result["trace_id"] = trace_id
result["duration_ms"] = elapsed_ms
if not result.get("ok") and "error_code" not in result:
return oerr.upgrade_legacy_failure(result)
return result
if (isinstance(result, tuple) and len(result) == 2
and isinstance(result[1], dict)):
result[1]["trace_id"] = trace_id
result[1]["duration_ms"] = elapsed_ms
return result
return wrapper
# ---------------------------------------------------------------------------
# 连接(以下 *_impl 函数只在 COM 线程内执行)
# ---------------------------------------------------------------------------
def _safe_find_graph(op, name=None):
"""安全版 op.find_graph —— 绕过 originpro 1.1.15 的库缺陷。
实测(2026-09-18 用户现场,Origin 2024 SR1):originpro 1.1.15 的
find_graph(name) 会抛 **TypeError**——它把对象传给了只接受 int/str 的 Pages()。
这里先走库方法,抛 TypeError 时退化为遍历页面按短名匹配;都失败返回 None
(上层按 graph_not_found 处理,**不让异常冒泡**)。
"""
if op is None:
return None
try:
return op.find_graph(name) if name else op.find_graph()
except TypeError:
pass
except Exception:
return None
def _short(gp):
try:
return str(gp.GetName() if hasattr(gp, "GetName") else getattr(gp, "name", ""))
except Exception:
return ""
try:
pages = op.pages() if hasattr(op, "pages") else None
if pages is None:
return None
for pg in pages:
try:
if name is None:
if "Graph" in _short(pg):
return pg
elif _short(pg).split("]")[-1].lower() == str(name).lower():
return pg
except Exception:
continue
except Exception:
return None
return None
def _origin_running():
"""探测 Origin 主进程是否在运行(仅提示用,不阻塞)。"""
try:
out = os.popen('tasklist /FI "IMAGENAME eq Origin64.exe" /NH 2>nul').read()
return "Origin64.exe" in out
except Exception:
return None
def _isolated_session_blocked():
"""ORIGIN_SESSION=isolated 时的隔离守卫:已有 Origin 在运行则拒绝连接。
设计动机:默认 attach 模式复用已运行的 Origin(快),但会"劫持"用户手动
打开的 Origin 窗口;隔离模式宁可拒绝也不碰用户会话,由用户决定关闭 Origin
还是切回 attach。返回 None 表示放行,返回 dict 表示结构化拒绝。
"""
if os.environ.get("ORIGIN_SESSION", "attach").lower() != "isolated":
return None
nproc = _origin_proc_count()
if nproc:
return oerr.fail(
"origin_busy_user_session",
f"隔离会话模式(ORIGIN_SESSION=isolated)检测到 {nproc} 个 Origin64 进程"
"正在运行,为避免劫持用户手动打开的 Origin,已停止连接。",
origin_processes=nproc)
return None
def _lt_read_float(expr):
"""读取 LabTalk 数值表达式(项目变量中转,originpro/OriginExt 双通道)。
只允许在 COM 线程内调用。读不到返回 None(调用方决定 unreadable 语义)。
"""
op = _origin_app
if op is None:
return None
try:
op.po.LT_execute(f"double __dshv = {expr};")
except Exception:
return None
try:
v = op.lt_float("__dshv")
if v is not None:
return float(v)
except Exception:
pass
try:
v = op.po.LT_get_var("__dshv")
if v is not None:
return float(v)
except Exception:
pass
return None
def _lt_read_str(expr):
"""读取 LabTalk 字符串表达式(项目变量中转)。读不到返回 None。"""
op = _origin_app
if op is None:
return None
try:
op.po.LT_execute('__dshvs$ = %s;' % expr)
except Exception:
return None
try:
v = op.lt_str("__dshvs$")
if v is not None:
return str(v)
except Exception:
pass
try:
v = op.po.LT_get_str("__dshvs$")
if isinstance(v, tuple) and v:
v = v[0]
if v is not None:
return str(v)
except Exception:
pass
return None
def _ensure_active_graph(graph=None):
"""激活目标图页并复核(LabTalk 通道的前提)。
真机探针(probe6)证明:LabTalk 裸表达式只在目标图页恰为活动窗口时解析,
否则静默返回 NaN 或落到别的窗口。返回 (short_name, error_payload|None)。
"""
op = _origin_app
if op is None:
return None, oerr.fail("connection_error", "未连接 Origin")
import origin_edit as oedit
return oedit.ensure_active_graph(op, op.po, graph)
def _lt_write_checked(script, read_expr=None, expect=None, read_fn=None):
"""LabTalk 写入 + 读回校验:返回 (ok, readback, note)。
- 先复核激活(否则直接判失败,避免"静默落到别的窗口");
- read_fn 优先(COM 读回通道,如 axis.title),否则用 read_expr 走 LabTalk;
- 读不到即判失败(宁可报未生效,也不谎报成功)。
"""
short, err = _ensure_active_graph()
if err is not None:
return False, None, f"激活复核失败: {err.get('error')}"
try:
_origin_app.po.LT_execute(script)
except Exception as e:
return False, None, f"LabTalk 执行异常: {e}"
v = None
if read_fn is not None:
v, verr = safe_call(read_fn)
if verr is not None:
v = None
if v is None and read_expr:
v = _lt_read_float(read_expr)
if v is None:
v = _lt_read_str(read_expr)
if v is None:
return False, None, "写入后读回失败(通道不支持读回,或未落到目标图页)"
# NaN 是"读到了空值",绝不能当作校验通过(NaN 比较恒为 False,会假成功)
try:
import math as _math
if isinstance(v, float) and _math.isnan(v):
return False, None, "读回为 NaN(该 LabTalk 通道在当前图层/版本下无效),判未生效"
except Exception:
pass
if expect is not None:
try:
if abs(float(v) - float(expect)) > 1e-6:
return False, v, f"读回 {v} 与期望 {expect} 不符"
except (TypeError, ValueError):
if str(v).strip() != str(expect).strip():
return False, v, f"读回 {v!r} 与期望 {expect!r} 不符"
return True, v, None
def _origin_proc_count():
"""统计 Origin64 进程数(>1 说明存在多实例,需清理以免 COM 连错实例)。"""
try:
out = os.popen('tasklist /FI "IMAGENAME eq Origin64.exe" /NH 2>nul').read()
return sum(1 for line in out.splitlines() if "Origin64.exe" in line)
except Exception:
return None
def _autokill_enabled():
"""连接前自动清理残留 Origin 进程的开关(DSH_ORIGIN_AUTOKILL)。
- 显式 "1"/"true"/"on"/"yes":强制开启;"0"/"false"/"off"/"no":强制关闭;
- 未设置时:仅 ORIGIN_SESSION=isolated 默认开启(隔离会话里正在运行的
Origin 视为上一轮自动化残留,可安全清理重建);
- attach 模式(默认)不开:用户手动打开的 Origin 可能有未保存工作,
只在连接失败时通过 recovery.diagnose 建议手动 taskkill。
"""
raw = os.environ.get("DSH_ORIGIN_AUTOKILL")
if raw is not None and raw.strip():
return raw.strip().lower() in ("1", "true", "on", "yes")
return os.environ.get("ORIGIN_SESSION", "attach").lower() == "isolated"
def _kill_residual_origin(wait_seconds=2.0):
"""taskkill 清理全部 Origin64 进程并等待其完全退出(连接前自愈,#1)。
约束:只应在 _autokill_enabled() 为真时调用(见该函数的策略说明)。
返回诊断 dict;after>0 表示 taskkill 未能清干净(权限/守门进程)。
"""
before = _origin_proc_count()
info = {"enabled": True, "before": before, "killed": False, "after": None,
"wait_ms": 0}
if not before:
info["after"] = before
return info
try:
os.popen('taskkill /F /IM Origin64.exe /T >nul 2>&1').read()
info["killed"] = True
except Exception:
pass
t0 = time.time()
time.sleep(max(0.0, float(wait_seconds))) # 要求:等 2 秒再让 COM 重启它
deadline = time.time() + 8.0 # 进程退出最多再等 8 秒
while time.time() < deadline:
if not _origin_proc_count():
break
time.sleep(0.5)
info["wait_ms"] = int((time.time() - t0) * 1000)
info["after"] = _origin_proc_count()
return info
def _connect_impl():
global _connected, _origin_app
if _connected and _origin_app is not None:
return True, _describe_impl()
# 连接前自愈(#1):清理残留 Origin64 进程 -> 等 2 秒 -> 再让 COM 启动/复用
autokill = _kill_residual_origin() if _autokill_enabled() else None
blocked = _isolated_session_blocked()
if blocked is not None:
if autokill and autokill.get("after"):
blocked["autokill"] = autokill
blocked.setdefault("next_actions", []).append(
"已尝试自动 taskkill 但仍有 Origin64 进程存活(权限不足),"
"请手动关闭或用管理员权限重试")
return False, blocked
try:
import originpro as op
# 单实例语义加固:originpro 首次访问默认走 Origin.Application(可能启动
# 新实例;多实例时 COM 会连错导致 LT_execute 异常)。这里在首次访问前
# 强制 Attach(OriginExt.ApplicationSI)复用已运行的 Origin 主实例。
try:
from originpro import config as _opconfig
_opconfig.po.Attach()
except Exception:
pass
started = time.time()
user_files = op.path() # 触发 COM 连接(OriginExt.ApplicationSI)
elapsed = time.time() - started
_origin_app = op
_connected = True
nproc = _origin_proc_count()
info = {
"ok": True,
"connected": True,
"user_files": user_files,
"connect_ms": int(elapsed * 1000),
"origin_running_before": _origin_running(),
"detail": "已连接 Origin COM 自动化服务器",
}
if autokill is not None:
info["autokill"] = autokill
if nproc and nproc > 1:
info["warning"] = (
f"检测到 {nproc} 个 Origin64 进程(正常应为 1)。多实例会导致 COM "
"连接异常(如 LT_execute 报错)。请关闭多余 Origin 窗口,仅保留主实例。"
)
return True, info
except Exception as e:
nproc = _origin_proc_count()
extra = ""
if nproc and nproc > 1:
extra = (f" 当前有 {nproc} 个 Origin64 进程(多实例冲突常见原因),"
"请关闭多余的 Origin 窗口只保留一个主实例后重试。")
result = oerr.fail(
"connection_error",
str(e) + extra,
origin_running=_origin_running(),
next_actions=[
"确认已安装 Origin(C:\\Program Files\\OriginLab\\Origin2026b\\Origin64.exe)",
"确认 Origin 可被脚本启动(首次启动约 5~45 秒)",
"Origin 与脚本的运行权限需一致(管理员/普通)",
"taskkill /F /IM Origin64.exe 清理残留进程,等 2 秒后重试"
"(或设置 DSH_ORIGIN_AUTOKILL=1 自动清理)",
"调 origin_diagnose 定位安装/COM 注册/目录权限问题",
])
if autokill is not None:
result["autokill"] = autokill
return False, result
def _describe_impl():
op = _origin_app
info = {
"ok": True,
"connected": _connected,
"user_files": op.path() if op else None,
"origin_exe": "C:\\Program Files\\OriginLab\\Origin2026b\\Origin64.exe",
"lock": "专用COM线程(串行)" + (" + 跨进程命名互斥体" if _ipc_lock else ""),
}
return info
# ---------------------------------------------------------------------------
# 数据写入
# ---------------------------------------------------------------------------
def _new_unique_name(prefix):
return f"{prefix}_{uuid.uuid4().hex[:8]}"
def safe_call(fn, *args, **kwargs):
"""调用并容错:返回 (result, error_str|None)。"""
try:
return fn(*args, **kwargs), None
except Exception as e: # noqa: BLE001
return None, f"{type(e).__name__}: {e}"
def _set_layer_geometry(gl, left=None, top=None, width=None, height=None):
"""以 %页 单位设置图层几何(COM 作用域读写,窗口无关),返回逐项说明。"""
notes = []
try:
gl.set_int("unit", 1) # 1 = %page
except Exception:
pass
for key, val in (("left", left), ("top", top),
("width", width), ("height", height)):
if val is None:
continue
ok = False
for setter in (lambda k=key, v=val: gl.set_float(k, float(v)),
lambda k=key, v=val: gl.set_int(k, int(round(float(v))))):
_, err = safe_call(setter)
if err is None:
ok = True
break
back, _ = safe_call(gl.get_float, key)
if back is None:
back, _ = safe_call(gl.get_int, key)
hit = ok and isinstance(back, (int, float)) and abs(float(back) - float(val)) <= 0.05
notes.append(f"{key}={val}%{'✔' if hit else '✗'}"
+ (f"(读回 {back})" if not hit and back is not None else ""))
return notes
def _page_names():
"""当前项目所有页面短名集合(用于 plotxy 页面差集检测)。"""
op = _origin_app
try:
return {str(op.po.Pages(i).GetName()) for i in range(op.po.Pages.Count)}
except Exception:
return set()
def _plotxy_new_page(script, before):
"""执行 LabTalk plotxy,用页面名差集返回新建的图页短名。
不依赖 find_graph()(其返回最近图页有歧义),因此更健壮:
优先选非 Book 的图页,避免把数据工作簿当成图。
"""
op = _origin_app
op.po.LT_execute(script)
after = _page_names()
newp = after - before
if not newp:
return None
cands = [n for n in newp if not n.lower().startswith("book")]
return (cands or sorted(newp))[0]
def _delete_graph_page(graph_name):
"""按短名删除图页(幂等命名用)。返回是否已删除。"""
op = _origin_app
try:
gp = _safe_find_graph(op, graph_name)
if gp is not None:
gp.destroy()
return True
except Exception:
pass
# 兜底:LabTalk 直接删页
try:
op.po.LT_execute(f"page -d {graph_name};")
return True
except Exception:
return False
def _ensure_graph_name(graph_name, title):
"""幂等命名:若已有同名图页,先删旧再新建,图名保持稳定,不产生 Graph2/3。
返回用于 new_graph 的 lname。
"""
if graph_name:
_delete_graph_page(graph_name)
return str(graph_name)
return title or ""
def _write_data_impl(columns, worksheet=None, book_name=None, sheet_name=None):
try:
ok, conn = _connect_impl()
if not ok:
return conn
op = _origin_app
data = _normalize_columns(columns)
if data is None:
return {"ok": False, "error": "columns 参数必须是 dict{列名: 列表} 或 二维列表"}
if not data:
return {"ok": False, "error": "columns 为空"}
if worksheet:
wks = op.find_sheet("w", worksheet)
if not wks:
return {"ok": False, "error": f"工作表不存在: {worksheet}"}
else:
wks = op.new_sheet("w", sheet_name or _new_unique_name("DSHData"))
if not wks:
return {"ok": False, "error": "新建工作表失败"}
rows = max((len(v) for v in data.values()), default=0)
col_names = list(data.keys())
for i, cname in enumerate(col_names):
vals = data[cname]
axis = "X" if i == 0 else "Y"
wks.from_list(i, vals, lname=cname, axis=axis)
return {
"ok": True,
"worksheet": str(wks),
"book": book_name,
"columns": col_names,
"rows": rows,
"detail": f"已写入 {len(col_names)} 列 x {rows} 行到 {wks}",
}
except Exception as e:
return {"ok": False, "error": f"{e}", "trace": traceback.format_exc(limit=3)}
def _normalize_columns(columns):
"""把 dict{列名: 列表} / 二维列表 / 一维列表 统一为 dict{列名: 列表}。"""
if isinstance(columns, dict):
out = {}
for k, v in columns.items():
if isinstance(v, (list, tuple)):
out[str(k)] = [float(x) if isinstance(x, (int, float)) else x for x in v]
else:
return None
return out
if isinstance(columns, (list, tuple)):
rows = list(columns)
if not rows:
return {}
if all(isinstance(r, (list, tuple)) for r in rows):
ncol = max(len(r) for r in rows)
return {f"C{i+1}": [r[i] if i < len(r) else None for r in rows] for i in range(ncol)}
if all(isinstance(r, (int, float)) for r in rows):
return {"Y": [float(x) for x in rows]}
return None
# ---------------------------------------------------------------------------
# 画图
# ---------------------------------------------------------------------------
def _plot_impl(worksheet, y_columns=None, x_column=None, plot_type="line",
graph_name=None, title=None, yerr_column=None,
style_mode=None, family=None, style_overrides=None):
"""画图(支持幂等命名 + 可选样式应用)。返回包含 style 建议。"""
try:
ok, conn = _connect_impl()
if not ok:
return conn
op = _origin_app
plot_type = (plot_type or "line").lower()
wks = op.find_sheet("w", worksheet)
if not wks:
return oerr.fail("worksheet_not_found", f"工作表不存在: {worksheet}", worksheet=worksheet)
valid_kinds = list(PLOT_TYPES) + ["histogram", "box", "bar"]
if plot_type not in valid_kinds:
return oerr.fail("invalid_request",
f"plot_type 必须是 {valid_kinds} 之一,收到 {plot_type!r}",
valid_kinds=valid_kinds, received=plot_type)
# 幂等命名:同名图页先删旧再新建
lname = _ensure_graph_name(graph_name, title or "")
# 特殊图型:直方图(numpy 分箱 + 柱状图,不依赖 plotxy,稳定可控)
if plot_type == "histogram":
import numpy as np
col = 0
if y_columns:
c = y_columns[0]
ci2 = _col_index_impl(wks, c)
col = ci2 if ci2 is not None else 0
v = np.asarray(wks.to_list(col), dtype=float)
v = v[np.isfinite(v)]
if v.size == 0:
return oerr.fail("empty_data", "直方图数据为空", column=str(col))
bins = 10
counts, edges = np.histogram(v, bins=bins)
centers = (edges[:-1] + edges[1:]) / 2
hs = op.new_sheet("w", "HistData")
hs.from_list(0, list(centers), lname="bin_center")
hs.from_list(1, list(counts), lname="count")
gp = op.new_graph(lname=lname or "Histogram")
gl = gp[0]
gl.add_plot(hs, 1, 0, type="c")
gl.rescale()
short_name = gp.obj.GetName()
style = _apply_style_impl(short_name, plot_type="histogram",
columns=["count"], style_mode=style_mode,
family=family, x_title="Bin center",
style_overrides=style_overrides)
return {
"ok": True,
"graph": short_name,
"graph_short": short_name,
"plot_type": "histogram",
"y_columns": [str(col)],
"x_column": "auto",
"bins": bins,
"style": style,
"detail": f"已创建直方图 {short_name}({bins} 个 bin,{v.size} 点)",
}
# 特殊图型:箱线图 / 条形图(Origin 官方模板,比 plotxy 代码稳定可靠;
# plotxy 的 204/215 在部分 Origin 2026b 上会渲染成面积图或不出图)
if plot_type in ("box", "bar"):
is_bar = plot_type == "bar"
ncols = wks.obj.Cols
if y_columns:
c = y_columns[0]
ci2 = _col_index_impl(wks, c)
col = ci2 if ci2 is not None else (1 if ncols > 1 else 0)
else:
col = 1 if ncols > 1 else 0 # 默认取第二列(首列为 X 的惯例)
templ = "bar" if is_bar else "box"
gp = op.new_graph(lname=lname or ("Bar" if is_bar else "Box"),
template=templ)
gl = gp[0]
p = gl.add_plot(wks, col, "#", type="?") # '#' = 行号/类别作 X
if p is None:
return oerr.fail("origin_operation_error", f"{plot_type} 图创建失败")
gl.rescale()
short_name = gp.obj.GetName()
return {
"ok": True,
"graph": short_name,
"graph_short": short_name,
"plot_type": plot_type,
"y_columns": [str(col)],
"x_column": "auto",
"detail": f"已创建图 {short_name}({PLOT_TYPES_CN.get(plot_type, plot_type)})",
}
# 主路径:XY 基础图型(line/scatter/line_symbol/column)
if x_column is None:
x_column = 0 # 默认第一列(列索引),与 write_data 的"第一列自动为 X"一致
if y_columns is None:
y_columns = _y_columns_impl(wks, x_column)
gp = op.new_graph(lname=lname) # 短名=给定名或自动分配
gl = gp[0]
plotted = []
for yc in y_columns:
p = gl.add_plot(wks, yc, x_column, type=PLOT_TYPES[plot_type],
colyerr=yerr_column or -1)
if p is None:
return oerr.fail("column_not_found", f"画图失败: y={yc}, x={x_column}",
y_column=str(yc), x_column=str(x_column))
plotted.append(str(yc))
gl.rescale()
short_name = gp.obj.GetName()
x_title = None
try:
# 解析真实 X 列名(x_column 可能是 0 起始索引),再语义推断标题
x_name = x_column if not isinstance(x_column, int) else None
if x_name is None and x_column is not None:
x_name = _col_name_impl(wks, int(x_column))
if x_name:
t = pst.infer_axis_title([str(x_name)])["title"]