dsh-origin-bridge 0.4.2 → 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
@@ -47,7 +47,7 @@ SCALE_NAMES = {1: "linear", 2: "log10", 3: "probability", 4: "probit", 5: "recip
47
47
  6: "offset_reciprocal", 7: "logit", 8: "ln", 9: "log2"}
48
48
  # new_graph(template=NAME) accepts these OriginPro 2024 template stems (extension
49
49
  # optional) and returns None for an unknown one, which is how they get validated.
50
- GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley", "dply",
50
+ GRAPH_TEMPLATES = ("line", "scatter", "linesymb", "column", "doubley",
51
51
  "heat_map", "cmap", "mesh", "contline", "contgray", "3d",
52
52
  "glwireface", "pie", "box", "histdist", "polar", "ternary",
53
53
  "vector", "errbar")
@@ -661,7 +661,9 @@ def write_columns(columns, book_name=""):
661
661
  ws = op.new_sheet(lname=book_name or "")
662
662
  for i, (name, values) in enumerate(columns.items()):
663
663
  try:
664
- 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]
665
667
  except (TypeError, ValueError) as exc:
666
668
  raise OriginError("bad_arguments", "列 %r 里有非数值项:%s" % (name, exc),
667
669
  ["数值列只接受数字或 null;文本请放 Notes 页,或先把分类编码成数字"])
@@ -676,6 +678,10 @@ def write_block(headers, rows, book_name=""):
676
678
  which is why a 40-column sheet is slow; from_list2 writes the block at once."""
677
679
  if not rows or not isinstance(rows, (list, tuple)):
678
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]] 这种形状']])
679
685
  ncol = max(len(r) for r in rows)
680
686
  if headers and len(headers) != ncol:
681
687
  raise OriginError("bad_arguments", "headers 有 %d 项,但 rows 最宽 %d 列" % (len(headers), ncol))
@@ -747,6 +753,39 @@ def column_formula(worksheet, col, formula, label="", units=""):
747
753
  return call(run)
748
754
 
749
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
+
750
789
  def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
751
790
  graph="", layer=0, template=""):
752
791
  """Draw one or more series.
@@ -844,6 +883,10 @@ def plot(worksheet, x=1, y=2, plot_type="line", graph_name="", title="",
844
883
  raise OriginError("plot_incomplete",
845
884
  "要求 %d 条曲线,层里只有 %d 条" % (len(yis), drawn),
846
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)
847
890
  return sanitize({"graph": _bind("gr", gr, gr.name), "plot_type": plot_type,
848
891
  "channel": channel, "template": tpl,
849
892
  "source_sheet": _safe(lambda: ws.name, ""),
@@ -1140,6 +1183,8 @@ def add_layer(graph, layer_type="right"):
1140
1183
  right-Y layer reads back as 'RightY'. Filling it is left to origin_plot with
1141
1184
  layer=<index>, which is the only path measured to draw into a chosen layer.
1142
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
1143
1188
  if isinstance(layer_type, str) and layer_type.lower() in LAYER_TYPES:
1144
1189
  code = LAYER_TYPES[layer_type.lower()]
1145
1190
  elif isinstance(layer_type, int) and layer_type in LAYER_TYPES.values():
@@ -1393,7 +1438,10 @@ def export(graph, path="", fmt="png", width=1600):
1393
1438
  if page is None:
1394
1439
  raise OriginError("graph_not_found", "找不到图页:%s(项目里可能还没有图)" % (graph or ""))
1395
1440
  name = _safe(lambda: page.name, "Graph1")
1396
- 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)))
1397
1445
  if os.path.splitext(target_path)[1].lower().lstrip(".") != fmt:
1398
1446
  target_path = os.path.splitext(target_path)[0] + "." + fmt
1399
1447
  os.makedirs(os.path.dirname(target_path) or ".", exist_ok=True)
@@ -1557,6 +1605,9 @@ def figure(source="", columns=None, x=1, y=1, plot_type="line", title="",
1557
1605
  p = plot(info["worksheet"], x=x, y=y, plot_type=plot_type, title=title, template=template)
1558
1606
  steps.append({"step": "plot", "graph": p["graph_page"], "type": plot_type,
1559
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]})
1560
1611
  if x_title or y_title:
1561
1612
  st = style(p["graph"], x_title=x_title, y_title=y_title)
1562
1613
  steps.append({"step": "style", **st})
@@ -1568,6 +1619,11 @@ def figure(source="", columns=None, x=1, y=1, plot_type="line", title="",
1568
1619
  steps.append({"step": "legend", "proof_level": lg.get("proof_level")})
1569
1620
 
1570
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 目视确认后再决定"]
1571
1627
  deliver = {}
1572
1628
  if export_path or output_dir:
1573
1629
  base = export_path or os.path.join(output_dir, "%s.%s" % (p["graph_page"], fmt))
@@ -1730,7 +1786,9 @@ def close_pages(kind="graph", names=None, dry_run=False):
1730
1786
  targets, had = call(collect)
1731
1787
  wanted = [_safe(lambda p=p: p.name, "?") for p in targets]
1732
1788
  if not targets:
1733
- 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": "没有匹配的页面"})
1734
1792
  if dry_run:
1735
1793
  return sanitize({"would_close": wanted, "dry_run": True})
1736
1794
  errors = call(destroy, targets)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-origin-bridge",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Drive local Origin/OriginPro from chat: import .dat/.csv/Excel/workstation .bin data, plot, fit, and save editable .opju projects. Third-party tool, not affiliated with or endorsed by OriginLab Corporation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -46,10 +46,6 @@
46
46
  "fixtures/*.csv",
47
47
  "fixtures/*.txt",
48
48
  "smoke_test.py",
49
- "run_cases.py",
50
- "render_realdata.py",
51
- "cases/catalogue.py",
52
- "cases/data.py",
53
49
  "edge_checks.py",
54
50
  "test_asciiio.py",
55
51
  "test_binio.py",
package/server.py CHANGED
@@ -20,7 +20,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
20
20
  import originlab # noqa: E402
21
21
 
22
22
  SERVER_NAME = "origin-bridge"
23
- SERVER_VERSION = "0.4.2"
23
+ SERVER_VERSION = "0.4.3"
24
24
  PROTOCOL = "2024-11-05"
25
25
 
26
26
 
@@ -82,7 +82,7 @@ TOOLS = [
82
82
  },
83
83
  {
84
84
  "name": "origin_plot",
85
- "description": "在工作表上画图。x/y 用 1 起始列号或列名;y 可以给列表,一次把多条曲线画进同一层。plot_type: line|scatter|line_symbol|column。给 template 用 Origin 自带图模建页(如 doubley/heat_map/line),给 graph+layer 则往已有图页的指定层里加曲线。",
85
+ "description": "在工作表上画图。x/y 用 1 起始列号或列名;y 可以给列表,一次把多条曲线画进同一层。plot_type: line|scatter|line_symbol|column。给 template 用 Origin 自带图模建页(如 doubley/heat_map/line),给 graph+layer 则往已有图页的指定层里加曲线。返回里有 warnings 时代表图可能不可读(线性轴跨数量级等),要把选项交给用户决定。",
86
86
  "inputSchema": {
87
87
  "type": "object",
88
88
  "properties": {
@@ -235,7 +235,7 @@ TOOLS = [
235
235
  },
236
236
  {
237
237
  "name": "origin_figure",
238
- "description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。",
238
+ "description": "主路径:一次调用完成 导入/写数 → 画图 → 轴标题 → 导出图片 → 保存可编辑 .opju。用户只想“把这份数据画成图并给我文件”时用它,不要拆成多步。返回里若有 warnings,说明图虽然画成了但可能不可读(例如线性横轴跨了几个数量级,点全挤在最左边):把 next_actions 里的方案讲给用户让他选,不要自己擅自改轴。",
239
239
  "inputSchema": {
240
240
  "type": "object",
241
241
  "properties": {
@@ -536,6 +536,14 @@ def _handle(msg):
536
536
  except originlab.OriginError as exc:
537
537
  return _tool_result(call_id, {"ok": False, "code": exc.code, "message": exc.message,
538
538
  "next_actions": exc.next_actions, **exc.extra}, True)
539
+ except KeyError as exc:
540
+ # A model can omit a required argument even though the schema names it;
541
+ # that is a bad_arguments answer, not a crash with a stack trace.
542
+ want = (tool.get("inputSchema") or {}).get("required") or []
543
+ return _tool_result(call_id, {"ok": False, "code": "bad_arguments",
544
+ "message": "%s 缺少参数 %s(该工具要求 %s)"
545
+ % (name, exc, want or "见 schema"),
546
+ "next_actions": ["补上缺的参数再调用"]}, True)
539
547
  except Exception as exc: # noqa: BLE001 - report, never kill the server
540
548
  _log("tool %s crashed: %r" % (name, exc))
541
549
  bare = isinstance(exc, (SystemError, TypeError, AttributeError))
package/smoke_test.py CHANGED
@@ -7,6 +7,7 @@ Exits non-zero on the first hard failure. Writes artifacts to ./out/.
7
7
  """
8
8
 
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import subprocess
12
13
  import sys
@@ -309,15 +310,22 @@ def main():
309
310
  "data": os.path.join(HERE, "sample.dat")})
310
311
  check("chart_check reads columns from the file", pv.get("ok") and
311
312
  pv.get("columns_detected") == ["X", "Y"], (pv.get("columns_detected"), pv.get("issues")))
312
- cr = rpc.tool("origin_chart_render", {"name": preset_name,
313
- "source": os.path.join(HERE, "sample.dat"),
314
- "x": 1, "y": 2, "output_dir": OUT})
313
+ xrd = {"two_theta": [round(20 + i * 1.5, 2) for i in range(12)],
314
+ "intensity": [round(120 + 900 * math.exp(-((i - 5) ** 2) / 3.0)) for i in range(12)]}
315
+ cr = rpc.tool("origin_chart_render", {"name": preset_name, "columns": xrd,
316
+ "output_dir": OUT})
315
317
  check("chart_render delivers image+opju", cr.get("ok") and
316
318
  bool((cr.get("delivered") or {}).get("image")) and bool((cr.get("delivered") or {}).get("project")),
317
319
  (cr.get("delivered"), cr.get("code"), cr.get("message")))
318
320
  check("chart_render applied the preset titles",
319
321
  (cr.get("preset") or {}).get("x_title") and (cr.get("preset") or {}).get("y_title"),
320
322
  cr.get("preset"))
323
+ bad = rpc.tool("origin_chart_render", {"name": preset_name,
324
+ "source": os.path.join(HERE, "sample.dat"),
325
+ "x": 1, "y": 2, "output_dir": OUT})
326
+ check("chart_render refuses data missing the preset's columns",
327
+ not bad.get("ok") and bad.get("code") == "preset_columns_mismatch",
328
+ (bad.get("code"), bad.get("message")))
321
329
 
322
330
  gp = rpc.tool("origin_fit", {"worksheet": ws, "x": 1, "y": 2, "kind": "gauss"})
323
331
  check("fit preset resolves to an installed function", gp.get("ok") and gp.get("func") == "Gauss",