dsh-origin-bridge 0.4.1 → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +299 -234
- package/asciiio.py +82 -12
- package/docs/preview.png +0 -0
- package/edge_checks.py +10 -0
- package/originlab.py +475 -50
- package/package.json +76 -74
- package/scripts/make_preview.py +77 -0
- package/server.py +612 -577
- package/smoke_test.py +37 -7
- 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,17 +31,23 @@ 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
|
-
GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley",
|
|
50
|
+
GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley",
|
|
42
51
|
"heat_map", "cmap", "mesh", "contline", "contgray", "3d",
|
|
43
52
|
"glwireface", "pie", "box", "histdist", "polar", "ternary",
|
|
44
53
|
"vector", "errbar")
|
|
@@ -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)
|
|
@@ -418,7 +661,9 @@ def write_columns(columns, book_name=""):
|
|
|
418
661
|
ws = op.new_sheet(lname=book_name or "")
|
|
419
662
|
for i, (name, values) in enumerate(columns.items()):
|
|
420
663
|
try:
|
|
421
|
-
|
|
664
|
+
# None is a documented missing value, not a non-numeric cell: it has
|
|
665
|
+
# to reach Origin as an empty cell instead of killing the write.
|
|
666
|
+
nums = [None if v is None else float(v) for v in values]
|
|
422
667
|
except (TypeError, ValueError) as exc:
|
|
423
668
|
raise OriginError("bad_arguments", "列 %r 里有非数值项:%s" % (name, exc),
|
|
424
669
|
["数值列只接受数字或 null;文本请放 Notes 页,或先把分类编码成数字"])
|
|
@@ -433,6 +678,10 @@ def write_block(headers, rows, book_name=""):
|
|
|
433
678
|
which is why a 40-column sheet is slow; from_list2 writes the block at once."""
|
|
434
679
|
if not rows or not isinstance(rows, (list, tuple)):
|
|
435
680
|
raise OriginError("bad_arguments", "rows 必须是非空的二维列表")
|
|
681
|
+
if any(not isinstance(r, (list, tuple)) for r in rows):
|
|
682
|
+
raise OriginError("bad_arguments",
|
|
683
|
+
"rows 得是二维的(每行一个列表),收到 %r" % (rows[0],),
|
|
684
|
+
[['[["x","y"],[1,2],[3,4]] 这种形状']])
|
|
436
685
|
ncol = max(len(r) for r in rows)
|
|
437
686
|
if headers and len(headers) != ncol:
|
|
438
687
|
raise OriginError("bad_arguments", "headers 有 %d 项,但 rows 最宽 %d 列" % (len(headers), ncol))
|
|
@@ -469,6 +718,8 @@ def column_formula(worksheet, col, formula, label="", units=""):
|
|
|
469
718
|
|
|
470
719
|
def run():
|
|
471
720
|
idx = _col_index(ws, col) if _col_exists(ws, col) else _extend_to(ws, col)
|
|
721
|
+
prev_formula = _safe(lambda: str(ws.get_formula(idx)), "")
|
|
722
|
+
before = [v for v in _safe(lambda: list(ws.to_list(idx)), []) if v is not None]
|
|
472
723
|
# originpro 1.1.15 documents set_formula's column as 1-offset, but it is
|
|
473
724
|
# 0-based here: passing idx+1 grabs a non-existent column and the library
|
|
474
725
|
# dies inside SetStrProp on a None object.
|
|
@@ -485,13 +736,56 @@ def column_formula(worksheet, col, formula, label="", units=""):
|
|
|
485
736
|
["LabTalk 里底 10 对数是 log() 不是 log10()",
|
|
486
737
|
"跨列引用要先绑定 range,不能内联 [Book]Sheet!col(N)",
|
|
487
738
|
"列号用 Col(1) 这种当前表写法试试"])
|
|
739
|
+
warns = []
|
|
740
|
+
proof = "verified"
|
|
741
|
+
if before and before == vals and prev_formula.strip() != str(formula).strip():
|
|
742
|
+
# A bogus LabTalk formula on a column that already had data leaves the
|
|
743
|
+
# old numbers in place, so "computed_rows > 0" alone cannot prove much.
|
|
744
|
+
warns.append("改了公式但这一列的数值一个都没变(%d 行原样保留):Origin 可能没有执行它"
|
|
745
|
+
% len(vals))
|
|
746
|
+
proof = "unverified"
|
|
488
747
|
return sanitize({"column": idx + 1, "formula": str(formula),
|
|
489
748
|
"computed_rows": len(vals), "first": vals[0], "last": vals[-1],
|
|
490
|
-
"columns": info["columns"], "
|
|
749
|
+
"columns": info["columns"], "warnings": warns, "proof_level": proof,
|
|
750
|
+
**({} if proof == "verified"
|
|
751
|
+
else {"next_actions": ["origin_inspect 看这一列现在到底是什么值"]})})
|
|
491
752
|
|
|
492
753
|
return call(run)
|
|
493
754
|
|
|
494
755
|
|
|
756
|
+
def _decade_span(values):
|
|
757
|
+
"""How many orders of magnitude the positive values cover (0 if not measurable)."""
|
|
758
|
+
pos = [v for v in (values or []) if isinstance(v, (int, float)) and v > 0]
|
|
759
|
+
if len(pos) < 2:
|
|
760
|
+
return 0.0
|
|
761
|
+
lo, hi = min(pos), max(pos)
|
|
762
|
+
return math.log10(hi / lo) if hi > lo else 0.0
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _axis_review(lay, xvals):
|
|
766
|
+
"""Flag a plot whose X axis is linear across several orders of magnitude.
|
|
767
|
+
|
|
768
|
+
A Bode/EIS sweep spans 1e5..1e-2 Hz: on a linear axis every point piles up
|
|
769
|
+
against the y axis and the exported PNG is unreadable, yet nothing about the
|
|
770
|
+
call failed. The bridge cannot decide for the user whether they want a log
|
|
771
|
+
axis, a zoomed range or a Nyquist view, so it says so and hands the choice
|
|
772
|
+
back.
|
|
773
|
+
"""
|
|
774
|
+
scale = _safe(lambda: lay.xscale)
|
|
775
|
+
decades = _decade_span(xvals)
|
|
776
|
+
if scale not in (None, 1) or decades < 3:
|
|
777
|
+
return None
|
|
778
|
+
return ("X 轴是线性刻度但数据跨了 %.1f 个数量级(%g..%g),点会全挤在最左边,"
|
|
779
|
+
"图基本没法看。这不算出错,但要让用户决定怎么处理,别自己改轴"
|
|
780
|
+
% (decades, min(v for v in xvals if v > 0), max(v for v in xvals if v > 0)))
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
REVIEW_OPTIONS = ["origin_style 把 x_scale 设成 log10(对数横轴,Bode 图常用)",
|
|
784
|
+
"只画其中一段:origin_style 用 xlim 限定范围",
|
|
785
|
+
"换成别的 X 轴:如阻抗用 Z' 对 Z'' 画 Nyquist 图",
|
|
786
|
+
"保持现状(用户就是想看线性轴)"]
|
|
787
|
+
|
|
788
|
+
|
|
495
789
|
def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
|
|
496
790
|
graph="", layer=0, template=""):
|
|
497
791
|
"""Draw one or more series.
|
|
@@ -589,6 +883,10 @@ def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
|
|
|
589
883
|
raise OriginError("plot_incomplete",
|
|
590
884
|
"要求 %d 条曲线,层里只有 %d 条" % (len(yis), drawn),
|
|
591
885
|
["origin_inspect 看该层,或改用 origin_view 目视确认"])
|
|
886
|
+
review = _axis_review(lay, xvals)
|
|
887
|
+
if review:
|
|
888
|
+
span["warnings"] = [review]
|
|
889
|
+
span["next_actions"] = list(REVIEW_OPTIONS)
|
|
592
890
|
return sanitize({"graph": _bind("gr", gr, gr.name), "plot_type": plot_type,
|
|
593
891
|
"channel": channel, "template": tpl,
|
|
594
892
|
"source_sheet": _safe(lambda: ws.name, ""),
|
|
@@ -647,13 +945,15 @@ def style(graph, layer=0, series=None, x_title="", y_title="", x_scale="", y_sca
|
|
|
647
945
|
if want is None:
|
|
648
946
|
raise OriginError("bad_scale", "未知刻度类型:%r" % x_scale,
|
|
649
947
|
["可选 %s" % sorted(SCALE_TYPES)])
|
|
650
|
-
attempt("x_scale", lambda: setattr(lay, "xscale",
|
|
948
|
+
attempt("x_scale", lambda: setattr(lay, "xscale", SCALE_NAMES[want]),
|
|
949
|
+
lambda: lay.xscale, want)
|
|
651
950
|
if y_scale:
|
|
652
951
|
want = SCALE_TYPES.get(str(y_scale).lower())
|
|
653
952
|
if want is None:
|
|
654
953
|
raise OriginError("bad_scale", "未知刻度类型:%r" % y_scale,
|
|
655
954
|
["可选 %s" % sorted(SCALE_TYPES)])
|
|
656
|
-
attempt("y_scale", lambda: setattr(lay, "yscale",
|
|
955
|
+
attempt("y_scale", lambda: setattr(lay, "yscale", SCALE_NAMES[want]),
|
|
956
|
+
lambda: lay.yscale, want)
|
|
657
957
|
if xlim:
|
|
658
958
|
attempt("xlim", lambda: lay.set_xlim(*_pair(xlim)), lambda: lay.xlim)
|
|
659
959
|
if ylim:
|
|
@@ -718,16 +1018,60 @@ def _pair(v):
|
|
|
718
1018
|
return vals[:3]
|
|
719
1019
|
|
|
720
1020
|
|
|
1021
|
+
def _axis_span(lay, which):
|
|
1022
|
+
"""(begin, end) of an axis, via the layer's xlim/ylim — axis.from_ is not readable here."""
|
|
1023
|
+
limits = _safe(lambda: getattr(lay, "%slim" % which))
|
|
1024
|
+
try:
|
|
1025
|
+
lo, hi = float(limits[0]), float(limits[1])
|
|
1026
|
+
except (TypeError, ValueError, IndexError):
|
|
1027
|
+
return None, None
|
|
1028
|
+
return (lo, hi) if lo <= hi else (hi, lo)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _line_coords(lay, item):
|
|
1032
|
+
"""Reference lines are usually given as one coordinate: x=12 is a vertical line."""
|
|
1033
|
+
if all(k in item for k in ("x1", "y1", "x2", "y2")):
|
|
1034
|
+
return [item[k] for k in ("x1", "y1", "x2", "y2")]
|
|
1035
|
+
has_x, has_y = "x" in item, "y" in item
|
|
1036
|
+
if has_x and not has_y:
|
|
1037
|
+
lo, hi = _axis_span(lay, "y")
|
|
1038
|
+
if lo is None or hi is None:
|
|
1039
|
+
raise OriginError("bad_arguments", "读不到 Y 轴范围,竖参考线请给 x1/y1/x2/y2")
|
|
1040
|
+
return [item["x"], lo, item["x"], hi]
|
|
1041
|
+
if has_y and not has_x:
|
|
1042
|
+
lo, hi = _axis_span(lay, "x")
|
|
1043
|
+
if lo is None or hi is None:
|
|
1044
|
+
raise OriginError("bad_arguments", "读不到 X 轴范围,横参考线请给 x1/y1/x2/y2")
|
|
1045
|
+
return [lo, item["y"], hi, item["y"]]
|
|
1046
|
+
raise OriginError("bad_arguments",
|
|
1047
|
+
"lines 的每一项要有 x 或 y(单坐标=参考线),或 x1/y1/x2/y2(线段):收到 %r" % (item,),
|
|
1048
|
+
['竖线 {"x": 12},横线 {"y": 50},任意线段 {"x1":0,"y1":0,"x2":1,"y2":2}'])
|
|
1049
|
+
|
|
1050
|
+
|
|
721
1051
|
def annotate(graph, labels=(), lines=(), layer=0):
|
|
722
1052
|
"""Text labels and reference lines in data coordinates."""
|
|
723
1053
|
if not labels and not lines:
|
|
724
1054
|
raise OriginError("bad_arguments", "labels 和 lines 至少给一个")
|
|
1055
|
+
for name, items in (("labels", labels or ()), ("lines", lines or ())):
|
|
1056
|
+
if isinstance(items, str) or not isinstance(items, (list, tuple)):
|
|
1057
|
+
raise OriginError("bad_arguments", "%s 要是列表,收到 %r" % (name, type(items).__name__))
|
|
1058
|
+
for item in items:
|
|
1059
|
+
if not isinstance(item, dict):
|
|
1060
|
+
raise OriginError("bad_arguments",
|
|
1061
|
+
"%s 的每一项要是对象,如 {\"text\":..,\"x\":..,\"y\":..};收到 %r"
|
|
1062
|
+
% (name, item),
|
|
1063
|
+
["labels: [{\"text\":\"峰值\",\"x\":1.2,\"y\":30}]"])
|
|
725
1064
|
|
|
726
1065
|
def run():
|
|
727
1066
|
gr = _graph_of(origin(), graph)
|
|
728
1067
|
if gr is None:
|
|
729
1068
|
raise OriginError("graph_not_found", "图页句柄无效:%s" % graph)
|
|
730
|
-
|
|
1069
|
+
try:
|
|
1070
|
+
lay = gr[layer]
|
|
1071
|
+
except Exception:
|
|
1072
|
+
raise OriginError("bad_layer", "图页 %s 没有第 %d 层(共 %d 层)"
|
|
1073
|
+
% (_safe(lambda: gr.name, "?"), layer, _layer_count(gr)),
|
|
1074
|
+
["先用 origin_inspect 看层数"])
|
|
731
1075
|
done = []
|
|
732
1076
|
for item in labels or ():
|
|
733
1077
|
text = str(item.get("text", ""))
|
|
@@ -735,8 +1079,8 @@ def annotate(graph, labels=(), lines=(), layer=0):
|
|
|
735
1079
|
lab = _safe(lambda: lay.add_label(text, x, y))
|
|
736
1080
|
done.append({"kind": "label", "text": text, "at": [x, y], "ok": lab is not None})
|
|
737
1081
|
for item in lines or ():
|
|
738
|
-
coords =
|
|
739
|
-
ln = _safe(lambda: lay.add_line(*
|
|
1082
|
+
coords = _line_coords(lay, item)
|
|
1083
|
+
ln = _safe(lambda c=coords: lay.add_line(*c))
|
|
740
1084
|
done.append({"kind": "line", "coords": coords, "ok": ln is not None,
|
|
741
1085
|
"width": _safe(lambda: ln.width) if ln is not None else None,
|
|
742
1086
|
"type": _safe(lambda: ln.type) if ln is not None else None})
|
|
@@ -839,6 +1183,8 @@ def add_layer(graph, layer_type="right"):
|
|
|
839
1183
|
right-Y layer reads back as 'RightY'. Filling it is left to origin_plot with
|
|
840
1184
|
layer=<index>, which is the only path measured to draw into a chosen layer.
|
|
841
1185
|
"""
|
|
1186
|
+
if isinstance(layer_type, str) and layer_type.strip().isdigit():
|
|
1187
|
+
layer_type = int(layer_type.strip()) # "2" from a model is still the code 2
|
|
842
1188
|
if isinstance(layer_type, str) and layer_type.lower() in LAYER_TYPES:
|
|
843
1189
|
code = LAYER_TYPES[layer_type.lower()]
|
|
844
1190
|
elif isinstance(layer_type, int) and layer_type in LAYER_TYPES.values():
|
|
@@ -914,7 +1260,8 @@ def view(graph, width=900):
|
|
|
914
1260
|
return call(run)
|
|
915
1261
|
|
|
916
1262
|
|
|
917
|
-
LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x"
|
|
1263
|
+
LABTALK_DENY = ("doc -s", "doc -c", "saveas", "quit", "exitloop", "type -m", "openb", "run.x",
|
|
1264
|
+
"close;", "close ", "system ", "shell ", "run.section", "run.open", "fopen")
|
|
918
1265
|
|
|
919
1266
|
|
|
920
1267
|
def labtalk(script, readback=(), numeric=()):
|
|
@@ -943,7 +1290,15 @@ def labtalk(script, readback=(), numeric=()):
|
|
|
943
1290
|
strs[str(name)] = _safe(lambda n=name: op.get_lt_str(n), None)
|
|
944
1291
|
nums = {}
|
|
945
1292
|
for name in numeric or ():
|
|
946
|
-
|
|
1293
|
+
def read(n=name):
|
|
1294
|
+
# lt_float returns nan for integer-typed LabTalk variables
|
|
1295
|
+
# (layer.showlegend, Legend.Show), which would make a successful
|
|
1296
|
+
# change look unverifiable.
|
|
1297
|
+
value = op.lt_float(n)
|
|
1298
|
+
if isinstance(value, float) and math.isnan(value):
|
|
1299
|
+
return op.lt_int(n)
|
|
1300
|
+
return value
|
|
1301
|
+
nums[str(name)] = _safe(read, None)
|
|
947
1302
|
return sanitize({"script": str(script), "returned": bool(ret),
|
|
948
1303
|
"readback": strs, "numeric": nums,
|
|
949
1304
|
"proof_level": "verified" if (ret and (strs or nums)) else
|
|
@@ -994,7 +1349,14 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
994
1349
|
ws = _resolve("ws", worksheet)
|
|
995
1350
|
if ws is None:
|
|
996
1351
|
raise OriginError("worksheet_not_found", "工作表句柄无效:%s" % worksheet)
|
|
997
|
-
kind = str(kind).lower()
|
|
1352
|
+
kind = str(kind or "").lower()
|
|
1353
|
+
switched = False
|
|
1354
|
+
if func and kind in ("", "linear"):
|
|
1355
|
+
# Naming a fitting function means a nonlinear fit: running the linear one
|
|
1356
|
+
# anyway returned slope/intercept for a caller who asked for Gauss.
|
|
1357
|
+
kind, switched = "nlfitsing", True
|
|
1358
|
+
if not kind:
|
|
1359
|
+
kind = "linear"
|
|
998
1360
|
if kind in FIT_PRESETS:
|
|
999
1361
|
func, kind = FIT_PRESETS[kind], "nlfitsing"
|
|
1000
1362
|
if kind not in ("linear", "nlfitsing", "nlfit"):
|
|
@@ -1027,9 +1389,11 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1027
1389
|
try:
|
|
1028
1390
|
nl = op.NLFit(func)
|
|
1029
1391
|
except Exception as exc:
|
|
1030
|
-
raise OriginError("fit_function_unavailable",
|
|
1031
|
-
|
|
1032
|
-
|
|
1392
|
+
raise OriginError("fit_function_unavailable",
|
|
1393
|
+
"函数 %r 不是 Origin 里已定义的拟合函数:%s" % (func, exc),
|
|
1394
|
+
["func 只能是 Origin 内置函数名,自定义表达式(如 a*exp(-b*x)+c)不支持",
|
|
1395
|
+
"本机实测可用的预设:%s" % ", ".join(sorted(set(FIT_PRESETS))),
|
|
1396
|
+
"NLFit 需要 OriginPro 授权"])
|
|
1033
1397
|
nl.set_data(ws, xi, yi)
|
|
1034
1398
|
notes = []
|
|
1035
1399
|
for name, val in (starts or {}).items():
|
|
@@ -1053,6 +1417,8 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1053
1417
|
return sanitize({"kind": "nlfitsing", "func": func, "r_squared": r2,
|
|
1054
1418
|
"fit_status": res.get("fitstatus") if isinstance(res, dict) else None,
|
|
1055
1419
|
"constraints": notes, "trimmed": not full,
|
|
1420
|
+
**({"note": "你给了 func,所以按非线性拟合执行(kind 省略时默认 linear)"}
|
|
1421
|
+
if switched else {}),
|
|
1056
1422
|
**({"result": body} if full else {"fit": body})})
|
|
1057
1423
|
|
|
1058
1424
|
return call(run, timeout=300)
|
|
@@ -1060,8 +1426,11 @@ def fit(worksheet, x=1, y=2, kind="linear", func="", fixed=None, starts=None,
|
|
|
1060
1426
|
|
|
1061
1427
|
def export(graph, path="", fmt="png", width=1600):
|
|
1062
1428
|
fmt = str(fmt).lstrip(".").lower()
|
|
1063
|
-
|
|
1064
|
-
|
|
1429
|
+
# Origin's export filter only recognises the short names: asking for a.tiff
|
|
1430
|
+
# leaves no file behind, which reads as a failed export.
|
|
1431
|
+
fmt = {"tiff": "tif", "jpeg": "jpg"}.get(fmt, fmt)
|
|
1432
|
+
if fmt not in ("png", "tif", "jpg", "svg", "pdf", "emf"):
|
|
1433
|
+
raise OriginError("bad_format", "fmt 只支持 png/tif/jpg/svg/pdf/emf,收到 %s" % fmt)
|
|
1065
1434
|
|
|
1066
1435
|
def run():
|
|
1067
1436
|
op = origin()
|
|
@@ -1069,7 +1438,12 @@ def export(graph, path="", fmt="png", width=1600):
|
|
|
1069
1438
|
if page is None:
|
|
1070
1439
|
raise OriginError("graph_not_found", "找不到图页:%s(项目里可能还没有图)" % (graph or ""))
|
|
1071
1440
|
name = _safe(lambda: page.name, "Graph1")
|
|
1072
|
-
|
|
1441
|
+
# No path is not an invitation to write into the process CWD: under dsh
|
|
1442
|
+
# that is the plugin's node_modules folder. Origin's User Files wins.
|
|
1443
|
+
folder = _safe(lambda: op.path("u"), "") or os.getcwd()
|
|
1444
|
+
target_path = os.path.abspath(path or os.path.join(folder, "%s.%s" % (name, fmt)))
|
|
1445
|
+
if os.path.splitext(target_path)[1].lower().lstrip(".") != fmt:
|
|
1446
|
+
target_path = os.path.splitext(target_path)[0] + "." + fmt
|
|
1073
1447
|
os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
|
|
1074
1448
|
_safe(lambda: page.save_fig(target_path))
|
|
1075
1449
|
if not os.path.isfile(target_path):
|
|
@@ -1119,13 +1493,18 @@ def _save_project(path=""):
|
|
|
1119
1493
|
raise OriginError("save_failed", "保存工程失败:%s (ok=%s)" % (target, ok),
|
|
1120
1494
|
["该文件可能正被 Origin 占用(工程绑定后会加锁)",
|
|
1121
1495
|
"换一个不冲突的文件名,或省略 path 用自动时间戳命名"])
|
|
1122
|
-
info = _verify_file(target, must_be_image=False)
|
|
1496
|
+
info = _verify_file(target, must_be_image=False, min_bytes=256)
|
|
1123
1497
|
if not info["ok"]:
|
|
1124
|
-
raise OriginError("save_suspect", "
|
|
1498
|
+
raise OriginError("save_suspect", "工程文件异常:%s" % info,
|
|
1125
1499
|
["项目里得先有工作表或图页再保存"])
|
|
1500
|
+
pages_now = len(list(_safe(lambda: op.pages(), []) or []))
|
|
1126
1501
|
_project_file = target
|
|
1127
|
-
out = dict(info, editable=True, saved=bool(ok),
|
|
1502
|
+
out = dict(info, editable=True, saved=bool(ok), pages=pages_now,
|
|
1128
1503
|
note="可在 Origin 里继续编辑的 .opju,不是图片快照")
|
|
1504
|
+
if not pages_now:
|
|
1505
|
+
# An empty project really is a few hundred bytes: calling that
|
|
1506
|
+
# "文件过小" was a false alarm on origin_exit(save_to=...).
|
|
1507
|
+
out["warnings"] = ["工程里一个页面都没有,存下来的 .opju 打开是空的"]
|
|
1129
1508
|
if same_file and not ok:
|
|
1130
1509
|
out["unchanged"] = True
|
|
1131
1510
|
out["note"] = "项目已绑定在该文件上且无待写变更,文件保持原样"
|
|
@@ -1163,6 +1542,11 @@ def inspect(graph=""):
|
|
|
1163
1542
|
"lname": _safe(lambda p=p: p.lname, "")} for p in op.pages()]
|
|
1164
1543
|
out = {"pages": pages, "handles": sorted(_handles)}
|
|
1165
1544
|
page = _graph_of(op, graph)
|
|
1545
|
+
if page is None and str(graph or "").strip():
|
|
1546
|
+
# A handle that no longer exists must not degrade into "here are the
|
|
1547
|
+
# other pages", which reads as a successful inspection of nothing.
|
|
1548
|
+
raise OriginError("graph_not_found", "图页句柄无效或已关闭:%s" % graph,
|
|
1549
|
+
["origin_inspect 不带 graph 先看页面清单", "或重新 origin_plot 拿句柄"])
|
|
1166
1550
|
if page is not None:
|
|
1167
1551
|
layers = []
|
|
1168
1552
|
i = 0
|
|
@@ -1190,7 +1574,7 @@ def exit_origin(save_to=""):
|
|
|
1190
1574
|
_handles.clear()
|
|
1191
1575
|
_names.clear()
|
|
1192
1576
|
_project_file = ""
|
|
1193
|
-
|
|
1577
|
+
_reset_connection()
|
|
1194
1578
|
return sanitize({"exited": True, "saved": saved})
|
|
1195
1579
|
|
|
1196
1580
|
return call(run)
|
|
@@ -1221,6 +1605,9 @@ def figure(source="", columns=None, x=1, y=1, plot_type="line", title="",
|
|
|
1221
1605
|
p = plot(info["worksheet"], x=x, y=y, plot_type=plot_type, title=title, template=template)
|
|
1222
1606
|
steps.append({"step": "plot", "graph": p["graph_page"], "type": plot_type,
|
|
1223
1607
|
"channel": p.get("channel", ""), "plots": p.get("plots_in_layer")})
|
|
1608
|
+
warns = list(p.get("warnings") or [])
|
|
1609
|
+
if warns:
|
|
1610
|
+
steps.append({"step": "review", "why": str(warns[0])[:90]})
|
|
1224
1611
|
if x_title or y_title:
|
|
1225
1612
|
st = style(p["graph"], x_title=x_title, y_title=y_title)
|
|
1226
1613
|
steps.append({"step": "style", **st})
|
|
@@ -1232,6 +1619,11 @@ def figure(source="", columns=None, x=1, y=1, plot_type="line", title="",
|
|
|
1232
1619
|
steps.append({"step": "legend", "proof_level": lg.get("proof_level")})
|
|
1233
1620
|
|
|
1234
1621
|
out = {"data": info, "plot": p, "style": st, "legend": lg}
|
|
1622
|
+
if warns:
|
|
1623
|
+
# Top level, because a delivered-but-unreadable PNG is exactly the kind of
|
|
1624
|
+
# thing the model must raise with the user rather than quietly hand over.
|
|
1625
|
+
out["warnings"] = warns
|
|
1626
|
+
out["next_actions"] = list(p.get("next_actions") or []) + ["origin_view 目视确认后再决定"]
|
|
1235
1627
|
deliver = {}
|
|
1236
1628
|
if export_path or output_dir:
|
|
1237
1629
|
base = export_path or os.path.join(output_dir, "%s.%s" % (p["graph_page"], fmt))
|
|
@@ -1394,7 +1786,9 @@ def close_pages(kind="graph", names=None, dry_run=False):
|
|
|
1394
1786
|
targets, had = call(collect)
|
|
1395
1787
|
wanted = [_safe(lambda p=p: p.name, "?") for p in targets]
|
|
1396
1788
|
if not targets:
|
|
1397
|
-
|
|
1789
|
+
# Stable shape: a dry run always answers with would_close, empty or not.
|
|
1790
|
+
return sanitize({"closed": [], "asked": [], "would_close": [],
|
|
1791
|
+
"note": "没有匹配的页面"})
|
|
1398
1792
|
if dry_run:
|
|
1399
1793
|
return sanitize({"would_close": wanted, "dry_run": True})
|
|
1400
1794
|
errors = call(destroy, targets)
|
|
@@ -1435,10 +1829,13 @@ def _catalog():
|
|
|
1435
1829
|
|
|
1436
1830
|
def chart_presets(category=""):
|
|
1437
1831
|
cat = _catalog()
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1832
|
+
known = sorted({t["category"] for t in cat.TEMPLATES})
|
|
1833
|
+
want = str(category or "").strip().lower()
|
|
1834
|
+
if want and want not in {c.lower() for c in known}:
|
|
1835
|
+
raise OriginError("bad_category", "没有这个图表预设分类:%r" % category,
|
|
1836
|
+
["可用分类:%s" % ", ".join(known)])
|
|
1837
|
+
items = cat.list_templates(want or None)
|
|
1838
|
+
return sanitize({"n_templates": len(items), "categories": known, "templates": items})
|
|
1442
1839
|
|
|
1443
1840
|
|
|
1444
1841
|
def chart_preset(name):
|
|
@@ -1457,7 +1854,26 @@ def chart_check(name, data=None, columns=None):
|
|
|
1457
1854
|
if tpl is None:
|
|
1458
1855
|
raise OriginError("preset_not_found", "没有这个图表预设:%r" % name)
|
|
1459
1856
|
if data and not columns:
|
|
1460
|
-
|
|
1857
|
+
# `data` is documented as a file path, but models hand us dicts and lists
|
|
1858
|
+
# here constantly; accept those as column sources instead of dying with a
|
|
1859
|
+
# raw TypeError, and say so when the value is neither.
|
|
1860
|
+
if isinstance(data, dict):
|
|
1861
|
+
columns = list(data)
|
|
1862
|
+
elif isinstance(data, (list, tuple)):
|
|
1863
|
+
columns = [str(c) for c in data]
|
|
1864
|
+
elif isinstance(data, str):
|
|
1865
|
+
try:
|
|
1866
|
+
parsed = read_file(data)
|
|
1867
|
+
except OriginError:
|
|
1868
|
+
raise
|
|
1869
|
+
except Exception as exc:
|
|
1870
|
+
raise OriginError("bad_arguments",
|
|
1871
|
+
"data 要的是数据文件路径,或者列名列表/字典:%s" % exc,
|
|
1872
|
+
["origin_chart_check 传 columns=[...] 更直接"]) from exc
|
|
1873
|
+
columns = [c["name"] for c in parsed["columns"]]
|
|
1874
|
+
else:
|
|
1875
|
+
raise OriginError("bad_arguments", "data 只能是文件路径字符串、列名字典或列表",
|
|
1876
|
+
["origin_chart_check 传 columns=[...] 更直接"])
|
|
1461
1877
|
detected = list(columns or [])
|
|
1462
1878
|
issues = []
|
|
1463
1879
|
for req in tpl["required_columns"] or ():
|
|
@@ -1484,6 +1900,15 @@ def chart_render(name, source="", columns=None, x=1, y=2, output_dir="",
|
|
|
1484
1900
|
throw away whatever project the user has open in Origin.
|
|
1485
1901
|
"""
|
|
1486
1902
|
checked = chart_check(name, data=source) if not columns else chart_check(name, columns=columns)
|
|
1903
|
+
if checked["issues"]:
|
|
1904
|
+
# Drawing a preset over data that does not carry its columns produced a
|
|
1905
|
+
# chart with the wrong axis labels and no hint anything was off.
|
|
1906
|
+
raise OriginError("preset_columns_mismatch",
|
|
1907
|
+
"%s 预设的列对不上:%s(识别到的列:%s)"
|
|
1908
|
+
% (name, ";".join(checked["issues"]), checked["columns_detected"] or "无"),
|
|
1909
|
+
["把数据列名改成预设要求的名字(见 required_columns)",
|
|
1910
|
+
"换一个匹配数据的预设:origin_chart_presets",
|
|
1911
|
+
"不套预设只是画图:用 origin_figure"])
|
|
1487
1912
|
preset = chart_preset(name)
|
|
1488
1913
|
kind = str(plot_type or preset.get("default_plot_kind") or "line").lower()
|
|
1489
1914
|
if kind not in PLOT_CODES:
|