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/originlab.py
CHANGED
|
@@ -9,10 +9,13 @@ by a registry, because parsing Origin's own [Book]Sheet! range strings back and
|
|
|
9
9
|
forth is the most common source of silent failures.
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
|
+
import atexit
|
|
13
|
+
import math
|
|
12
14
|
import os
|
|
13
15
|
import queue
|
|
14
16
|
import re
|
|
15
17
|
import struct
|
|
18
|
+
import subprocess
|
|
16
19
|
import sys
|
|
17
20
|
import threading
|
|
18
21
|
import time
|
|
@@ -28,14 +31,20 @@ PLOT_CODES = {"line": 200, "scatter": 201, "line_symbol": 202, "column": 203}
|
|
|
28
31
|
PLOT_LETTERS = {"line": "l", "scatter": "s", "line_symbol": "y", "column": "c"}
|
|
29
32
|
# originpro passes this integer straight to layadd, and the layer's own name is the
|
|
30
33
|
# proof: read back as BottomXLeftY/TopX/RightY/LeftY/TopXRightY/BottomXRightY/Inset.
|
|
31
|
-
|
|
34
|
+
# Humans say "bottom" for the plain below-axis layer, which is code 0 (BottomXLeftY).
|
|
35
|
+
LAYER_TYPES = {"bottom": 0, "bottom_left": 0, "top": 1, "right": 2, "left": 3,
|
|
32
36
|
"top_right": 4, "bottom_right": 5, "inset": 6}
|
|
33
37
|
# What Origin itself calls each layer, read back from layer.name after add_layer.
|
|
34
38
|
LAYER_NAME_BY_CODE = {0: "BottomXLeftY", 1: "TopX", 2: "RightY", 3: "LeftY",
|
|
35
39
|
4: "TopXRightY", 5: "BottomXRightY", 6: "Inset"}
|
|
36
40
|
# originpro's scale setter takes a name but the getter returns the int.
|
|
37
|
-
SCALE_TYPES = {"linear": 1, "log10": 2, "
|
|
38
|
-
"
|
|
41
|
+
SCALE_TYPES = {"linear": 1, "log10": 2, "log": 2, "probability": 3, "probit": 4,
|
|
42
|
+
"reciprocal": 5,
|
|
43
|
+
"offset_reciprocal": 6, "logit": 7, "ln": 8, "natural_log": 8, "log2": 9}
|
|
44
|
+
# originpro's setter only understands its own names, so a friendly alias such as
|
|
45
|
+
# "log" has to be translated back before it reaches layer.xscale.
|
|
46
|
+
SCALE_NAMES = {1: "linear", 2: "log10", 3: "probability", 4: "probit", 5: "reciprocal",
|
|
47
|
+
6: "offset_reciprocal", 7: "logit", 8: "ln", 9: "log2"}
|
|
39
48
|
# new_graph(template=NAME) accepts these OriginPro 2024 template stems (extension
|
|
40
49
|
# optional) and returns None for an unknown one, which is how they get validated.
|
|
41
50
|
GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley", "dply",
|
|
@@ -46,8 +55,9 @@ GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley", "dply",
|
|
|
46
55
|
# these were each constructed successfully on OriginPro 2024 SR1.
|
|
47
56
|
FIT_PRESETS = {"gauss": "Gauss", "gaussian": "Gauss", "lorentz": "Lorentz",
|
|
48
57
|
"voigt": "Voigt", "expdec1": "ExpDec1", "expdec2": "ExpDec2",
|
|
49
|
-
"
|
|
50
|
-
"
|
|
58
|
+
"expdecay": "ExpDec1", "sine": "Sine", "power": "Power",
|
|
59
|
+
"logistic": "Logistic", "boltzmann": "Boltzmann", "doseresp": "DoseResp",
|
|
60
|
+
"cubic": "Cubic", "poly3": "Cubic"}
|
|
51
61
|
# Legend anchors are layer DATA coordinates (Legend.x/.y read back exactly), so a
|
|
52
62
|
# position is a fraction of the axis span rather than a page measurement.
|
|
53
63
|
LEGEND_POSITIONS = {
|
|
@@ -59,6 +69,7 @@ LEGEND_POSITIONS = {
|
|
|
59
69
|
"inside": (0.75, 0.80),
|
|
60
70
|
}
|
|
61
71
|
IMAGE_MAGIC = {".png": b"\x89PNG\r\n\x1a\n", ".tif": b"II*\x00", ".tiff": b"II*\x00",
|
|
72
|
+
".jpg": b"\xff\xd8\xff", ".jpeg": b"\xff\xd8\xff",
|
|
62
73
|
".pdf": b"%PDF", ".svg": b"<", ".emf": b"\x01\x00\x00\x00"}
|
|
63
74
|
ASCII_EXTS = (".dat", ".csv", ".txt", ".tsv")
|
|
64
75
|
|
|
@@ -78,6 +89,7 @@ _q = queue.Queue()
|
|
|
78
89
|
_thread = None
|
|
79
90
|
_thread_lock = threading.Lock()
|
|
80
91
|
_op = None
|
|
92
|
+
_suspect = False
|
|
81
93
|
|
|
82
94
|
|
|
83
95
|
def _worker():
|
|
@@ -91,22 +103,78 @@ def _worker():
|
|
|
91
103
|
|
|
92
104
|
|
|
93
105
|
def call(fn, *args, timeout=240, **kwargs):
|
|
94
|
-
global _thread
|
|
106
|
+
global _thread, _suspect
|
|
95
107
|
with _thread_lock:
|
|
96
108
|
if _thread is None or not _thread.is_alive():
|
|
97
109
|
_thread = threading.Thread(target=_worker, daemon=True, name="origin-com")
|
|
98
110
|
_thread.start()
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
111
|
+
if _suspect:
|
|
112
|
+
# A previous call left Origin's COM channel in a bad state (a destroyed page
|
|
113
|
+
# keeps a C-level error pending, which then surfaces from an unrelated call).
|
|
114
|
+
# Re-attach once, lazily, so one wedge does not poison the whole session.
|
|
115
|
+
_suspect = False
|
|
116
|
+
_reset_connection()
|
|
117
|
+
for attempt in (0, 1):
|
|
118
|
+
box = {"done": threading.Event()}
|
|
119
|
+
_q.put((fn, args, kwargs, box))
|
|
120
|
+
if not box["done"].wait(timeout):
|
|
121
|
+
raise OriginError(
|
|
122
|
+
"com_timeout",
|
|
123
|
+
"Origin 在 %d 秒内没有响应" % timeout,
|
|
124
|
+
["看 Origin 窗口是否卡在对话框或许可证弹窗", "任务管理器结束 Origin64.exe 后重试"],
|
|
125
|
+
)
|
|
126
|
+
if "error" not in box:
|
|
127
|
+
return box["value"]
|
|
128
|
+
exc = box["error"]
|
|
129
|
+
# Once per call: Origin's cached COM pointer can go stale when the process
|
|
130
|
+
# dies (user closed it, instance limit, exit tool). Every later call then
|
|
131
|
+
# fails identically, so drop the pointer and let origin() re-attach.
|
|
132
|
+
if attempt == 0 and _dead_app(exc) and _reset_connection():
|
|
133
|
+
continue
|
|
134
|
+
raise exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
_DEAD_APP_MARKS = ("returned a result with an exception set", "ApplicationBase_",
|
|
138
|
+
"-2147417851", "-2147023170", "RPC 服务器不可用",
|
|
139
|
+
"The object invoked has disconnected from its clients",
|
|
140
|
+
"com_error", "Pywintypes.com_error")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _dead_app(exc):
|
|
144
|
+
"""True when Origin's COM connection itself is gone, not when an argument was bad."""
|
|
145
|
+
if isinstance(exc, OriginError):
|
|
146
|
+
# server.py flattens a raw SystemError into code="internal_error" before it
|
|
147
|
+
# reaches here, so judge by the message text rather than the code.
|
|
148
|
+
return any(m in exc.message for m in _DEAD_APP_MARKS)
|
|
149
|
+
text = "%s: %s" % (type(exc).__name__, exc)
|
|
150
|
+
return isinstance(exc, SystemError) or any(m in text for m in _DEAD_APP_MARKS)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _reset_connection():
|
|
154
|
+
"""Forget the cached originpro module and its dangling Application pointer."""
|
|
155
|
+
global _op, _suspect
|
|
156
|
+
_op = None
|
|
157
|
+
_suspect = False
|
|
158
|
+
# Page wrappers belong to the Origin process we just lost: keeping them would
|
|
159
|
+
# let the next call fail with a mystery error instead of "句柄无效,请重新导入".
|
|
160
|
+
_handles.clear()
|
|
161
|
+
_names.clear()
|
|
162
|
+
mod = sys.modules.get("originpro")
|
|
163
|
+
if mod is None:
|
|
164
|
+
return False
|
|
165
|
+
po = getattr(mod, "po", None)
|
|
166
|
+
if po is not None and "_app" in getattr(po, "__dict__", {}):
|
|
167
|
+
try:
|
|
168
|
+
po.__dict__["_app"] = None
|
|
169
|
+
except Exception:
|
|
170
|
+
return False
|
|
171
|
+
return True
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def mark_suspect():
|
|
175
|
+
"""Flag the COM channel as damaged: the next call re-attaches before running."""
|
|
176
|
+
global _suspect
|
|
177
|
+
_suspect = True
|
|
110
178
|
|
|
111
179
|
|
|
112
180
|
def origin():
|
|
@@ -119,6 +187,10 @@ def origin():
|
|
|
119
187
|
except ImportError as exc:
|
|
120
188
|
raise OriginError("missing_originpro", "这个 Python 环境里没有 originpro:%s" % exc,
|
|
121
189
|
["pip install originpro"])
|
|
190
|
+
# Record who was already running: anything that appears after we connect was
|
|
191
|
+
# started by us, and anything windowless that predates us is an orphan left by
|
|
192
|
+
# a client that exited without releasing its Origin.
|
|
193
|
+
before = _process_rows()
|
|
122
194
|
try:
|
|
123
195
|
op.path()
|
|
124
196
|
except Exception as exc:
|
|
@@ -128,12 +200,152 @@ def origin():
|
|
|
128
200
|
["确认装了 Origin/OriginPro 2021 或更高(originpro 的硬性下限)",
|
|
129
201
|
"确认 Origin 与 Python 都是 64 位",
|
|
130
202
|
"若任务管理器里有多个 Origin64.exe,只保留一个",
|
|
131
|
-
"长会话攒页面后桥会坏:先 origin_exit 让插件重启 Origin"
|
|
203
|
+
"长会话攒页面后桥会坏:先 origin_exit 让插件重启 Origin",
|
|
204
|
+
"实例数到上限时:origin_reclaim 会关掉没有窗口的残留实例"],
|
|
132
205
|
)
|
|
206
|
+
existing = {row["pid"] for row in before} if before is not None else set()
|
|
207
|
+
after = _process_rows()
|
|
208
|
+
if after is not None:
|
|
209
|
+
_started_pids.update(row["pid"] for row in after if row["pid"] not in existing)
|
|
133
210
|
_op = op
|
|
134
211
|
return op
|
|
135
212
|
|
|
136
213
|
|
|
214
|
+
# ------------------------------------------------------- instance lifecycle
|
|
215
|
+
|
|
216
|
+
ORIGIN_EXE = "Origin64.exe"
|
|
217
|
+
_started_pids = set() # instances that appeared because WE connected
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _process_rows():
|
|
221
|
+
"""List Origin64 processes as {pid, started, window}, or None if unknown.
|
|
222
|
+
|
|
223
|
+
MainWindowHandle is what separates an instance the human can see from an
|
|
224
|
+
orphan a departing client left behind: originpro launches Origin on demand
|
|
225
|
+
and Origin does not quit when its client process exits.
|
|
226
|
+
|
|
227
|
+
A failed probe returns None, never an empty list: an empty list reads as
|
|
228
|
+
"there is nothing to reclaim", which is the one thing a caller must not be
|
|
229
|
+
told when we could not actually look.
|
|
230
|
+
"""
|
|
231
|
+
if os.name != "nt":
|
|
232
|
+
return []
|
|
233
|
+
script = ("Get-Process Origin64 -ErrorAction SilentlyContinue | ForEach-Object {"
|
|
234
|
+
"\"$($_.Id)|\" + $(try { $_.StartTime.ToString('o') } catch { '' }) + "
|
|
235
|
+
"\"|$($_.MainWindowHandle)\"}")
|
|
236
|
+
try:
|
|
237
|
+
out = subprocess.run(["powershell", "-NoProfile", "-Command", script],
|
|
238
|
+
capture_output=True, text=True, timeout=25).stdout
|
|
239
|
+
except Exception:
|
|
240
|
+
return None
|
|
241
|
+
rows = []
|
|
242
|
+
for line in out.splitlines():
|
|
243
|
+
parts = line.strip().split("|")
|
|
244
|
+
if len(parts) != 3:
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
rows.append({"pid": int(parts[0]), "started": parts[1], "window": int(parts[2] or 0)})
|
|
248
|
+
except ValueError:
|
|
249
|
+
continue
|
|
250
|
+
return rows
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _terminate(pid, force=False):
|
|
254
|
+
args = ["taskkill"] + (["/F"] if force else []) + ["/PID", str(pid)]
|
|
255
|
+
return subprocess.run(args, capture_output=True, text=True, timeout=30)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _close_pid(pid):
|
|
259
|
+
"""Ask for a polite exit first; escalate to a forced kill only if it survives."""
|
|
260
|
+
_terminate(pid)
|
|
261
|
+
for _ in range(6):
|
|
262
|
+
time.sleep(0.5)
|
|
263
|
+
rows = _process_rows()
|
|
264
|
+
if rows is None:
|
|
265
|
+
continue # probe failed; keep polling rather than guessing "gone"
|
|
266
|
+
if pid not in {row["pid"] for row in rows}:
|
|
267
|
+
return "closed"
|
|
268
|
+
rows = _process_rows()
|
|
269
|
+
if rows is not None and pid not in {row["pid"] for row in rows}:
|
|
270
|
+
return "closed"
|
|
271
|
+
_terminate(pid, force=True)
|
|
272
|
+
return "force_closed"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def instances():
|
|
276
|
+
"""Report every Origin instance: background (reclaimable) vs foreground (the user's)."""
|
|
277
|
+
rows = _process_rows()
|
|
278
|
+
if rows is None:
|
|
279
|
+
return sanitize({"count": None, "probe": "unavailable",
|
|
280
|
+
"error": "读不到进程表(powershell 不可用或超时),无法确定实例数量",
|
|
281
|
+
"next_actions": ["重试 origin_instances", "或在任务管理器里看 Origin64.exe"]})
|
|
282
|
+
for row in rows:
|
|
283
|
+
row["foreground"] = bool(row["window"])
|
|
284
|
+
row["ours"] = row["pid"] in _started_pids
|
|
285
|
+
return sanitize({"count": len(rows), "probe": "ok",
|
|
286
|
+
"background": [r for r in rows if not r["foreground"]],
|
|
287
|
+
"foreground": [r for r in rows if r["foreground"]],
|
|
288
|
+
"started_by_us": sorted(_started_pids)})
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def reclaim(close_background=True):
|
|
292
|
+
"""Close windowless Origin instances; a visible window is reported, never killed.
|
|
293
|
+
|
|
294
|
+
An instance with no window has no client and no human in front of it. One with
|
|
295
|
+
a window may be the user's own unsaved work, so it is listed for them to close.
|
|
296
|
+
"""
|
|
297
|
+
rows = _process_rows()
|
|
298
|
+
if rows is None:
|
|
299
|
+
return sanitize({"closed": [], "force_closed": [], "left_running": [],
|
|
300
|
+
"probe": "unavailable",
|
|
301
|
+
"error": "读不到进程表,没有动任何进程",
|
|
302
|
+
"notify": "无法确认实例情况(进程探测失败),这次什么都没关。"})
|
|
303
|
+
back = [r for r in rows if not r["window"]]
|
|
304
|
+
front = [r for r in rows if r["window"]]
|
|
305
|
+
result = {"probe": "ok", "closed": [], "force_closed": [],
|
|
306
|
+
"left_running": [r["pid"] for r in front], "notify": ""}
|
|
307
|
+
if not close_background:
|
|
308
|
+
result["skipped"] = [r["pid"] for r in back]
|
|
309
|
+
return sanitize(result)
|
|
310
|
+
|
|
311
|
+
for row in back:
|
|
312
|
+
pid = row["pid"]
|
|
313
|
+
try:
|
|
314
|
+
outcome = _close_pid(pid)
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
result["left_running"].append(pid)
|
|
317
|
+
result["notify"] += "PID %s 关不掉:%s;" % (pid, exc)
|
|
318
|
+
continue
|
|
319
|
+
result[outcome].append(pid)
|
|
320
|
+
|
|
321
|
+
if front:
|
|
322
|
+
result["notify"] += ("检测到 %d 个**有窗口**的 Origin 实例,没有动它们(可能是你自己开着的项目,"
|
|
323
|
+
"含未保存工作)。要释放实例配额请自己关掉这些窗口,"
|
|
324
|
+
"或先 origin_save_project 保存。") % len(front)
|
|
325
|
+
if not result["notify"]:
|
|
326
|
+
result["notify"] = "已回收全部后台实例,没有发现前台窗口。"
|
|
327
|
+
_started_pids.difference_update(result["closed"] + result["force_closed"])
|
|
328
|
+
return sanitize(result)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _release_on_exit():
|
|
332
|
+
"""A client that exits must not leave its own Origin instance running."""
|
|
333
|
+
if not _started_pids:
|
|
334
|
+
return
|
|
335
|
+
try:
|
|
336
|
+
rows = _process_rows()
|
|
337
|
+
if rows is None:
|
|
338
|
+
return # cannot tell which pids are ours; leaving Origin is the safe error
|
|
339
|
+
for row in rows:
|
|
340
|
+
if row["pid"] in _started_pids and not row["window"]:
|
|
341
|
+
_close_pid(row["pid"])
|
|
342
|
+
except Exception:
|
|
343
|
+
pass
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
atexit.register(_release_on_exit)
|
|
347
|
+
|
|
348
|
+
|
|
137
349
|
# ---------------------------------------------------------------- handles
|
|
138
350
|
|
|
139
351
|
_handles = {}
|
|
@@ -210,11 +422,23 @@ def _round2(v):
|
|
|
210
422
|
|
|
211
423
|
def _col_index(ws, col):
|
|
212
424
|
ncol = int(_safe(lambda: ws.cols, 0) or 0)
|
|
425
|
+
if ncol == 0:
|
|
426
|
+
# A handle that resolves to a page with no columns is always either stale
|
|
427
|
+
# (the project was closed underneath us) or an empty book. Saying "列号超出
|
|
428
|
+
# 范围(共 0 列)" would send the model to re-count columns.
|
|
429
|
+
raise OriginError("sheet_empty", "这张工作表没有任何列,句柄可能已失效:%s"
|
|
430
|
+
% (_safe(lambda: ws.name, "?"),),
|
|
431
|
+
["origin_inspect 看当前页面", "重新 origin_import 或 origin_write 拿新句柄"])
|
|
213
432
|
idx = int(col) if isinstance(col, float) and float(col).is_integer() else (
|
|
214
433
|
int(col) if isinstance(col, int) else None)
|
|
215
434
|
if idx is not None:
|
|
216
|
-
|
|
217
|
-
|
|
435
|
+
# Column numbers are 1-based everywhere in this API. Passing the internal
|
|
436
|
+
# 0-based index straight through would silently rewrite the first column.
|
|
437
|
+
if idx < 1:
|
|
438
|
+
raise OriginError("bad_column", "列号从 1 开始,收到 %s" % col,
|
|
439
|
+
["想操作第 1 列就传 1,或改用列名字符串"])
|
|
440
|
+
idx = idx - 1
|
|
441
|
+
if idx < ncol:
|
|
218
442
|
return idx
|
|
219
443
|
raise OriginError("bad_column", "列号 %s 超出范围(共 %d 列)" % (col, ncol),
|
|
220
444
|
["列号从 1 开始,或用列名字符串"])
|
|
@@ -247,6 +471,16 @@ def _extend_to(ws, col):
|
|
|
247
471
|
return want - 1
|
|
248
472
|
|
|
249
473
|
|
|
474
|
+
def _numeric_columns(ws):
|
|
475
|
+
"""Column indices that hold at least one real number (0-based)."""
|
|
476
|
+
out = []
|
|
477
|
+
for i in range(int(_safe(lambda: ws.cols, 0) or 0)):
|
|
478
|
+
vals = _safe(lambda i=i: ws.to_list(i), []) or []
|
|
479
|
+
if any(isinstance(v, (int, float)) and not isinstance(v, bool) for v in vals):
|
|
480
|
+
out.append(i)
|
|
481
|
+
return out
|
|
482
|
+
|
|
483
|
+
|
|
250
484
|
def _sheet_info(ws):
|
|
251
485
|
rows = int(_safe(lambda: ws.rows, 0) or 0)
|
|
252
486
|
cols = int(_safe(lambda: ws.cols, 0) or 0)
|
|
@@ -263,13 +497,13 @@ def _sheet_info(ws):
|
|
|
263
497
|
}
|
|
264
498
|
|
|
265
499
|
|
|
266
|
-
def _verify_file(path, must_be_image=True):
|
|
500
|
+
def _verify_file(path, must_be_image=True, min_bytes=1024):
|
|
267
501
|
size = os.path.getsize(path)
|
|
268
502
|
ext = os.path.splitext(path)[1].lower()
|
|
269
503
|
with open(path, "rb") as fh:
|
|
270
504
|
head = fh.read(24)
|
|
271
505
|
magic = IMAGE_MAGIC.get(ext) if must_be_image else None
|
|
272
|
-
ok = size >
|
|
506
|
+
ok = size > min_bytes and (magic is None or head.startswith(magic))
|
|
273
507
|
dims = None
|
|
274
508
|
if ext == ".png" and head[12:16] == b"IHDR":
|
|
275
509
|
dims = list(struct.unpack(">II", head[16:24]))
|
|
@@ -400,7 +634,16 @@ def import_file(path, book_name=""):
|
|
|
400
634
|
raise OriginError("import_empty", "读进来了但是 0 行:%s" % path,
|
|
401
635
|
["自研解析器也失败了:%s" % (parsed or {}).get("fallback_reason", "见上"),
|
|
402
636
|
"确认文件里有数值行,或改用 origin_read_file 先看结构"])
|
|
403
|
-
|
|
637
|
+
warns = []
|
|
638
|
+
if not _numeric_columns(ws):
|
|
639
|
+
# Origin happily turns prose into a one-row text sheet; saying rows=1
|
|
640
|
+
# and calling it a success is how a bad file reaches a plot step.
|
|
641
|
+
warns.append("导进来的列里没有一列是数值(共 %d 列 %d 行),这张表画不出图:%s"
|
|
642
|
+
% (info.get("cols"), info.get("rows"), path))
|
|
643
|
+
info.update({"source": path, "format": ext, "channel": "origin_from_file",
|
|
644
|
+
"warnings": info.get("warnings", []) + warns})
|
|
645
|
+
if warns:
|
|
646
|
+
info["next_actions"] = ["用 origin_read_file 看文件真实结构,确认它是不是数据文件"]
|
|
404
647
|
return sanitize(info)
|
|
405
648
|
|
|
406
649
|
return call(run)
|
|
@@ -469,6 +712,8 @@ def column_formula(worksheet, col, formula, label="", units=""):
|
|
|
469
712
|
|
|
470
713
|
def run():
|
|
471
714
|
idx = _col_index(ws, col) if _col_exists(ws, col) else _extend_to(ws, col)
|
|
715
|
+
prev_formula = _safe(lambda: str(ws.get_formula(idx)), "")
|
|
716
|
+
before = [v for v in _safe(lambda: list(ws.to_list(idx)), []) if v is not None]
|
|
472
717
|
# originpro 1.1.15 documents set_formula's column as 1-offset, but it is
|
|
473
718
|
# 0-based here: passing idx+1 grabs a non-existent column and the library
|
|
474
719
|
# dies inside SetStrProp on a None object.
|
|
@@ -485,9 +730,19 @@ def column_formula(worksheet, col, formula, label="", units=""):
|
|
|
485
730
|
["LabTalk 里底 10 对数是 log() 不是 log10()",
|
|
486
731
|
"跨列引用要先绑定 range,不能内联 [Book]Sheet!col(N)",
|
|
487
732
|
"列号用 Col(1) 这种当前表写法试试"])
|
|
733
|
+
warns = []
|
|
734
|
+
proof = "verified"
|
|
735
|
+
if before and before == vals and prev_formula.strip() != str(formula).strip():
|
|
736
|
+
# A bogus LabTalk formula on a column that already had data leaves the
|
|
737
|
+
# old numbers in place, so "computed_rows > 0" alone cannot prove much.
|
|
738
|
+
warns.append("改了公式但这一列的数值一个都没变(%d 行原样保留):Origin 可能没有执行它"
|
|
739
|
+
% len(vals))
|
|
740
|
+
proof = "unverified"
|
|
488
741
|
return sanitize({"column": idx + 1, "formula": str(formula),
|
|
489
742
|
"computed_rows": len(vals), "first": vals[0], "last": vals[-1],
|
|
490
|
-
"columns": info["columns"], "
|
|
743
|
+
"columns": info["columns"], "warnings": warns, "proof_level": proof,
|
|
744
|
+
**({} if proof == "verified"
|
|
745
|
+
else {"next_actions": ["origin_inspect 看这一列现在到底是什么值"]})})
|
|
491
746
|
|
|
492
747
|
return call(run)
|
|
493
748
|
|
|
@@ -647,13 +902,15 @@ def style(graph, layer=0, series=None, x_title="", y_title="", x_scale="", y_sca
|
|
|
647
902
|
if want is None:
|
|
648
903
|
raise OriginError("bad_scale", "未知刻度类型:%r" % x_scale,
|
|
649
904
|
["可选 %s" % sorted(SCALE_TYPES)])
|
|
650
|
-
attempt("x_scale", lambda: setattr(lay, "xscale",
|
|
905
|
+
attempt("x_scale", lambda: setattr(lay, "xscale", SCALE_NAMES[want]),
|
|
906
|
+
lambda: lay.xscale, want)
|
|
651
907
|
if y_scale:
|
|
652
908
|
want = SCALE_TYPES.get(str(y_scale).lower())
|
|
653
909
|
if want is None:
|
|
654
910
|
raise OriginError("bad_scale", "未知刻度类型:%r" % y_scale,
|
|
655
911
|
["可选 %s" % sorted(SCALE_TYPES)])
|
|
656
|
-
attempt("y_scale", lambda: setattr(lay, "yscale",
|
|
912
|
+
attempt("y_scale", lambda: setattr(lay, "yscale", SCALE_NAMES[want]),
|
|
913
|
+
lambda: lay.yscale, want)
|
|
657
914
|
if xlim:
|
|
658
915
|
attempt("xlim", lambda: lay.set_xlim(*_pair(xlim)), lambda: lay.xlim)
|
|
659
916
|
if ylim:
|
|
@@ -718,16 +975,60 @@ def _pair(v):
|
|
|
718
975
|
return vals[:3]
|
|
719
976
|
|
|
720
977
|
|
|
978
|
+
def _axis_span(lay, which):
|
|
979
|
+
"""(begin, end) of an axis, via the layer's xlim/ylim — axis.from_ is not readable here."""
|
|
980
|
+
limits = _safe(lambda: getattr(lay, "%slim" % which))
|
|
981
|
+
try:
|
|
982
|
+
lo, hi = float(limits[0]), float(limits[1])
|
|
983
|
+
except (TypeError, ValueError, IndexError):
|
|
984
|
+
return None, None
|
|
985
|
+
return (lo, hi) if lo <= hi else (hi, lo)
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def _line_coords(lay, item):
|
|
989
|
+
"""Reference lines are usually given as one coordinate: x=12 is a vertical line."""
|
|
990
|
+
if all(k in item for k in ("x1", "y1", "x2", "y2")):
|
|
991
|
+
return [item[k] for k in ("x1", "y1", "x2", "y2")]
|
|
992
|
+
has_x, has_y = "x" in item, "y" in item
|
|
993
|
+
if has_x and not has_y:
|
|
994
|
+
lo, hi = _axis_span(lay, "y")
|
|
995
|
+
if lo is None or hi is None:
|
|
996
|
+
raise OriginError("bad_arguments", "读不到 Y 轴范围,竖参考线请给 x1/y1/x2/y2")
|
|
997
|
+
return [item["x"], lo, item["x"], hi]
|
|
998
|
+
if has_y and not has_x:
|
|
999
|
+
lo, hi = _axis_span(lay, "x")
|
|
1000
|
+
if lo is None or hi is None:
|
|
1001
|
+
raise OriginError("bad_arguments", "读不到 X 轴范围,横参考线请给 x1/y1/x2/y2")
|
|
1002
|
+
return [lo, item["y"], hi, item["y"]]
|
|
1003
|
+
raise OriginError("bad_arguments",
|
|
1004
|
+
"lines 的每一项要有 x 或 y(单坐标=参考线),或 x1/y1/x2/y2(线段):收到 %r" % (item,),
|
|
1005
|
+
['竖线 {"x": 12},横线 {"y": 50},任意线段 {"x1":0,"y1":0,"x2":1,"y2":2}'])
|
|
1006
|
+
|
|
1007
|
+
|
|
721
1008
|
def annotate(graph, labels=(), lines=(), layer=0):
|
|
722
1009
|
"""Text labels and reference lines in data coordinates."""
|
|
723
1010
|
if not labels and not lines:
|
|
724
1011
|
raise OriginError("bad_arguments", "labels 和 lines 至少给一个")
|
|
1012
|
+
for name, items in (("labels", labels or ()), ("lines", lines or ())):
|
|
1013
|
+
if isinstance(items, str) or not isinstance(items, (list, tuple)):
|
|
1014
|
+
raise OriginError("bad_arguments", "%s 要是列表,收到 %r" % (name, type(items).__name__))
|
|
1015
|
+
for item in items:
|
|
1016
|
+
if not isinstance(item, dict):
|
|
1017
|
+
raise OriginError("bad_arguments",
|
|
1018
|
+
"%s 的每一项要是对象,如 {\"text\":..,\"x\":..,\"y\":..};收到 %r"
|
|
1019
|
+
% (name, item),
|
|
1020
|
+
["labels: [{\"text\":\"峰值\",\"x\":1.2,\"y\":30}]"])
|
|
725
1021
|
|
|
726
1022
|
def run():
|
|
727
1023
|
gr = _graph_of(origin(), graph)
|
|
728
1024
|
if gr is None:
|
|
729
1025
|
raise OriginError("graph_not_found", "图页句柄无效:%s" % graph)
|
|
730
|
-
|
|
1026
|
+
try:
|
|
1027
|
+
lay = gr[layer]
|
|
1028
|
+
except Exception:
|
|
1029
|
+
raise OriginError("bad_layer", "图页 %s 没有第 %d 层(共 %d 层)"
|
|
1030
|
+
% (_safe(lambda: gr.name, "?"), layer, _layer_count(gr)),
|
|
1031
|
+
["先用 origin_inspect 看层数"])
|
|
731
1032
|
done = []
|
|
732
1033
|
for item in labels or ():
|
|
733
1034
|
text = str(item.get("text", ""))
|
|
@@ -735,8 +1036,8 @@ def annotate(graph, labels=(), lines=(), layer=0):
|
|
|
735
1036
|
lab = _safe(lambda: lay.add_label(text, x, y))
|
|
736
1037
|
done.append({"kind": "label", "text": text, "at": [x, y], "ok": lab is not None})
|
|
737
1038
|
for item in lines or ():
|
|
738
|
-
coords =
|
|
739
|
-
ln = _safe(lambda: lay.add_line(*
|
|
1039
|
+
coords = _line_coords(lay, item)
|
|
1040
|
+
ln = _safe(lambda c=coords: lay.add_line(*c))
|
|
740
1041
|
done.append({"kind": "line", "coords": coords, "ok": ln is not None,
|
|
741
1042
|
"width": _safe(lambda: ln.width) if ln is not None else None,
|
|
742
1043
|
"type": _safe(lambda: ln.type) if ln is not None else None})
|
|
@@ -914,7 +1215,8 @@ def view(graph, width=900):
|
|
|
914
1215
|
return call(run)
|
|
915
1216
|
|
|
916
1217
|
|
|
917
|
-
LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x"
|
|
1218
|
+
LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x",
|
|
1219
|
+
"close;", "close ", "system ", "shell ", "run.section", "run.open", "fopen")
|
|
918
1220
|
|
|
919
1221
|
|
|
920
1222
|
def labtalk(script, readback=(), numeric=()):
|
|
@@ -943,7 +1245,15 @@ def labtalk(script, readback=(), numeric=()):
|
|
|
943
1245
|
strs[str(name)] = _safe(lambda n=name: op.get_lt_str(n), None)
|
|
944
1246
|
nums = {}
|
|
945
1247
|
for name in numeric or ():
|
|
946
|
-
|
|
1248
|
+
def read(n=name):
|
|
1249
|
+
# lt_float returns nan for integer-typed LabTalk variables
|
|
1250
|
+
# (layer.showlegend, Legend.Show), which would make a successful
|
|
1251
|
+
# change look unverifiable.
|
|
1252
|
+
value = op.lt_float(n)
|
|
1253
|
+
if isinstance(value, float) and math.isnan(value):
|
|
1254
|
+
return op.lt_int(n)
|
|
1255
|
+
return value
|
|
1256
|
+
nums[str(name)] = _safe(read, None)
|
|
947
1257
|
return sanitize({"script": str(script), "returned": bool(ret),
|
|
948
1258
|
"readback": strs, "numeric": nums,
|
|
949
1259
|
"proof_level": "verified" if (ret and (strs or nums)) else
|
|
@@ -994,7 +1304,14 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
994
1304
|
ws = _resolve("ws", worksheet)
|
|
995
1305
|
if ws is None:
|
|
996
1306
|
raise OriginError("worksheet_not_found", "工作表句柄无效:%s" % worksheet)
|
|
997
|
-
kind = str(kind).lower()
|
|
1307
|
+
kind = str(kind or "").lower()
|
|
1308
|
+
switched = False
|
|
1309
|
+
if func and kind in ("", "linear"):
|
|
1310
|
+
# Naming a fitting function means a nonlinear fit: running the linear one
|
|
1311
|
+
# anyway returned slope/intercept for a caller who asked for Gauss.
|
|
1312
|
+
kind, switched = "nlfitsing", True
|
|
1313
|
+
if not kind:
|
|
1314
|
+
kind = "linear"
|
|
998
1315
|
if kind in FIT_PRESETS:
|
|
999
1316
|
func, kind = FIT_PRESETS[kind], "nlfitsing"
|
|
1000
1317
|
if kind not in ("linear", "nlfitsing", "nlfit"):
|
|
@@ -1027,9 +1344,11 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1027
1344
|
try:
|
|
1028
1345
|
nl = op.NLFit(func)
|
|
1029
1346
|
except Exception as exc:
|
|
1030
|
-
raise OriginError("fit_function_unavailable",
|
|
1031
|
-
|
|
1032
|
-
|
|
1347
|
+
raise OriginError("fit_function_unavailable",
|
|
1348
|
+
"函数 %r 不是 Origin 里已定义的拟合函数:%s" % (func, exc),
|
|
1349
|
+
["func 只能是 Origin 内置函数名,自定义表达式(如 a*exp(-b*x)+c)不支持",
|
|
1350
|
+
"本机实测可用的预设:%s" % ", ".join(sorted(set(FIT_PRESETS))),
|
|
1351
|
+
"NLFit 需要 OriginPro 授权"])
|
|
1033
1352
|
nl.set_data(ws, xi, yi)
|
|
1034
1353
|
notes = []
|
|
1035
1354
|
for name, val in (starts or {}).items():
|
|
@@ -1053,6 +1372,8 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1053
1372
|
return sanitize({"kind": "nlfitsing", "func": func, "r_squared": r2,
|
|
1054
1373
|
"fit_status": res.get("fitstatus") if isinstance(res, dict) else None,
|
|
1055
1374
|
"constraints": notes, "trimmed": not full,
|
|
1375
|
+
**({"note": "你给了 func,所以按非线性拟合执行(kind 省略时默认 linear)"}
|
|
1376
|
+
if switched else {}),
|
|
1056
1377
|
**({"result": body} if full else {"fit": body})})
|
|
1057
1378
|
|
|
1058
1379
|
return call(run, timeout=300)
|
|
@@ -1060,8 +1381,11 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1060
1381
|
|
|
1061
1382
|
def export(graph, path="", fmt="png", width=1600):
|
|
1062
1383
|
fmt = str(fmt).lstrip(".").lower()
|
|
1063
|
-
|
|
1064
|
-
|
|
1384
|
+
# Origin's export filter only recognises the short names: asking for a.tiff
|
|
1385
|
+
# leaves no file behind, which reads as a failed export.
|
|
1386
|
+
fmt = {"tiff": "tif", "jpeg": "jpg"}.get(fmt, fmt)
|
|
1387
|
+
if fmt not in ("png", "tif", "jpg", "svg", "pdf", "emf"):
|
|
1388
|
+
raise OriginError("bad_format", "fmt 只支持 png/tif/jpg/svg/pdf/emf,收到 %s" % fmt)
|
|
1065
1389
|
|
|
1066
1390
|
def run():
|
|
1067
1391
|
op = origin()
|
|
@@ -1070,6 +1394,8 @@ def export(graph, path="", fmt="png", width=1600):
|
|
|
1070
1394
|
raise OriginError("graph_not_found", "找不到图页:%s(项目里可能还没有图)" % (graph or ""))
|
|
1071
1395
|
name = _safe(lambda: page.name, "Graph1")
|
|
1072
1396
|
target_path = os.path.abspath(path or os.path.join(os.getcwd(), "%s.%s" % (name, fmt)))
|
|
1397
|
+
if os.path.splitext(target_path)[1].lower().lstrip(".") != fmt:
|
|
1398
|
+
target_path = os.path.splitext(target_path)[0] + "." + fmt
|
|
1073
1399
|
os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
|
|
1074
1400
|
_safe(lambda: page.save_fig(target_path))
|
|
1075
1401
|
if not os.path.isfile(target_path):
|
|
@@ -1119,13 +1445,18 @@ def _save_project(path=""):
|
|
|
1119
1445
|
raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
|
|
1120
1446
|
["该文件可能正被 Origin 占用(工程绑定后会加锁)",
|
|
1121
1447
|
"换一个不冲突的文件名,或省略 path 用自动时间戳命名"])
|
|
1122
|
-
info = _verify_file(target, must_be_image=False)
|
|
1448
|
+
info = _verify_file(target, must_be_image=False, min_bytes=256)
|
|
1123
1449
|
if not info["ok"]:
|
|
1124
|
-
raise OriginError("save_suspect", "
|
|
1450
|
+
raise OriginError("save_suspect", "工程文件异常:%s" % info,
|
|
1125
1451
|
["项目里得先有工作表或图页再保存"])
|
|
1452
|
+
pages_now = len(list(_safe(lambda: op.pages(), []) or []))
|
|
1126
1453
|
_project_file = target
|
|
1127
|
-
out = dict(info, editable=True, saved=bool(ok),
|
|
1454
|
+
out = dict(info, editable=True, saved=bool(ok), pages=pages_now,
|
|
1128
1455
|
note="可在 Origin 里继续编辑的 .opju,不是图片快照")
|
|
1456
|
+
if not pages_now:
|
|
1457
|
+
# An empty project really is a few hundred bytes: calling that
|
|
1458
|
+
# "文件过小" was a false alarm on origin_exit(save_to=...).
|
|
1459
|
+
out["warnings"] = ["工程里一个页面都没有,存下来的 .opju 打开是空的"]
|
|
1129
1460
|
if same_file and not ok:
|
|
1130
1461
|
out["unchanged"] = True
|
|
1131
1462
|
out["note"] = "项目已绑定在该文件上且无待写变更,文件保持原样"
|
|
@@ -1163,6 +1494,11 @@ def inspect(graph=""):
|
|
|
1163
1494
|
"lname": _safe(lambda p=p: p.lname, "")} for p in op.pages()]
|
|
1164
1495
|
out = {"pages": pages, "handles": sorted(_handles)}
|
|
1165
1496
|
page = _graph_of(op, graph)
|
|
1497
|
+
if page is None and str(graph or "").strip():
|
|
1498
|
+
# A handle that no longer exists must not degrade into "here are the
|
|
1499
|
+
# other pages", which reads as a successful inspection of nothing.
|
|
1500
|
+
raise OriginError("graph_not_found", "图页句柄无效或已关闭:%s" % graph,
|
|
1501
|
+
["origin_inspect 不带 graph 先看页面清单", "或重新 origin_plot 拿句柄"])
|
|
1166
1502
|
if page is not None:
|
|
1167
1503
|
layers = []
|
|
1168
1504
|
i = 0
|
|
@@ -1190,7 +1526,7 @@ def exit_origin(save_to=""):
|
|
|
1190
1526
|
_handles.clear()
|
|
1191
1527
|
_names.clear()
|
|
1192
1528
|
_project_file = ""
|
|
1193
|
-
|
|
1529
|
+
_reset_connection()
|
|
1194
1530
|
return sanitize({"exited": True, "saved": saved})
|
|
1195
1531
|
|
|
1196
1532
|
return call(run)
|
|
@@ -1435,10 +1771,13 @@ def _catalog():
|
|
|
1435
1771
|
|
|
1436
1772
|
def chart_presets(category=""):
|
|
1437
1773
|
cat = _catalog()
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1774
|
+
known = sorted({t["category"] for t in cat.TEMPLATES})
|
|
1775
|
+
want = str(category or "").strip().lower()
|
|
1776
|
+
if want and want not in {c.lower() for c in known}:
|
|
1777
|
+
raise OriginError("bad_category", "没有这个图表预设分类:%r" % category,
|
|
1778
|
+
["可用分类:%s" % ", ".join(known)])
|
|
1779
|
+
items = cat.list_templates(want or None)
|
|
1780
|
+
return sanitize({"n_templates": len(items), "categories": known, "templates": items})
|
|
1442
1781
|
|
|
1443
1782
|
|
|
1444
1783
|
def chart_preset(name):
|
|
@@ -1457,7 +1796,26 @@ def chart_check(name, data=None, columns=None):
|
|
|
1457
1796
|
if tpl is None:
|
|
1458
1797
|
raise OriginError("preset_not_found", "没有这个图表预设:%r" % name)
|
|
1459
1798
|
if data and not columns:
|
|
1460
|
-
|
|
1799
|
+
# `data` is documented as a file path, but models hand us dicts and lists
|
|
1800
|
+
# here constantly; accept those as column sources instead of dying with a
|
|
1801
|
+
# raw TypeError, and say so when the value is neither.
|
|
1802
|
+
if isinstance(data, dict):
|
|
1803
|
+
columns = list(data)
|
|
1804
|
+
elif isinstance(data, (list, tuple)):
|
|
1805
|
+
columns = [str(c) for c in data]
|
|
1806
|
+
elif isinstance(data, str):
|
|
1807
|
+
try:
|
|
1808
|
+
parsed = read_file(data)
|
|
1809
|
+
except OriginError:
|
|
1810
|
+
raise
|
|
1811
|
+
except Exception as exc:
|
|
1812
|
+
raise OriginError("bad_arguments",
|
|
1813
|
+
"data 要的是数据文件路径,或者列名列表/字典:%s" % exc,
|
|
1814
|
+
["origin_chart_check 传 columns=[...] 更直接"]) from exc
|
|
1815
|
+
columns = [c["name"] for c in parsed["columns"]]
|
|
1816
|
+
else:
|
|
1817
|
+
raise OriginError("bad_arguments", "data 只能是文件路径字符串、列名字典或列表",
|
|
1818
|
+
["origin_chart_check 传 columns=[...] 更直接"])
|
|
1461
1819
|
detected = list(columns or [])
|
|
1462
1820
|
issues = []
|
|
1463
1821
|
for req in tpl["required_columns"] or ():
|
|
@@ -1484,6 +1842,15 @@ def chart_render(name, source="", columns=None, x=1, y=2, output_dir="",
|
|
|
1484
1842
|
throw away whatever project the user has open in Origin.
|
|
1485
1843
|
"""
|
|
1486
1844
|
checked = chart_check(name, data=source) if not columns else chart_check(name, columns=columns)
|
|
1845
|
+
if checked["issues"]:
|
|
1846
|
+
# Drawing a preset over data that does not carry its columns produced a
|
|
1847
|
+
# chart with the wrong axis labels and no hint anything was off.
|
|
1848
|
+
raise OriginError("preset_columns_mismatch",
|
|
1849
|
+
"%s 预设的列对不上:%s(识别到的列:%s)"
|
|
1850
|
+
% (name, ";".join(checked["issues"]), checked["columns_detected"] or "无"),
|
|
1851
|
+
["把数据列名改成预设要求的名字(见 required_columns)",
|
|
1852
|
+
"换一个匹配数据的预设:origin_chart_presets",
|
|
1853
|
+
"不套预设只是画图:用 origin_figure"])
|
|
1487
1854
|
preset = chart_preset(name)
|
|
1488
1855
|
kind = str(plot_type or preset.get("default_plot_kind") or "line").lower()
|
|
1489
1856
|
if kind not in PLOT_CODES:
|