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/README.md +299 -295
- package/originlab.py +62 -4
- package/package.json +1 -5
- package/server.py +11 -3
- package/smoke_test.py +11 -3
- package/cases/catalogue.py +0 -636
- package/cases/data.py +0 -120
- package/render_realdata.py +0 -99
- package/run_cases.py +0 -304
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",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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",
|