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