From 79d48b98c901ce3073ea0524607edbc4ed76e058 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 17 Aug 2026 10:38:39 +0800 Subject: [PATCH] =?UTF-8?q?fix(origin):=20=E4=BF=AE=E5=A4=8D=E5=A4=9A?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E5=B8=83=E5=B1=80=E4=B8=8E=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=9F=BA=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + PROGRESS.md | 4 +- software-contracts/origin.plot.v2.json | 6 +- tests/test_origin_worker.py | 517 +++++++++++++----- tests/test_software_nodes.py | 2 +- .../adapters/origin.plot@v2/adapter.json | 2 +- .../adapters/origin.plot@v2/worker.py | 284 ++++++---- 7 files changed, 554 insertions(+), 263 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fec415f..9df232e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。 + - 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。 - Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。 diff --git a/PROGRESS.md b/PROGRESS.md index 09d44dc..ed49c9c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index 06c83d7..40b8d78 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -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} } diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index 36f4068..4b22854 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -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": { - "type": "line", - "series": [{"input": "sample", "x": "x", "y": "y"}], - }}, + "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, [ - ("x", {"scale": "log10", "minimum": 1, "maximum": 100}), - ("y", {"scale": "ln"}), - ]) + worker._rescale_with_axis_scales( + layer, + [ + ("x", {"scale": "log10", "minimum": 1, "maximum": 100}), + ("y", {"scale": "ln"}), + ], + ) - self.assertEqual(layer.events, [ - ("scale", "x", "log10"), - ("scale", "y", "ln"), - ("rescale",), - ]) + 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, [ - ("x", {"minimum": 1, "maximum": 100}), - ]) + 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, [ - {"input": "first", "label": "7 d", "x": 0, "y": 1}, - {"input": "second", "label": "28 d", "x": 0, "y": 1}, - ]) + 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, [ - {"input": "sample", "x": "x1", "y": "y", "label": "First"}, - {"input": "sample", "x": "x2", "y": "y", "label": "Second"}, - ]) + 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": { - "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", - }], - }, - { - "key": "thermal", - "series": [ - { - "input": "sample", "x": "temperature", "y": "tg", - "kind": "line", - }, - { - "input": "sample", "x": "temperature", "y": "dtg", - "kind": "line", "y_axis": "right", - }, - ], - "right_y_axis": {"title": "DTG"}, - }, - ], - }}, + "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", + } + ], + }, + { + "key": "thermal", + "series": [ + { + "input": "sample", + "x": "temperature", + "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"}], } 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": { - "type": "recipe", - "recipe_version": 1, - "layout": {"rows": 1, "columns": 1}, - "panels": [{ - "key": "main", - "series": [{ - "input": "sample", - "x": "age", - "y": "strength", - "kind": "line_scatter", - }], - }], - }}, + "operation": { + "plot": { + "type": "recipe", + "recipe_version": 1, + "layout": {"rows": 1, "columns": 1}, + "panels": [ + { + "key": "main", + "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), { - "enabled": True, - "position": "top_right", - "font_size": 8, - }) + 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}), - ("worksheet", {"coly": 4, "colx": 0, "type": "l"}), - ]) + 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]]) - } - resolved = [{ - "input": "sample", "x": 0, "y": 1, "size": 2, "lower": 3, "upper": 4, - }] + 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, + } + ] 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, [ - {"y": "a", "label": "Phase A"}, - {"y": "phase_b"}, - ]) + 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}, ) diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 9ed85ab..43efb44 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -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", diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index a4da686..bc0221b 100644 --- a/windows-node/adapters/origin.plot@v2/adapter.json +++ b/windows-node/adapters/origin.plot@v2/adapter.json @@ -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", diff --git a/windows-node/adapters/origin.plot@v2/worker.py b/windows-node/adapters/origin.plot@v2/worker.py index aaedb8d..e22b73f 100644 --- a/windows-node/adapters/origin.plot@v2/worker.py +++ b/windows-node/adapters/origin.plot@v2/worker.py @@ -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({ - "adapter_version": ADAPTER_VERSION, - "software": "OriginPro", - "software_version": software_version, - "health": health, - "detail": detail, - }, ensure_ascii=False)) + print( + json.dumps( + { + "adapter_version": ADAPTER_VERSION, + "software": "OriginPro", + "software_version": software_version, + "health": health, + "detail": detail, + }, + 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( - str(target), - type=extension, - width=pixel_width if extension == "png" else 0, - ratio=100 if extension in {"svg", "pdf"} else 0, - )).resolve() + 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() 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"],