fix(origin): 修复多面板布局与导出基线

This commit is contained in:
caoqianming 2026-08-17 10:38:39 +08:00
parent 03371a8825
commit 79d48b98c9
7 changed files with 554 additions and 263 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`
- Origin 绘图新增声明式 Recipe可组合 14 个二维面板、左右坐标轴、误差棒和逐系列样式对已有专业软件任务不满意时Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-17(Origin 对数坐标自动缩放修复完成,未发版)
最后更新:2026-08-17(Origin 多面板自动布局与导出基线修复完成,未发版)
---
@ -22,6 +22,8 @@
### 2026-08-17
- **08-17 / Unreleased / Origin 多面板自动布局与导出基线修复**adapter 提升至 0.9.2,多面板图改用 Origin `layarrange` 根据行列、页边距和间距统一排列图层,并读取 Origin 计算后的实际图层位置放置标题与图例;共享 X 轴时仅底行保留标题和刻度标签,图例新增 `auto` 位置以调用 Origin 智能避让。每次任务显式设置 `@U=1`,消除 Origin 默认打印基线在中文标题、轴标题和图例上形成的异常横线,且不修改用户机器的持久配置。
- **08-17 / Unreleased / Origin 对数坐标自动缩放修复**adapter 提升至 0.9.1,单图、多面板及右 Y 轴统一在 Origin 自动计算范围前应用坐标轴尺度,避免线性范围中的零值切换为对数轴后被强制展开到 `1E-10`;显式范围仍在自动缩放后覆盖,保持请求权威。多面板缺少 `y_axis` 时改用面板标题或数据列名回退,并在契约中提示对数轴提供正数范围及纵轴标题。相关 88 项 unittest、Python 编译、契约 JSON 和 diff 检查通过,未连接或写入生产数据库。
### 2026-08-14

View File

@ -198,7 +198,8 @@
"properties": {
"enabled": {"type": "boolean"},
"position": {
"enum": ["top_left", "top_right", "bottom_left", "bottom_right"]
"enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"],
"description": "图例位置auto 由 Origin 根据当前数据和图层空间智能定位。"
},
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
@ -523,7 +524,8 @@
"properties": {
"enabled": {"type": "boolean"},
"position": {
"enum": ["top_left", "top_right", "bottom_left", "bottom_right"]
"enum": ["auto", "top_left", "top_right", "bottom_left", "bottom_right"],
"description": "图例位置auto 由 Origin 根据当前面板数据智能定位。"
},
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}

View File

@ -9,11 +9,17 @@ from unittest.mock import patch
WORKER_PATH = (
Path(__file__).resolve().parents[1]
/ "windows-node" / "adapters" / "origin.plot@v2" / "worker.py"
/ "windows-node"
/ "adapters"
/ "origin.plot@v2"
/ "worker.py"
)
ADAPTER_MANIFEST_PATH = (
Path(__file__).resolve().parents[1]
/ "windows-node" / "adapters" / "origin.plot@v2" / "adapter.json"
/ "windows-node"
/ "adapters"
/ "origin.plot@v2"
/ "adapter.json"
)
SPEC = importlib.util.spec_from_file_location("zcbot_origin_worker", WORKER_PATH)
assert SPEC and SPEC.loader
@ -26,10 +32,12 @@ class OriginWorkerUnitTests(unittest.TestCase):
def _request() -> dict:
return {
"inputs": [{"key": "sample"}],
"operation": {"plot": {
"operation": {
"plot": {
"type": "line",
"series": [{"input": "sample", "x": "x", "y": "y"}],
}},
}
},
"outputs": [{"key": "figure_png", "format": "png"}],
}
@ -67,9 +75,7 @@ class OriginWorkerUnitTests(unittest.TestCase):
with patch.object(worker, "_windows_file_version", return_value="2025") as read:
self.assertEqual(worker._registered_origin_version(FakeRegistry), "2025")
read.assert_called_once_with(
Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe")
)
read.assert_called_once_with(Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe"))
FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid"
self.assertIsNone(worker._registered_origin_version(FakeRegistry))
@ -78,7 +84,10 @@ class OriginWorkerUnitTests(unittest.TestCase):
root = Path(directory)
csv_path = root / "input.csv"
csv_path.write_text("x,y\n1,2\n3,4\n", encoding="utf-8")
self.assertEqual(worker._read_rows(csv_path, None), (["x", "y"], [["1", "2"], ["3", "4"]]))
self.assertEqual(
worker._read_rows(csv_path, None),
(["x", "y"], [["1", "2"], ["3", "4"]]),
)
json_path = root / "input.json"
json_path.write_text(json.dumps([{"x": 1, "y": 2}, {"x": 3, "y": 4}]), encoding="utf-8")
@ -94,7 +103,9 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.assertEqual(manifest["size_bytes"], 2)
def test_axis_title_includes_units(self) -> None:
self.assertEqual(worker._axis_title({"title": "Stress", "unit": "MPa"}, "Y"), "Stress (MPa)")
self.assertEqual(
worker._axis_title({"title": "Stress", "unit": "MPa"}, "Y"), "Stress (MPa)"
)
self.assertEqual(worker._axis_title(None, "Time"), "Time")
def test_layout_helpers_apply_validated_values(self) -> None:
@ -125,21 +136,38 @@ class OriginWorkerUnitTests(unittest.TestCase):
class FakeLabel:
def set_int(_, prop, value):
self.labels[(name, prop)] = value
return FakeLabel()
layer = FakeLayer()
worker._apply_axis(layer, "x", {
"title": "Time", "unit": "d", "scale": "log10",
"minimum": 1, "maximum": 100, "major_step": 1,
"tick_label_angle": 45, "tick_label_font_size": 10,
"title_font_size": 12, "grid": "major_minor",
}, "X")
worker._apply_axis(
layer,
"x",
{
"title": "Time",
"unit": "d",
"scale": "log10",
"minimum": 1,
"maximum": 100,
"major_step": 1,
"tick_label_angle": 45,
"tick_label_font_size": 10,
"title_font_size": 12,
"grid": "major_minor",
},
"X",
)
self.assertEqual(layer.axes["x"].title, "Time (d)")
self.assertEqual(layer.axes["x"].scale, "log10")
self.assertEqual(layer.axes["x"].limits, (1, 100, 1))
self.assertEqual(layer.values, {
"x.label.rotate": 45, "x.label.pt": 10, "x.grid.show": 3,
})
self.assertEqual(
layer.values,
{
"x.label.rotate": 45,
"x.label.pt": 10,
"x.grid.show": 3,
},
)
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
def test_rescale_applies_log_scales_before_deriving_limits(self) -> None:
@ -161,9 +189,7 @@ class OriginWorkerUnitTests(unittest.TestCase):
class FakeLayer:
def __init__(self):
self.events = []
self.axes = {
name: FakeAxis(name, self.events) for name in ("x", "y")
}
self.axes = {name: FakeAxis(name, self.events) for name in ("x", "y")}
def axis(self, name):
return self.axes[name]
@ -172,16 +198,22 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.events.append(("rescale",))
layer = FakeLayer()
worker._rescale_with_axis_scales(layer, [
worker._rescale_with_axis_scales(
layer,
[
("x", {"scale": "log10", "minimum": 1, "maximum": 100}),
("y", {"scale": "ln"}),
])
],
)
self.assertEqual(layer.events, [
self.assertEqual(
layer.events,
[
("scale", "x", "log10"),
("scale", "y", "ln"),
("rescale",),
])
],
)
def test_rescale_does_not_apply_explicit_limits_before_rescaling(self) -> None:
class FakeAxis:
@ -202,9 +234,12 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.rescaled = True
layer = FakeLayer()
worker._rescale_with_axis_scales(layer, [
worker._rescale_with_axis_scales(
layer,
[
("x", {"minimum": 1, "maximum": 100}),
])
],
)
self.assertTrue(layer.rescaled)
self.assertEqual(layer.axis_value.scale, "linear")
@ -225,10 +260,17 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.values[name] = value
plot = FakePlot()
worker._apply_series_style(plot, {
"color": "#3366CC", "line_width": 1.5, "line_style": "dash_dot",
"symbol": "diamond", "symbol_size": 8, "transparency": 20,
})
worker._apply_series_style(
plot,
{
"color": "#3366CC",
"line_width": 1.5,
"line_style": "dash_dot",
"symbol": "diamond",
"symbol_size": 8,
"transparency": 20,
},
)
self.assertEqual(plot.color, (51, 102, 204))
self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4})
self.assertEqual(plot.symbol_kind, 5)
@ -270,26 +312,34 @@ class OriginWorkerUnitTests(unittest.TestCase):
{"input": "second", "x": "temperature", "y": "value", "label": "28 d"},
]
resolved, labels = worker._resolve_series(input_data, series)
self.assertEqual(resolved, [
self.assertEqual(
resolved,
[
{"input": "first", "label": "7 d", "x": 0, "y": 1},
{"input": "second", "label": "28 d", "x": 0, "y": 1},
])
],
)
self.assertEqual(labels, {("first", 1): "7 d", ("second", 1): "28 d"})
def test_shared_y_column_rejects_conflicting_labels(self) -> None:
input_data = {"sample": (["x1", "x2", "y"], [[1, 2, 3]])}
with self.assertRaisesRegex(ValueError, "SERIES_LABEL_CONFLICT"):
worker._resolve_series(input_data, [
worker._resolve_series(
input_data,
[
{"input": "sample", "x": "x1", "y": "y", "label": "First"},
{"input": "sample", "x": "x2", "y": "y", "label": "Second"},
])
],
)
def test_worker_owns_cross_field_semantic_validation(self) -> None:
request = self._request()
worker._validate_semantics(request)
request["operation"]["plot"]["x_axis"] = {
"scale": "log10", "minimum": 0, "maximum": 100
"scale": "log10",
"minimum": 0,
"maximum": 100,
}
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
worker._validate_semantics(request)
@ -302,33 +352,45 @@ class OriginWorkerUnitTests(unittest.TestCase):
def test_multi_panel_semantics_cover_layout_axes_and_total_series(self) -> None:
request = {
"inputs": [{"key": "sample"}],
"operation": {"plot": {
"operation": {
"plot": {
"type": "multi_panel",
"layout": {"rows": 1, "columns": 2, "share_x": True},
"panels": [
{
"key": "strength",
"series": [{
"input": "sample", "x": "age", "y": "strength",
"kind": "scatter", "y_error": "sd",
}],
"series": [
{
"input": "sample",
"x": "age",
"y": "strength",
"kind": "scatter",
"y_error": "sd",
}
],
},
{
"key": "thermal",
"series": [
{
"input": "sample", "x": "temperature", "y": "tg",
"input": "sample",
"x": "temperature",
"y": "tg",
"kind": "line",
},
{
"input": "sample", "x": "temperature", "y": "dtg",
"kind": "line", "y_axis": "right",
"input": "sample",
"x": "temperature",
"y": "dtg",
"kind": "line",
"y_axis": "right",
},
],
"right_y_axis": {"title": "DTG"},
},
],
}},
}
},
"outputs": [{"key": "figure_png", "format": "png"}],
}
worker._validate_semantics(request)
@ -338,10 +400,13 @@ class OriginWorkerUnitTests(unittest.TestCase):
worker._validate_semantics(request)
request["operation"]["plot"]["layout"] = {
"rows": 1, "columns": 2, "share_x": True,
"rows": 1,
"columns": 2,
"share_x": True,
}
request["operation"]["plot"]["panels"][0]["x_axis"] = {
"scale": "log10", "minimum": 0,
"scale": "log10",
"minimum": 0,
}
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
worker._validate_semantics(request)
@ -352,7 +417,8 @@ class OriginWorkerUnitTests(unittest.TestCase):
request["operation"]["plot"]["panels"][0].pop("x_axis")
request["operation"]["plot"]["x_axis"] = {
"scale": "log10", "minimum": 0,
"scale": "log10",
"minimum": 0,
}
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
worker._validate_semantics(request)
@ -360,20 +426,26 @@ class OriginWorkerUnitTests(unittest.TestCase):
def test_recipe_uses_the_composition_semantics(self) -> None:
request = {
"inputs": [{"key": "sample"}],
"operation": {"plot": {
"operation": {
"plot": {
"type": "recipe",
"recipe_version": 1,
"layout": {"rows": 1, "columns": 1},
"panels": [{
"panels": [
{
"key": "main",
"series": [{
"series": [
{
"input": "sample",
"x": "age",
"y": "strength",
"kind": "line_scatter",
}],
}],
}},
}
],
}
],
}
},
"outputs": [{"key": "figure_png", "format": "png"}],
}
worker._validate_semantics(request)
@ -397,18 +469,19 @@ class OriginWorkerUnitTests(unittest.TestCase):
worker._panel_setting(plot, panel, "y_axis"),
{"title": "Strength", "unit": "MPa"},
)
self.assertEqual(worker._panel_legend(plot, panel), {
self.assertEqual(
worker._panel_legend(plot, panel),
{
"enabled": True,
"position": "top_right",
"font_size": 8,
})
},
)
def test_panel_y_axis_fallback_prefers_title_then_column_name(self) -> None:
series = [{"y": "CS_20C"}]
self.assertEqual(
worker._panel_y_axis_fallback(
{"title": "抗压强度 (MPa)"}, series
),
worker._panel_y_axis_fallback({"title": "抗压强度 (MPa)"}, series),
"抗压强度 (MPa)",
)
self.assertEqual(
@ -445,8 +518,12 @@ class OriginWorkerUnitTests(unittest.TestCase):
layer = FakeLayer()
series = [
{
"input": "sample", "x": "age", "y": "strength",
"kind": "scatter", "x_error": "age_sd", "y_error": "strength_sd",
"input": "sample",
"x": "age",
"y": "strength",
"kind": "scatter",
"x_error": "age_sd",
"y_error": "strength_sd",
},
{"input": "sample", "x": "age", "y": "fit", "kind": "line"},
]
@ -455,24 +532,47 @@ class OriginWorkerUnitTests(unittest.TestCase):
{"input": "sample", "x": 0, "y": 4},
]
worker._add_xy_plots(layer, {"sample": "worksheet"}, series, resolved)
self.assertEqual(layer.calls, [
("worksheet", {"coly": 1, "colx": 0, "type": "s", "colxerr": 2, "colyerr": 3}),
self.assertEqual(
layer.calls,
[
(
"worksheet",
{"coly": 1, "colx": 0, "type": "s", "colxerr": 2, "colyerr": 3},
),
("worksheet", {"coly": 4, "colx": 0, "type": "l"}),
])
],
)
self.assertFalse(layer.grouped)
def test_series_support_xyz_and_error_roles(self) -> None:
resolved, labels = worker._resolve_series(
{"sample": (["x", "y", "z", "x_sd", "y_sd"], [[0, 1, 2, 0.1, 0.2]])},
[{
"input": "sample", "x": "x", "y": "y", "z": "z",
"x_error": "x_sd", "y_error": "y_sd", "label": "测量值",
}],
[
{
"input": "sample",
"x": "x",
"y": "y",
"z": "z",
"x_error": "x_sd",
"y_error": "y_sd",
"label": "测量值",
}
],
)
self.assertEqual(
resolved,
[
{
"input": "sample",
"label": "测量值",
"x": 0,
"y": 1,
"z": 2,
"x_error": 3,
"y_error": 4,
}
],
)
self.assertEqual(resolved, [{
"input": "sample", "label": "测量值", "x": 0, "y": 1,
"z": 2, "x_error": 3, "y_error": 4,
}])
self.assertEqual(labels, {("sample", 1): "测量值"})
def test_series_support_second_batch_roles(self) -> None:
@ -483,23 +583,44 @@ class OriginWorkerUnitTests(unittest.TestCase):
[[0, 1, 2, 0.5, 1.5]],
)
},
[{
"input": "sample", "x": "x", "y": "mean", "size": "size",
"lower": "lower", "upper": "upper",
}],
[
{
"input": "sample",
"x": "x",
"y": "mean",
"size": "size",
"lower": "lower",
"upper": "upper",
}
],
)
self.assertEqual(
resolved,
[
{
"input": "sample",
"label": None,
"x": 0,
"y": 1,
"size": 2,
"lower": 3,
"upper": 4,
}
],
)
self.assertEqual(resolved, [{
"input": "sample", "label": None, "x": 0, "y": 1,
"size": 2, "lower": 3, "upper": 4,
}])
def test_second_batch_value_validation_rejects_invalid_data(self) -> None:
input_data = {
"sample": (["x", "y", "size", "lower", "upper"], [[0, 1, 0, 2, 1]])
input_data = {"sample": (["x", "y", "size", "lower", "upper"], [[0, 1, 0, 2, 1]])}
resolved = [
{
"input": "sample",
"x": 0,
"y": 1,
"size": 2,
"lower": 3,
"upper": 4,
}
resolved = [{
"input": "sample", "x": 0, "y": 1, "size": 2, "lower": 3, "upper": 4,
}]
]
with self.assertRaisesRegex(ValueError, "SIZE_VALUE_NOT_POSITIVE"):
worker._validate_bubble_values(input_data, resolved)
with self.assertRaisesRegex(ValueError, "BAND_BOUNDS_INVERTED"):
@ -653,9 +774,13 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.assertEqual(layer.group_call, (True, 0, 1))
self.assertTrue(layer.activated)
self.assertEqual(layer.command, "layer -b s 1")
self.assertEqual(op.sheet.columns[1:], [
(1, [10, 12], "A"), (2, [5, 6], "B"),
])
self.assertEqual(
op.sheet.columns[1:],
[
(1, [10, 12], "A"),
(2, [5, 6], "B"),
],
)
def test_stacked_graph_rejects_mismatched_x_values(self) -> None:
with self.assertRaisesRegex(ValueError, "STACKED_PLOT_X_VALUES_MISMATCH"):
@ -696,10 +821,13 @@ class OriginWorkerUnitTests(unittest.TestCase):
return self.legend
layer = Layer()
worker._replace_series_legend(layer, [
worker._replace_series_legend(
layer,
[
{"y": "a", "label": "Phase A"},
{"y": "phase_b"},
])
],
)
self.assertEqual(layer.name, "Legend")
self.assertEqual(layer.legend.text, r"\l(1) Phase A" "\n" r"\l(2) phase_b")
@ -803,6 +931,113 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.assertEqual(layer.title.values["left"], 382)
self.assertEqual(layer.title.values["top"], 229)
def test_origin_session_disables_printed_text_baselines(self) -> None:
class Origin:
def __init__(self):
self.variables = []
def set_lt_var(self, name, value):
self.variables.append((name, value))
origin = Origin()
worker._configure_origin_session(origin)
self.assertEqual(origin.variables, [("@U", 1)])
def test_origin_arranges_panel_layers_and_reports_resulting_geometry(self) -> None:
class Layer:
def __init__(self, geometry):
self.geometry = geometry
self.values = {}
self.commands = []
self.activated = False
self.obj = self
def set_int(self, name, value):
self.values[name] = value
def activate(self):
self.activated = True
def LT_execute(self, command):
self.commands.append(command)
def get_float(self, name):
return self.geometry[name]
layers = [
Layer({"left": 9, "top": 14, "width": 38, "height": 32}),
Layer({"left": 58, "top": 14, "width": 38, "height": 32}),
Layer({"left": 9, "top": 56, "width": 38, "height": 32}),
Layer({"left": 58, "top": 56, "width": 38, "height": 32}),
]
geometries = worker._arrange_panel_layers(layers, (2, 2))
self.assertTrue(layers[0].activated)
self.assertTrue(all(layer.values["unit"] == 1 for layer in layers))
self.assertEqual(
layers[0].commands,
["layarrange row:=2 col:=2 left:=9 right:=4 top:=14 bottom:=12 xgap:=11 ygap:=10;"],
)
self.assertEqual(
geometries,
[
(9, 14, 38, 32),
(58, 14, 38, 32),
(9, 56, 38, 32),
(58, 56, 38, 32),
],
)
def test_shared_x_hides_upper_row_title_and_tick_labels(self) -> None:
class Title:
show = True
class Layer:
def __init__(self):
self.title = Title()
self.commands = []
self.obj = self
def label(self, name):
return self.title if name == "xb" else None
def activate(self):
return None
def LT_execute(self, command):
self.commands.append(command)
upper = Layer()
lower = Layer()
worker._apply_shared_x_presentation(upper, panel_index=0, rows=2, columns=2, share_x=True)
worker._apply_shared_x_presentation(lower, panel_index=2, rows=2, columns=2, share_x=True)
self.assertFalse(upper.title.show)
self.assertEqual(upper.commands, ["axis -ps X L 0;"])
self.assertTrue(lower.title.show)
self.assertEqual(lower.commands, [])
def test_auto_legend_delegates_positioning_to_origin(self) -> None:
class Label:
def __init__(self):
self.values = {}
self.show = None
def set_int(self, name, value):
self.values[name] = value
class Layer:
def __init__(self):
self.legend = Label()
def label(self, _name):
return self.legend
layer = Layer()
worker._apply_legend(layer, {"enabled": True, "position": "auto"})
self.assertEqual(layer.legend.values["smartpos"], 1)
self.assertNotIn("left", layer.legend.values)
self.assertNotIn("top", layer.legend.values)
def test_heatmap_matrix_accepts_complete_unordered_grid(self) -> None:
matrix, xy_map = worker._heatmap_matrix(
[[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]],
@ -818,14 +1053,16 @@ class OriginWorkerUnitTests(unittest.TestCase):
{"x": 0, "y": 1, "z": 2},
)
with self.assertRaisesRegex(ValueError, "HEATMAP_COORDINATES_DUPLICATED"):
worker._heatmap_matrix(
[[0, 10, 1], [0, 10, 2]], {"x": 0, "y": 1, "z": 2}
)
worker._heatmap_matrix([[0, 10, 1], [0, 10, 2]], {"x": 0, "y": 1, "z": 2})
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_NOT_REGULAR"):
worker._heatmap_matrix(
[
[0, 10, 1], [1, 10, 2], [3, 10, 3],
[0, 20, 4], [1, 20, 5], [3, 20, 6],
[0, 10, 1],
[1, 10, 2],
[3, 10, 3],
[0, 20, 4],
[1, 20, 5],
[3, 20, 6],
],
{"x": 0, "y": 1, "z": 2},
)

View File

@ -378,7 +378,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
"grid": "major",
},
"y_axis": {"minimum": 0, "maximum": 80, "major_step": 10},
"legend": {"enabled": False, "position": "top_left", "font_size": 9},
"legend": {"enabled": False, "position": "auto", "font_size": 9},
}},
"outputs": [{
"key": "figure_png", "type": "figure", "format": "png",

View File

@ -1,6 +1,6 @@
{
"capability": "origin.plot@v2",
"adapter_version": "0.9.1",
"adapter_version": "0.9.2",
"runtime": "python",
"runtime_id": "origin",
"entrypoint": "worker.py",

View File

@ -4,6 +4,7 @@ This process accepts exactly one argument: a Node-created job directory. It neve
installs packages, evaluates user code, downloads data, or resolves paths from the
request. terminal.json is its only terminal-state contract.
"""
from __future__ import annotations
import csv
@ -67,18 +68,13 @@ LEGEND_POSITIONS = {
"bottom_left": (700, 7200),
"bottom_right": (6800, 7200),
}
PANEL_GEOMETRY = {
(1, 1): ((13, 14, 74, 72),),
(1, 2): ((9, 14, 38, 72), (58, 14, 38, 72)),
(2, 1): ((13, 14, 74, 31), (13, 57, 74, 31)),
(2, 2): (
(9, 14, 38, 31),
(58, 14, 38, 31),
(9, 57, 38, 31),
(58, 57, 38, 31),
),
PANEL_LAYOUT = {
(1, 1): {"left": 13, "right": 13, "top": 14, "bottom": 14, "xgap": 0, "ygap": 0},
(1, 2): {"left": 9, "right": 4, "top": 14, "bottom": 14, "xgap": 11, "ygap": 0},
(2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10},
(2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10},
}
ADAPTER_VERSION = "0.9.1"
ADAPTER_VERSION = "0.9.2"
def _server_executable(command: str) -> Path:
@ -115,11 +111,15 @@ def _windows_file_version(path: Path) -> str:
version_api = ctypes.WinDLL("version", use_last_error=True)
version_api.GetFileVersionInfoSizeW.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)
wintypes.LPCWSTR,
ctypes.POINTER(wintypes.DWORD),
]
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
version_api.GetFileVersionInfoW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
]
version_api.GetFileVersionInfoW.restype = wintypes.BOOL
version_api.VerQueryValueW.argtypes = [
@ -138,9 +138,7 @@ def _windows_file_version(path: Path) -> str:
raise ctypes.WinError(ctypes.get_last_error())
value = ctypes.c_void_p()
value_size = wintypes.UINT()
if not version_api.VerQueryValueW(
buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)
):
if not version_api.VerQueryValueW(buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)):
raise ctypes.WinError(ctypes.get_last_error())
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
translation = ctypes.c_void_p()
@ -187,9 +185,7 @@ def _windows_file_version(path: Path) -> str:
def _registered_origin_version(winreg: Any) -> str | None:
try:
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"
) as clsid_key:
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID") as clsid_key:
clsid = winreg.QueryValueEx(clsid_key, None)[0]
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
@ -217,16 +213,27 @@ def _probe() -> int:
originpro_version = version("originpro")
detail = f"Origin COM 与托管 Python 运行时可用originpro {originpro_version}"
del originpro
except (FileNotFoundError, ImportError, OSError, PackageNotFoundError, RuntimeError) as exc:
except (
FileNotFoundError,
ImportError,
OSError,
PackageNotFoundError,
RuntimeError,
) as exc:
health = "unavailable"
detail = str(exc)
print(json.dumps({
print(
json.dumps(
{
"adapter_version": ADAPTER_VERSION,
"software": "OriginPro",
"software_version": software_version,
"health": health,
"detail": detail,
}, ensure_ascii=False))
},
ensure_ascii=False,
)
)
return 0
@ -261,10 +268,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
_validate_axis(plot.get(axis_name), axis_name)
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
if layout.get(share_name):
settings = [
_panel_setting(plot, panel, axis_name) or {}
for panel in panels
]
settings = [_panel_setting(plot, panel, axis_name) or {} for panel in panels]
if any(value != settings[0] for value in settings[1:]):
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
else:
@ -281,11 +285,16 @@ def _validate_semantics(request: dict[str, Any]) -> None:
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
required_roles = (
("x", "y", "z") if plot_type in XYZ_PLOT_TYPES
else ("x", "y", "y_error") if plot_type == "y_error"
else ("y",) if plot_type in {"box", "histogram"}
else ("x", "y", "size") if plot_type == "bubble"
else ("x", "y", "lower", "upper") if plot_type == "band"
("x", "y", "z")
if plot_type in XYZ_PLOT_TYPES
else ("x", "y", "y_error")
if plot_type == "y_error"
else ("y",)
if plot_type in {"box", "histogram"}
else ("x", "y", "size")
if plot_type == "bubble"
else ("x", "y", "lower", "upper")
if plot_type == "band"
else ("x", "y")
)
for item in series:
@ -332,10 +341,17 @@ def _read_rows(path: Path, sheet: str | None) -> tuple[list[str], list[list[Any]
if isinstance(value, list) and value and all(isinstance(item, dict) for item in value):
headers = list(value[0])
return headers, [[item.get(name) for name in headers] for item in value]
if isinstance(value, dict) and value and all(isinstance(item, list) for item in value.values()):
if (
isinstance(value, dict)
and value
and all(isinstance(item, list) for item in value.values())
):
headers = list(value)
length = max(len(value[name]) for name in headers)
return headers, [[value[name][index] if index < len(value[name]) else None for name in headers] for index in range(length)]
return headers, [
[value[name][index] if index < len(value[name]) else None for name in headers]
for index in range(length)
]
raise ValueError("JSON_INPUT_SHAPE_UNSUPPORTED")
if suffix == ".xlsx":
from openpyxl import load_workbook
@ -392,7 +408,7 @@ def _axis_title(axis: Any, fallback: str) -> str:
def _hex_color(value: str) -> tuple[int, int, int]:
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
return tuple(int(value[index : index + 2], 16) for index in (1, 3, 5))
def _apply_canvas(graph: Any, canvas: Any) -> None:
@ -404,6 +420,49 @@ def _apply_canvas(graph: Any, canvas: Any) -> None:
graph.set_float("height", height / 25.4 * graph.get_float("resy"))
def _configure_origin_session(op: Any) -> None:
"""Normalize export behavior that otherwise depends on machine defaults."""
# Origin's default @U=0 prints a baseline under axis/data labels. It is
# especially conspicuous with CJK fonts and can look like an underline.
op.set_lt_var("@U", 1)
def _arrange_panel_layers(
layers: list[Any], grid: tuple[int, int]
) -> list[tuple[float, float, float, float]]:
"""Let Origin arrange the grid, then return its resulting page geometry."""
settings = PANEL_LAYOUT[grid]
for layer in layers:
layer.set_int("unit", 1)
layers[0].activate()
arguments = " ".join(
f"{name}:={value}"
for name, value in (
("row", grid[0]),
("col", grid[1]),
*settings.items(),
)
)
layers[0].obj.LT_execute(f"layarrange {arguments};")
return [
tuple(layer.get_float(name) for name in ("left", "top", "width", "height"))
for layer in layers
]
def _apply_shared_x_presentation(
layer: Any, *, panel_index: int, rows: int, columns: int, share_x: bool
) -> None:
"""For shared X grids, keep labels and the title only on the bottom row."""
if not share_x or panel_index // columns == rows - 1:
return
title = layer.label("xb")
if title is not None:
title.show = False
layer.activate()
layer.obj.LT_execute("axis -ps X L 0;")
def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
settings = spec if isinstance(spec, dict) else {}
axis = layer.axis(name)
@ -426,9 +485,7 @@ def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
layer.set_int(f"{name}.grid.show", grid_value)
def _rescale_with_axis_scales(
layer: Any, axes: list[tuple[str, Any]]
) -> None:
def _rescale_with_axis_scales(layer: Any, axes: list[tuple[str, Any]]) -> None:
"""Apply axis transforms before Origin derives automatic limits.
Origin's linear rescale commonly includes zero. Switching that result to a
@ -488,7 +545,9 @@ def _apply_legend(
label.show = legend.get("enabled", True)
if "font_size" in legend:
label.set_int("fsize", round(legend["font_size"]))
if "position" in legend:
if legend.get("position") == "auto":
label.set_int("smartpos", 1)
elif "position" in legend:
if attach_to_layer:
x_fraction, y_fraction = {
"top_left": (0.03, 0.04),
@ -498,9 +557,7 @@ def _apply_legend(
}[legend["position"]]
if vertical_offset:
y_fraction += 0.22 if legend["position"].startswith("top") else -0.22
left, top = _panel_page_pixel(
graph, geometry, x_fraction, y_fraction
)
left, top = _panel_page_pixel(graph, geometry, x_fraction, y_fraction)
label.set_int("attach", 1)
label.set_int("left", round(left))
label.set_int("top", round(top))
@ -592,23 +649,17 @@ def _apply_page_title(graph: Any, layer: Any, value: Any, style: Any) -> None:
title.set_int("top", round(graph.get_float("height") * 0.01))
def _panel_setting(
plot_spec: dict[str, Any], panel: dict[str, Any], name: str
) -> Any:
def _panel_setting(plot_spec: dict[str, Any], panel: dict[str, Any], name: str) -> Any:
value = panel.get(name)
return value if value is not None else plot_spec.get(name)
def _panel_y_axis_fallback(
panel: dict[str, Any], series_specs: list[dict[str, Any]]
) -> str:
def _panel_y_axis_fallback(panel: dict[str, Any], series_specs: list[dict[str, Any]]) -> str:
"""Prefer meaningful declarative metadata over Origin's generic ``Y``."""
return str(panel.get("title") or series_specs[0].get("y") or "Y")
def _panel_legend(
plot_spec: dict[str, Any], panel: dict[str, Any]
) -> dict[str, Any]:
def _panel_legend(plot_spec: dict[str, Any], panel: dict[str, Any]) -> dict[str, Any]:
return {
"enabled": True,
"position": "top_right",
@ -619,7 +670,9 @@ def _panel_legend(
def _input_file(job_dir: Path, key: str) -> Path:
directory = job_dir / "input" / key
files = [path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")]
files = [
path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")
]
if len(files) != 1:
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
return files[0]
@ -636,9 +689,8 @@ def _resolve_series(
headers, _ = input_data[input_key]
role_indexes = {
role: _column_index(headers, series[role], role)
for role in (
"x", "y", "z", "size", "lower", "upper", "x_error", "y_error"
) if role in series
for role in ("x", "y", "z", "size", "lower", "upper", "x_error", "y_error")
if role in series
}
y_index = role_indexes["y"]
label = series.get("label")
@ -767,15 +819,13 @@ def _add_xy_plots(
plot_count = len(layer.plot_list())
plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
plots.append(plot)
error_plots.append(list(layer.plot_list())[plot_count + 1:])
error_plots.append(list(layer.plot_list())[plot_count + 1 :])
if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs):
layer.group()
else:
# Explicit series styles require independent plots.
layer.group(False)
for plot, errors, series_spec in zip(
plots, error_plots, series_specs, strict=True
):
for plot, errors, series_spec in zip(plots, error_plots, series_specs, strict=True):
style = series_spec.get("style")
_apply_series_style(plot, style)
for error_plot in errors:
@ -790,9 +840,7 @@ def _configure_bubble_plot(
resolved: dict[str, Any],
) -> None:
origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"])
origin_plot.symbol_sizefactor = float(
(series_spec.get("style") or {}).get("symbol_size", 10)
)
origin_plot.symbol_sizefactor = float((series_spec.get("style") or {}).get("symbol_size", 10))
def _build_stacked_graph(
@ -856,24 +904,15 @@ def _build_recipe_graph(
while len(graph) < len(panels):
graph.add_layer(0)
_apply_canvas(graph, plot_spec.get("canvas"))
primary_layers = list(graph)[:len(panels)]
for layer, (left, top, width, height) in zip(
primary_layers, PANEL_GEOMETRY[grid], strict=True
):
layer.set_int("unit", 1)
layer.set_float("left", left)
layer.set_float("top", top)
layer.set_float("width", width)
layer.set_float("height", height)
primary_layers = list(graph)[: len(panels)]
geometries = _arrange_panel_layers(primary_layers, grid)
resolved_offset = 0
for panel, layer, geometry in zip(
panels, primary_layers, PANEL_GEOMETRY[grid], strict=True
for panel_index, (panel, layer, geometry) in enumerate(
zip(panels, primary_layers, geometries, strict=True)
):
panel_series = panel["series"]
resolved_panel = resolved_series[
resolved_offset:resolved_offset + len(panel_series)
]
resolved_panel = resolved_series[resolved_offset : resolved_offset + len(panel_series)]
resolved_offset += len(panel_series)
left_pairs = [
(spec, resolved)
@ -892,11 +931,18 @@ def _build_recipe_graph(
y_axis = _panel_setting(plot_spec, panel, "y_axis")
_rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)])
_apply_axis(
layer, "x", x_axis,
layer,
"x",
x_axis,
str(left_specs[0]["x"]),
)
_apply_axis(
layer, "y", y_axis, _panel_y_axis_fallback(panel, left_specs)
_apply_axis(layer, "y", y_axis, _panel_y_axis_fallback(panel, left_specs))
_apply_shared_x_presentation(
layer,
panel_index=panel_index,
rows=grid[0],
columns=grid[1],
share_x=bool(layout.get("share_x")),
)
legend = _panel_legend(plot_spec, panel)
_replace_series_legend(layer, left_specs, left_plots)
@ -918,7 +964,9 @@ def _build_recipe_graph(
panel_label = layer.add_label(str(panel["panel_label"]))
panel_label.set_int("background", 0)
panel_label.set_int("attach", 1)
panel_label.set_int("fsize", round((panel.get("title_style") or {}).get("font_size", 12)))
panel_label.set_int(
"fsize", round((panel.get("title_style") or {}).get("font_size", 12))
)
panel_left, panel_top = _panel_page_pixel(graph, geometry, 0.02, -0.14)
panel_label.set_float("left", round(panel_left))
panel_label.set_float("top", round(panel_top))
@ -928,9 +976,7 @@ def _build_recipe_graph(
right_layer = graph.add_layer(2)
right_specs = [item[0] for item in right_pairs]
right_resolved = [item[1] for item in right_pairs]
right_plots = _add_xy_plots(
right_layer, worksheets, right_specs, right_resolved
)
right_plots = _add_xy_plots(right_layer, worksheets, right_specs, right_resolved)
right_y_axis = panel.get("right_y_axis")
_rescale_with_axis_scales(right_layer, [("y2", right_y_axis)])
_apply_axis(right_layer, "y2", right_y_axis, "Right Y")
@ -971,18 +1017,12 @@ def _build_band_graph(
zip(series_specs, resolved_series, strict=True)
):
worksheet = worksheets[resolved["input"]]
upper_plot = layer.add_plot(
worksheet, coly=resolved["upper"], colx=resolved["x"], type="l"
)
lower_plot = layer.add_plot(
worksheet, coly=resolved["lower"], colx=resolved["x"], type="l"
)
upper_plot = layer.add_plot(worksheet, coly=resolved["upper"], colx=resolved["x"], type="l")
lower_plot = layer.add_plot(worksheet, coly=resolved["lower"], colx=resolved["x"], type="l")
upper_plot.set_fill_area(type=9)
_apply_series_style(upper_plot, series_spec.get("style"))
_apply_series_style(lower_plot, series_spec.get("style"))
center_plot = layer.add_plot(
worksheet, coly=resolved["y"], colx=resolved["x"], type="l"
)
center_plot = layer.add_plot(worksheet, coly=resolved["y"], colx=resolved["x"], type="l")
center_plots.append(center_plot)
label = series_spec.get("label") or series_spec["y"]
legend_entries.append(f"\\l({index * 3 + 3}) {label}")
@ -1005,7 +1045,10 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
}
plot_spec = request["operation"]["plot"]
plot_type = plot_spec["type"]
if plot_type not in PLOT_CONFIG and plot_type not in {"heatmap", *COMPOSITION_PLOT_TYPES}:
if plot_type not in PLOT_CONFIG and plot_type not in {
"heatmap",
*COMPOSITION_PLOT_TYPES,
}:
raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
series_specs = (
[item for panel in plot_spec["panels"] for item in panel["series"]]
@ -1025,6 +1068,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
op.set_show(False)
try:
op.new()
_configure_origin_session(op)
worksheets: dict[str, Any] = {}
for input_spec in input_specs:
input_key = input_spec["key"]
@ -1040,9 +1084,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
)
band_legend = None
if plot_type in COMPOSITION_PLOT_TYPES:
graph = _build_recipe_graph(
op, plot_spec, worksheets, resolved_series
)
graph = _build_recipe_graph(op, plot_spec, worksheets, resolved_series)
layer = graph[0]
origin_plots = []
elif plot_type == "band":
@ -1079,9 +1121,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
graph = op.new_graph(template=template)
layer = graph[0]
origin_plots = []
for series_spec, resolved in zip(
series_specs, resolved_series, strict=True
):
for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
arguments = {
"coly": resolved["y"],
"type": origin_plot_type,
@ -1092,9 +1132,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
arguments["colz"] = resolved["z"]
if plot_type == "y_error":
arguments["colyerr"] = resolved["y_error"]
origin_plot = layer.add_plot(
worksheets[resolved["input"]], **arguments
)
origin_plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
if plot_type == "bubble":
_configure_bubble_plot(op, origin_plot, series_spec, resolved)
origin_plots.append(origin_plot)
@ -1111,19 +1149,25 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
axes.append(("z", plot_spec.get("z_axis")))
_rescale_with_axis_scales(layer, axes)
_apply_axis(
layer, "x", plot_spec.get("x_axis"),
str(series_specs[0].get("x") or "X")
layer,
"x",
plot_spec.get("x_axis"),
str(series_specs[0].get("x") or "X"),
)
_apply_axis(
layer, "y", plot_spec.get("y_axis"),
layer,
"y",
plot_spec.get("y_axis"),
str(series_specs[0].get("y") or "Y"),
)
else:
layer.rescale()
if plot_type == "surface_3d":
_apply_axis(
layer, "z", plot_spec.get("z_axis"),
str(series_specs[0].get("z") or "Z")
layer,
"z",
plot_spec.get("z_axis"),
str(series_specs[0].get("z") or "Z"),
)
for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
style = series_spec.get("style")
@ -1156,12 +1200,14 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
for extension in ("png", "svg", "pdf"):
if extension in formats:
target = output / f"figure.{extension}"
exported = Path(graph.save_fig(
exported = Path(
graph.save_fig(
str(target),
type=extension,
width=pixel_width if extension == "png" else 0,
ratio=100 if extension in {"svg", "pdf"} else 0,
)).resolve()
)
).resolve()
if exported != target.resolve() or not target.is_file():
raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED")
_validate_artifact(target, extension)
@ -1208,7 +1254,9 @@ def main() -> int:
job_dir = Path(sys.argv[1])
request_record: dict[str, Any] = {}
try:
request_record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8"))
request_record = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
artifacts = run(job_dir)
terminal = {
"job_id": request_record["job_id"],