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

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `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 ### 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 检查通过,未连接或写入生产数据库。 - **08-17 / Unreleased / Origin 对数坐标自动缩放修复**adapter 提升至 0.9.1,单图、多面板及右 Y 轴统一在 Origin 自动计算范围前应用坐标轴尺度,避免线性范围中的零值切换为对数轴后被强制展开到 `1E-10`;显式范围仍在自动缩放后覆盖,保持请求权威。多面板缺少 `y_axis` 时改用面板标题或数据列名回退,并在契约中提示对数轴提供正数范围及纵轴标题。相关 88 项 unittest、Python 编译、契约 JSON 和 diff 检查通过,未连接或写入生产数据库。
### 2026-08-14 ### 2026-08-14

View File

@ -198,7 +198,8 @@
"properties": { "properties": {
"enabled": {"type": "boolean"}, "enabled": {"type": "boolean"},
"position": { "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} "font_size": {"type": "number", "minimum": 6, "maximum": 72}
} }
@ -523,7 +524,8 @@
"properties": { "properties": {
"enabled": {"type": "boolean"}, "enabled": {"type": "boolean"},
"position": { "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} "font_size": {"type": "number", "minimum": 6, "maximum": 72}
} }

View File

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

View File

@ -378,7 +378,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
"grid": "major", "grid": "major",
}, },
"y_axis": {"minimum": 0, "maximum": 80, "major_step": 10}, "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": [{ "outputs": [{
"key": "figure_png", "type": "figure", "format": "png", "key": "figure_png", "type": "figure", "format": "png",

View File

@ -1,6 +1,6 @@
{ {
"capability": "origin.plot@v2", "capability": "origin.plot@v2",
"adapter_version": "0.9.1", "adapter_version": "0.9.2",
"runtime": "python", "runtime": "python",
"runtime_id": "origin", "runtime_id": "origin",
"entrypoint": "worker.py", "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 installs packages, evaluates user code, downloads data, or resolves paths from the
request. terminal.json is its only terminal-state contract. request. terminal.json is its only terminal-state contract.
""" """
from __future__ import annotations from __future__ import annotations
import csv import csv
@ -67,18 +68,13 @@ LEGEND_POSITIONS = {
"bottom_left": (700, 7200), "bottom_left": (700, 7200),
"bottom_right": (6800, 7200), "bottom_right": (6800, 7200),
} }
PANEL_GEOMETRY = { PANEL_LAYOUT = {
(1, 1): ((13, 14, 74, 72),), (1, 1): {"left": 13, "right": 13, "top": 14, "bottom": 14, "xgap": 0, "ygap": 0},
(1, 2): ((9, 14, 38, 72), (58, 14, 38, 72)), (1, 2): {"left": 9, "right": 4, "top": 14, "bottom": 14, "xgap": 11, "ygap": 0},
(2, 1): ((13, 14, 74, 31), (13, 57, 74, 31)), (2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10},
(2, 2): ( (2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10},
(9, 14, 38, 31),
(58, 14, 38, 31),
(9, 57, 38, 31),
(58, 57, 38, 31),
),
} }
ADAPTER_VERSION = "0.9.1" ADAPTER_VERSION = "0.9.2"
def _server_executable(command: str) -> Path: 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 = ctypes.WinDLL("version", use_last_error=True)
version_api.GetFileVersionInfoSizeW.argtypes = [ version_api.GetFileVersionInfoSizeW.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD) wintypes.LPCWSTR,
ctypes.POINTER(wintypes.DWORD),
] ]
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
version_api.GetFileVersionInfoW.argtypes = [ 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.GetFileVersionInfoW.restype = wintypes.BOOL
version_api.VerQueryValueW.argtypes = [ version_api.VerQueryValueW.argtypes = [
@ -138,9 +138,7 @@ def _windows_file_version(path: Path) -> str:
raise ctypes.WinError(ctypes.get_last_error()) raise ctypes.WinError(ctypes.get_last_error())
value = ctypes.c_void_p() value = ctypes.c_void_p()
value_size = wintypes.UINT() value_size = wintypes.UINT()
if not version_api.VerQueryValueW( if not version_api.VerQueryValueW(buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)):
buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)
):
raise ctypes.WinError(ctypes.get_last_error()) raise ctypes.WinError(ctypes.get_last_error())
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
translation = ctypes.c_void_p() translation = ctypes.c_void_p()
@ -187,9 +185,7 @@ def _windows_file_version(path: Path) -> str:
def _registered_origin_version(winreg: Any) -> str | None: def _registered_origin_version(winreg: Any) -> str | None:
try: try:
with winreg.OpenKey( with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID") as clsid_key:
winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"
) as clsid_key:
clsid = winreg.QueryValueEx(clsid_key, None)[0] clsid = winreg.QueryValueEx(clsid_key, None)[0]
with winreg.OpenKey( with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32" winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
@ -217,16 +213,27 @@ def _probe() -> int:
originpro_version = version("originpro") originpro_version = version("originpro")
detail = f"Origin COM 与托管 Python 运行时可用originpro {originpro_version}" detail = f"Origin COM 与托管 Python 运行时可用originpro {originpro_version}"
del originpro del originpro
except (FileNotFoundError, ImportError, OSError, PackageNotFoundError, RuntimeError) as exc: except (
FileNotFoundError,
ImportError,
OSError,
PackageNotFoundError,
RuntimeError,
) as exc:
health = "unavailable" health = "unavailable"
detail = str(exc) detail = str(exc)
print(json.dumps({ print(
"adapter_version": ADAPTER_VERSION, json.dumps(
"software": "OriginPro", {
"software_version": software_version, "adapter_version": ADAPTER_VERSION,
"health": health, "software": "OriginPro",
"detail": detail, "software_version": software_version,
}, ensure_ascii=False)) "health": health,
"detail": detail,
},
ensure_ascii=False,
)
)
return 0 return 0
@ -261,10 +268,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
_validate_axis(plot.get(axis_name), axis_name) _validate_axis(plot.get(axis_name), axis_name)
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")): for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
if layout.get(share_name): if layout.get(share_name):
settings = [ settings = [_panel_setting(plot, panel, axis_name) or {} for panel in panels]
_panel_setting(plot, panel, axis_name) or {}
for panel in panels
]
if any(value != settings[0] for value in settings[1:]): if any(value != settings[0] for value in settings[1:]):
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH") raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
else: else:
@ -281,11 +285,16 @@ def _validate_semantics(request: dict[str, Any]) -> None:
if plot_type in XYZ_PLOT_TYPES and len(series) != 1: if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES") raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
required_roles = ( required_roles = (
("x", "y", "z") if plot_type in XYZ_PLOT_TYPES ("x", "y", "z")
else ("x", "y", "y_error") if plot_type == "y_error" if plot_type in XYZ_PLOT_TYPES
else ("y",) if plot_type in {"box", "histogram"} else ("x", "y", "y_error")
else ("x", "y", "size") if plot_type == "bubble" if plot_type == "y_error"
else ("x", "y", "lower", "upper") if plot_type == "band" 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") else ("x", "y")
) )
for item in series: 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): if isinstance(value, list) and value and all(isinstance(item, dict) for item in value):
headers = list(value[0]) headers = list(value[0])
return headers, [[item.get(name) for name in headers] for item in value] 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) headers = list(value)
length = max(len(value[name]) for name in headers) 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") raise ValueError("JSON_INPUT_SHAPE_UNSUPPORTED")
if suffix == ".xlsx": if suffix == ".xlsx":
from openpyxl import load_workbook 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]: 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: 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")) 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: def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
settings = spec if isinstance(spec, dict) else {} settings = spec if isinstance(spec, dict) else {}
axis = layer.axis(name) 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) layer.set_int(f"{name}.grid.show", grid_value)
def _rescale_with_axis_scales( def _rescale_with_axis_scales(layer: Any, axes: list[tuple[str, Any]]) -> None:
layer: Any, axes: list[tuple[str, Any]]
) -> None:
"""Apply axis transforms before Origin derives automatic limits. """Apply axis transforms before Origin derives automatic limits.
Origin's linear rescale commonly includes zero. Switching that result to a 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) label.show = legend.get("enabled", True)
if "font_size" in legend: if "font_size" in legend:
label.set_int("fsize", round(legend["font_size"])) 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: if attach_to_layer:
x_fraction, y_fraction = { x_fraction, y_fraction = {
"top_left": (0.03, 0.04), "top_left": (0.03, 0.04),
@ -498,9 +557,7 @@ def _apply_legend(
}[legend["position"]] }[legend["position"]]
if vertical_offset: if vertical_offset:
y_fraction += 0.22 if legend["position"].startswith("top") else -0.22 y_fraction += 0.22 if legend["position"].startswith("top") else -0.22
left, top = _panel_page_pixel( left, top = _panel_page_pixel(graph, geometry, x_fraction, y_fraction)
graph, geometry, x_fraction, y_fraction
)
label.set_int("attach", 1) label.set_int("attach", 1)
label.set_int("left", round(left)) label.set_int("left", round(left))
label.set_int("top", round(top)) 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)) title.set_int("top", round(graph.get_float("height") * 0.01))
def _panel_setting( def _panel_setting(plot_spec: dict[str, Any], panel: dict[str, Any], name: str) -> Any:
plot_spec: dict[str, Any], panel: dict[str, Any], name: str
) -> Any:
value = panel.get(name) value = panel.get(name)
return value if value is not None else plot_spec.get(name) return value if value is not None else plot_spec.get(name)
def _panel_y_axis_fallback( def _panel_y_axis_fallback(panel: dict[str, Any], series_specs: list[dict[str, Any]]) -> str:
panel: dict[str, Any], series_specs: list[dict[str, Any]]
) -> str:
"""Prefer meaningful declarative metadata over Origin's generic ``Y``.""" """Prefer meaningful declarative metadata over Origin's generic ``Y``."""
return str(panel.get("title") or series_specs[0].get("y") or "Y") return str(panel.get("title") or series_specs[0].get("y") or "Y")
def _panel_legend( def _panel_legend(plot_spec: dict[str, Any], panel: dict[str, Any]) -> dict[str, Any]:
plot_spec: dict[str, Any], panel: dict[str, Any]
) -> dict[str, Any]:
return { return {
"enabled": True, "enabled": True,
"position": "top_right", "position": "top_right",
@ -619,7 +670,9 @@ def _panel_legend(
def _input_file(job_dir: Path, key: str) -> Path: def _input_file(job_dir: Path, key: str) -> Path:
directory = job_dir / "input" / key 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: if len(files) != 1:
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}") raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
return files[0] return files[0]
@ -636,9 +689,8 @@ def _resolve_series(
headers, _ = input_data[input_key] headers, _ = input_data[input_key]
role_indexes = { role_indexes = {
role: _column_index(headers, series[role], role) role: _column_index(headers, series[role], role)
for role in ( for role in ("x", "y", "z", "size", "lower", "upper", "x_error", "y_error")
"x", "y", "z", "size", "lower", "upper", "x_error", "y_error" if role in series
) if role in series
} }
y_index = role_indexes["y"] y_index = role_indexes["y"]
label = series.get("label") label = series.get("label")
@ -767,15 +819,13 @@ def _add_xy_plots(
plot_count = len(layer.plot_list()) plot_count = len(layer.plot_list())
plot = layer.add_plot(worksheets[resolved["input"]], **arguments) plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
plots.append(plot) 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): if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs):
layer.group() layer.group()
else: else:
# Explicit series styles require independent plots. # Explicit series styles require independent plots.
layer.group(False) layer.group(False)
for plot, errors, series_spec in zip( for plot, errors, series_spec in zip(plots, error_plots, series_specs, strict=True):
plots, error_plots, series_specs, strict=True
):
style = series_spec.get("style") style = series_spec.get("style")
_apply_series_style(plot, style) _apply_series_style(plot, style)
for error_plot in errors: for error_plot in errors:
@ -790,9 +840,7 @@ def _configure_bubble_plot(
resolved: dict[str, Any], resolved: dict[str, Any],
) -> None: ) -> None:
origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"]) origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"])
origin_plot.symbol_sizefactor = float( origin_plot.symbol_sizefactor = float((series_spec.get("style") or {}).get("symbol_size", 10))
(series_spec.get("style") or {}).get("symbol_size", 10)
)
def _build_stacked_graph( def _build_stacked_graph(
@ -856,24 +904,15 @@ def _build_recipe_graph(
while len(graph) < len(panels): while len(graph) < len(panels):
graph.add_layer(0) graph.add_layer(0)
_apply_canvas(graph, plot_spec.get("canvas")) _apply_canvas(graph, plot_spec.get("canvas"))
primary_layers = list(graph)[:len(panels)] primary_layers = list(graph)[: len(panels)]
for layer, (left, top, width, height) in zip( geometries = _arrange_panel_layers(primary_layers, grid)
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)
resolved_offset = 0 resolved_offset = 0
for panel, layer, geometry in zip( for panel_index, (panel, layer, geometry) in enumerate(
panels, primary_layers, PANEL_GEOMETRY[grid], strict=True zip(panels, primary_layers, geometries, strict=True)
): ):
panel_series = panel["series"] panel_series = panel["series"]
resolved_panel = resolved_series[ resolved_panel = resolved_series[resolved_offset : resolved_offset + len(panel_series)]
resolved_offset:resolved_offset + len(panel_series)
]
resolved_offset += len(panel_series) resolved_offset += len(panel_series)
left_pairs = [ left_pairs = [
(spec, resolved) (spec, resolved)
@ -892,11 +931,18 @@ def _build_recipe_graph(
y_axis = _panel_setting(plot_spec, panel, "y_axis") y_axis = _panel_setting(plot_spec, panel, "y_axis")
_rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)]) _rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)])
_apply_axis( _apply_axis(
layer, "x", x_axis, layer,
"x",
x_axis,
str(left_specs[0]["x"]), str(left_specs[0]["x"]),
) )
_apply_axis( _apply_axis(layer, "y", y_axis, _panel_y_axis_fallback(panel, left_specs))
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) legend = _panel_legend(plot_spec, panel)
_replace_series_legend(layer, left_specs, left_plots) _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 = layer.add_label(str(panel["panel_label"]))
panel_label.set_int("background", 0) panel_label.set_int("background", 0)
panel_label.set_int("attach", 1) 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_left, panel_top = _panel_page_pixel(graph, geometry, 0.02, -0.14)
panel_label.set_float("left", round(panel_left)) panel_label.set_float("left", round(panel_left))
panel_label.set_float("top", round(panel_top)) panel_label.set_float("top", round(panel_top))
@ -928,9 +976,7 @@ def _build_recipe_graph(
right_layer = graph.add_layer(2) right_layer = graph.add_layer(2)
right_specs = [item[0] for item in right_pairs] right_specs = [item[0] for item in right_pairs]
right_resolved = [item[1] for item in right_pairs] right_resolved = [item[1] for item in right_pairs]
right_plots = _add_xy_plots( right_plots = _add_xy_plots(right_layer, worksheets, right_specs, right_resolved)
right_layer, worksheets, right_specs, right_resolved
)
right_y_axis = panel.get("right_y_axis") right_y_axis = panel.get("right_y_axis")
_rescale_with_axis_scales(right_layer, [("y2", right_y_axis)]) _rescale_with_axis_scales(right_layer, [("y2", right_y_axis)])
_apply_axis(right_layer, "y2", right_y_axis, "Right Y") _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) zip(series_specs, resolved_series, strict=True)
): ):
worksheet = worksheets[resolved["input"]] worksheet = worksheets[resolved["input"]]
upper_plot = layer.add_plot( upper_plot = layer.add_plot(worksheet, coly=resolved["upper"], colx=resolved["x"], type="l")
worksheet, coly=resolved["upper"], colx=resolved["x"], type="l" lower_plot = layer.add_plot(worksheet, coly=resolved["lower"], 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) upper_plot.set_fill_area(type=9)
_apply_series_style(upper_plot, series_spec.get("style")) _apply_series_style(upper_plot, series_spec.get("style"))
_apply_series_style(lower_plot, series_spec.get("style")) _apply_series_style(lower_plot, series_spec.get("style"))
center_plot = layer.add_plot( center_plot = layer.add_plot(worksheet, coly=resolved["y"], colx=resolved["x"], type="l")
worksheet, coly=resolved["y"], colx=resolved["x"], type="l"
)
center_plots.append(center_plot) center_plots.append(center_plot)
label = series_spec.get("label") or series_spec["y"] label = series_spec.get("label") or series_spec["y"]
legend_entries.append(f"\\l({index * 3 + 3}) {label}") 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_spec = request["operation"]["plot"]
plot_type = plot_spec["type"] 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") raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
series_specs = ( series_specs = (
[item for panel in plot_spec["panels"] for item in panel["series"]] [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) op.set_show(False)
try: try:
op.new() op.new()
_configure_origin_session(op)
worksheets: dict[str, Any] = {} worksheets: dict[str, Any] = {}
for input_spec in input_specs: for input_spec in input_specs:
input_key = input_spec["key"] input_key = input_spec["key"]
@ -1040,9 +1084,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
) )
band_legend = None band_legend = None
if plot_type in COMPOSITION_PLOT_TYPES: if plot_type in COMPOSITION_PLOT_TYPES:
graph = _build_recipe_graph( graph = _build_recipe_graph(op, plot_spec, worksheets, resolved_series)
op, plot_spec, worksheets, resolved_series
)
layer = graph[0] layer = graph[0]
origin_plots = [] origin_plots = []
elif plot_type == "band": elif plot_type == "band":
@ -1079,9 +1121,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
graph = op.new_graph(template=template) graph = op.new_graph(template=template)
layer = graph[0] layer = graph[0]
origin_plots = [] origin_plots = []
for series_spec, resolved in zip( for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
series_specs, resolved_series, strict=True
):
arguments = { arguments = {
"coly": resolved["y"], "coly": resolved["y"],
"type": origin_plot_type, "type": origin_plot_type,
@ -1092,9 +1132,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
arguments["colz"] = resolved["z"] arguments["colz"] = resolved["z"]
if plot_type == "y_error": if plot_type == "y_error":
arguments["colyerr"] = resolved["y_error"] arguments["colyerr"] = resolved["y_error"]
origin_plot = layer.add_plot( origin_plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
worksheets[resolved["input"]], **arguments
)
if plot_type == "bubble": if plot_type == "bubble":
_configure_bubble_plot(op, origin_plot, series_spec, resolved) _configure_bubble_plot(op, origin_plot, series_spec, resolved)
origin_plots.append(origin_plot) 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"))) axes.append(("z", plot_spec.get("z_axis")))
_rescale_with_axis_scales(layer, axes) _rescale_with_axis_scales(layer, axes)
_apply_axis( _apply_axis(
layer, "x", plot_spec.get("x_axis"), layer,
str(series_specs[0].get("x") or "X") "x",
plot_spec.get("x_axis"),
str(series_specs[0].get("x") or "X"),
) )
_apply_axis( _apply_axis(
layer, "y", plot_spec.get("y_axis"), layer,
"y",
plot_spec.get("y_axis"),
str(series_specs[0].get("y") or "Y"), str(series_specs[0].get("y") or "Y"),
) )
else: else:
layer.rescale() layer.rescale()
if plot_type == "surface_3d": if plot_type == "surface_3d":
_apply_axis( _apply_axis(
layer, "z", plot_spec.get("z_axis"), layer,
str(series_specs[0].get("z") or "Z") "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): for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
style = series_spec.get("style") style = series_spec.get("style")
@ -1156,12 +1200,14 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
for extension in ("png", "svg", "pdf"): for extension in ("png", "svg", "pdf"):
if extension in formats: if extension in formats:
target = output / f"figure.{extension}" target = output / f"figure.{extension}"
exported = Path(graph.save_fig( exported = Path(
str(target), graph.save_fig(
type=extension, str(target),
width=pixel_width if extension == "png" else 0, type=extension,
ratio=100 if extension in {"svg", "pdf"} else 0, width=pixel_width if extension == "png" else 0,
)).resolve() ratio=100 if extension in {"svg", "pdf"} else 0,
)
).resolve()
if exported != target.resolve() or not target.is_file(): if exported != target.resolve() or not target.is_file():
raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED") raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED")
_validate_artifact(target, extension) _validate_artifact(target, extension)
@ -1208,7 +1254,9 @@ def main() -> int:
job_dir = Path(sys.argv[1]) job_dir = Path(sys.argv[1])
request_record: dict[str, Any] = {} request_record: dict[str, Any] = {}
try: 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) artifacts = run(job_dir)
terminal = { terminal = {
"job_id": request_record["job_id"], "job_id": request_record["job_id"],