diff --git a/CHANGELOG.md b/CHANGELOG.md index 14b592a..fec415f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ ## Unreleased +- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。 + - Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。 - 修复 Origin 二维复合图中多条系列被错误套用同一种红色三角样式、标题和图例缺失或重叠的问题;颜色、线型、点型、误差棒、坐标轴和图例现在会按请求分别呈现。 diff --git a/PROGRESS.md b/PROGRESS.md index 8be0d22..09d44dc 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-14(专业软件输出归一与来源标记开发中,未发版) +最后更新:2026-08-17(Origin 对数坐标自动缩放修复完成,未发版) --- @@ -20,6 +20,10 @@ --- ## 已完成关键能力 +### 2026-08-17 + +- **08-17 / Unreleased / Origin 对数坐标自动缩放修复**:adapter 提升至 0.9.1,单图、多面板及右 Y 轴统一在 Origin 自动计算范围前应用坐标轴尺度,避免线性范围中的零值切换为对数轴后被强制展开到 `1E-10`;显式范围仍在自动缩放后覆盖,保持请求权威。多面板缺少 `y_axis` 时改用面板标题或数据列名回退,并在契约中提示对数轴提供正数范围及纵轴标题。相关 88 项 unittest、Python 编译、契约 JSON 和 diff 检查通过,未连接或写入生产数据库。 + ### 2026-08-14 - **08-14 / Unreleased / Origin 声明式 Recipe 与产物重做**:adapter 0.9.0 在 `origin.plot@v2` 中新增 `type=recipe + recipe_version=1`,复用已真机验证的 1–4 panel、左右轴、误差棒和系列样式声明结构及同一受控执行器,不开放 Python、LabTalk、模板或路径。新增通用 `software_job_revise`,按当前 user/task 复用源 Job 的 artifact inputs,以完整新 operation/outputs 重新校验并创建新 Job;单 Job 状态返回 `editable_request`,旧 Job 与产物保持不变,无 migration、无 OPJU 增量编辑。专项 94 项 unittest、Python 编译、Ruff 致命规则、diff 检查、工具 schema、Windows Node build 与独立 adapter 打包通过;全量 634 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项)。Recipe 执行路径复用本日已通过 Origin 2024 真机核对的组合执行器,未连接或写入生产数据库。 diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index 13c6e4f..06c83d7 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -441,6 +441,7 @@ "$defs": { "axis": { "type": "object", + "description": "坐标轴设置。使用对数刻度时应根据数据显式提供正数 minimum 和 maximum,以保证可复现的显示范围。", "additionalProperties": false, "properties": { "title": {"type": "string"}, @@ -504,10 +505,17 @@ "items": {"$ref": "#/$defs/panel_series"} }, "panel_label": {"type": "string", "minLength": 1, "maxLength": 20}, - "title": {"type": "string", "maxLength": 500}, + "title": { + "type": "string", + "maxLength": 500, + "description": "面板上方标题;它不替代 y_axis.title。需要纵轴名称和单位时应同时设置 y_axis。" + }, "title_style": {"$ref": "#/$defs/text_style"}, "x_axis": {"$ref": "#/$defs/axis"}, - "y_axis": {"$ref": "#/$defs/axis"}, + "y_axis": { + "$ref": "#/$defs/axis", + "description": "左纵轴设置。科研多面板图应显式提供 title 和 unit,避免使用数据列名或兼容性回退标题。" + }, "right_y_axis": {"$ref": "#/$defs/axis"}, "legend": { "type": "object", diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index b6b9dc0..36f4068 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -8,7 +8,8 @@ from pathlib import Path from unittest.mock import patch WORKER_PATH = ( - Path(__file__).resolve().parents[1] / "windows-node" / "origin-worker" / "worker.py" + Path(__file__).resolve().parents[1] + / "windows-node" / "adapters" / "origin.plot@v2" / "worker.py" ) ADAPTER_MANIFEST_PATH = ( Path(__file__).resolve().parents[1] @@ -141,6 +142,73 @@ class OriginWorkerUnitTests(unittest.TestCase): }) self.assertEqual(layer.labels, {("xb", "fsize"): 12}) + def test_rescale_applies_log_scales_before_deriving_limits(self) -> None: + class FakeAxis: + def __init__(self, name, events): + self.name = name + self.events = events + self._scale = "linear" + + @property + def scale(self): + return self._scale + + @scale.setter + def scale(self, value): + self._scale = value + self.events.append(("scale", self.name, value)) + + class FakeLayer: + def __init__(self): + self.events = [] + self.axes = { + name: FakeAxis(name, self.events) for name in ("x", "y") + } + + def axis(self, name): + return self.axes[name] + + def rescale(self): + self.events.append(("rescale",)) + + layer = FakeLayer() + 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",), + ]) + + def test_rescale_does_not_apply_explicit_limits_before_rescaling(self) -> None: + class FakeAxis: + scale = "linear" + + def set_limits(self, *_): + raise AssertionError("limits must be applied after rescale") + + class FakeLayer: + def __init__(self): + self.axis_value = FakeAxis() + self.rescaled = False + + def axis(self, _name): + return self.axis_value + + def rescale(self): + self.rescaled = True + + layer = FakeLayer() + worker._rescale_with_axis_scales(layer, [ + ("x", {"minimum": 1, "maximum": 100}), + ]) + + self.assertTrue(layer.rescaled) + self.assertEqual(layer.axis_value.scale, "linear") + def test_series_style_maps_to_origin_properties(self) -> None: class FakePlot: def __init__(self): @@ -335,6 +403,19 @@ class OriginWorkerUnitTests(unittest.TestCase): "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 + ), + "抗压强度 (MPa)", + ) + self.assertEqual( + worker._panel_y_axis_fallback({}, series), + "CS_20C", + ) + def test_mixed_xy_series_pass_kind_axis_and_error_columns_to_origin(self) -> None: class FakePlot: pass diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 67634c4..1cb0f90 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -135,7 +135,7 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_publish_output_contains_the_complete_installer_payload(self) -> None: project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8") self.assertIn("..\\install-windows-node.bat", project) - self.assertIn("..\\origin-worker\\requirements.txt", project) + self.assertIn("..\\adapters\\origin.plot@v2\\requirements.txt", project) self.assertIn("..\\adapters\\origin.plot@v2\\adapter.json", project) self.assertIn("adapters\\origin.plot@v2\\origin.plot.v2.json", project) self.assertFalse((ROOT / "install-windows-node.ps1").exists()) @@ -167,7 +167,9 @@ class WindowsNodeSourceTests(unittest.TestCase): def test_adapter_runtime_probe_is_worker_owned_and_reported(self) -> None: runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") - worker = (ROOT / "origin-worker" / "worker.py").read_text(encoding="utf-8") + worker = ( + ROOT / "adapters" / "origin.plot@v2" / "worker.py" + ).read_text(encoding="utf-8") connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker) self.assertIn('["--probe"]', runner) @@ -247,7 +249,9 @@ class WindowsNodeSourceTests(unittest.TestCase): runner = (PROJECT / "AdapterProcessRunner.cs").read_text(encoding="utf-8") connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") project = (PROJECT / "Zcbot.WindowsNode.csproj").read_text(encoding="utf-8") - worker = (ROOT / "origin-worker" / "worker.py").read_text(encoding="utf-8") + worker = ( + ROOT / "adapters" / "origin.plot@v2" / "worker.py" + ).read_text(encoding="utf-8") self.assertIn('Environment.GetEnvironmentVariable("ZCBOT_ORIGIN_PYTHON")', runner) self.assertIn( 'Path.Combine(paths.RootDirectory, "runtimes", runtimeId, "Scripts", "python.exe")', @@ -277,7 +281,7 @@ class WindowsNodeSourceTests(unittest.TestCase): script = (ROOT / "package-origin-adapter.bat").read_text(encoding="utf-8") connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8") self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script) - self.assertIn('"origin-worker\\worker.py"', script) + self.assertIn('"adapters\\origin.plot@v2\\worker.py"', script) self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script) self.assertNotIn("dotnet", script.lower()) diff --git a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj index e589cfe..a011862 100644 --- a/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj +++ b/windows-node/Zcbot.WindowsNode/Zcbot.WindowsNode.csproj @@ -25,12 +25,12 @@ PreserveNewest PreserveNewest - + adapters\origin.plot@v2\worker.py PreserveNewest PreserveNewest - + adapters\origin.plot@v2\requirements.txt PreserveNewest PreserveNewest diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index db532ef..a4da686 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.0", + "adapter_version": "0.9.1", "runtime": "python", "runtime_id": "origin", "entrypoint": "worker.py", diff --git a/windows-node/origin-worker/requirements.txt b/windows-node/adapters/origin.plot@v2/requirements.txt similarity index 100% rename from windows-node/origin-worker/requirements.txt rename to windows-node/adapters/origin.plot@v2/requirements.txt diff --git a/windows-node/origin-worker/worker.py b/windows-node/adapters/origin.plot@v2/worker.py similarity index 95% rename from windows-node/origin-worker/worker.py rename to windows-node/adapters/origin.plot@v2/worker.py index 9763768..aaedb8d 100644 --- a/windows-node/origin-worker/worker.py +++ b/windows-node/adapters/origin.plot@v2/worker.py @@ -78,7 +78,7 @@ PANEL_GEOMETRY = { (58, 57, 38, 31), ), } -ADAPTER_VERSION = "0.9.0" +ADAPTER_VERSION = "0.9.1" def _server_executable(command: str) -> Path: @@ -426,6 +426,22 @@ 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: + """Apply axis transforms before Origin derives automatic limits. + + Origin's linear rescale commonly includes zero. Switching that result to a + logarithmic axis afterwards coerces the zero bound to an unusable value such + as 1E-10. Explicit limits are intentionally left to ``_apply_axis`` after + rescaling so a caller-provided range remains authoritative. + """ + for name, spec in axes: + if isinstance(spec, dict) and "scale" in spec: + layer.axis(name).scale = spec["scale"] + layer.rescale() + + def _apply_series_style(origin_plot: Any, style: Any) -> None: if not isinstance(style, dict): return @@ -583,6 +599,13 @@ def _panel_setting( 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: + """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]: @@ -865,13 +888,15 @@ def _build_recipe_graph( left_specs = [item[0] for item in left_pairs] left_resolved = [item[1] for item in left_pairs] left_plots = _add_xy_plots(layer, worksheets, left_specs, left_resolved) - layer.rescale() + x_axis = _panel_setting(plot_spec, panel, "x_axis") + y_axis = _panel_setting(plot_spec, panel, "y_axis") + _rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)]) _apply_axis( - layer, "x", _panel_setting(plot_spec, panel, "x_axis"), + layer, "x", x_axis, str(left_specs[0]["x"]), ) _apply_axis( - layer, "y", _panel_setting(plot_spec, panel, "y_axis"), "Y" + layer, "y", y_axis, _panel_y_axis_fallback(panel, left_specs) ) legend = _panel_legend(plot_spec, panel) _replace_series_legend(layer, left_specs, left_plots) @@ -906,8 +931,9 @@ def _build_recipe_graph( right_plots = _add_xy_plots( right_layer, worksheets, right_specs, right_resolved ) - right_layer.rescale() - _apply_axis(right_layer, "y2", panel.get("right_y_axis"), "Right Y") + 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") _replace_series_legend(right_layer, right_specs, right_plots) _apply_legend( right_layer, @@ -1076,13 +1102,24 @@ def run(job_dir: Path) -> list[dict[str, Any]]: layer.group() _apply_canvas(graph, plot_spec.get("canvas")) if plot_type not in COMPOSITION_PLOT_TYPES: - layer.rescale() if plot_type not in {"polar", "pie"}: + axes = [ + ("x", plot_spec.get("x_axis")), + ("y", plot_spec.get("y_axis")), + ] + if plot_type == "surface_3d": + 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") ) - _apply_axis(layer, "y", plot_spec.get("y_axis"), "Y") + _apply_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"), diff --git a/windows-node/package-origin-adapter.bat b/windows-node/package-origin-adapter.bat index 7926c53..d65d1f0 100644 --- a/windows-node/package-origin-adapter.bat +++ b/windows-node/package-origin-adapter.bat @@ -21,8 +21,8 @@ if exist "!HASH_PATH!" del /f /q "!HASH_PATH!" mkdir "!PACKAGE_DIR!" copy /y "adapters\origin.plot@v2\adapter.json" "!PACKAGE_DIR!\adapter.json" >nul -copy /y "origin-worker\worker.py" "!PACKAGE_DIR!\worker.py" >nul -copy /y "origin-worker\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul +copy /y "adapters\origin.plot@v2\worker.py" "!PACKAGE_DIR!\worker.py" >nul +copy /y "adapters\origin.plot@v2\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul copy /y "..\software-contracts\origin.plot.v2.json" "!PACKAGE_DIR!\origin.plot.v2.json" >nul if errorlevel 1 goto :failed