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/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
- LAYER_TYPES = {"bottom_left": 0, "top": 1, "right": 2, "left": 3,
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, "probability": 3, "probit": 4, "reciprocal": 5,
38
- "offset_reciprocal": 6, "logit": 7, "ln": 8, "log2": 9}
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", "dply",
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
- "sine": "Sine", "power": "Power", "logistic": "Logistic",
50
- "boltzmann": "Boltzmann", "doseresp": "DoseResp", "cubic": "Cubic"}
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
- box = {"done": threading.Event()}
100
- _q.put((fn, args, kwargs, box))
101
- if not box["done"].wait(timeout):
102
- raise OriginError(
103
- "com_timeout",
104
- "Origin 在 %d 秒内没有响应" % timeout,
105
- ["看 Origin 窗口是否卡在对话框或许可证弹窗", "任务管理器结束 Origin64.exe 后重试"],
106
- )
107
- if "error" in box:
108
- raise box["error"]
109
- return box["value"]
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
- idx = idx - 1 if idx >= 1 else idx
217
- if 0 <= idx < ncol:
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 > 1024 and (magic is None or head.startswith(magic))
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
- info.update({"source": path, "format": ext, "channel": "origin_from_file"})
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
- nums = [float(v) for v in values]
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"], "proof_level": "verified"})
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", x_scale), lambda: lay.xscale, want)
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", y_scale), lambda: lay.yscale, want)
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
- lay = gr[layer]
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 = [item.get(k) for k in ("x1", "y1", "x2", "y2")]
739
- ln = _safe(lambda: lay.add_line(*coords))
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
- nums[str(name)] = _safe(lambda n=name: op.lt_float(n), None)
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", "函数 %r 不可用:%s" % (func, exc),
1031
- ["NLFit 需要 OriginPro 授权",
1032
- "本机实测可用的名字:%s" % ", ".join(sorted(set(FIT_PRESETS.values())))])
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
- if fmt not in ("png", "tif", "tiff", "svg", "pdf", "emf"):
1064
- raise OriginError("bad_format", "fmt 只支持 png/tif/svg/pdf/emf,收到 %s" % fmt)
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
- target_path = os.path.abspath(path or os.path.join(os.getcwd(), "%s.%s" % (name, fmt)))
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", "工程文件过小:%s" % info,
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
- _op = None
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
- return sanitize({"closed": [], "asked": [], "note": "没有匹配的页面"})
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
- items = cat.list_templates(category or None)
1439
- return sanitize({"n_templates": len(items),
1440
- "categories": sorted({t["category"] for t in cat.TEMPLATES}),
1441
- "templates": items})
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
- columns = [c["name"] for c in read_file(data)["columns"]]
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: