fix(origin): correct logarithmic axis scaling

This commit is contained in:
caoqianming 2026-08-17 09:53:29 +08:00
parent 4426ef9214
commit 03371a8825
10 changed files with 157 additions and 21 deletions

View File

@ -8,6 +8,8 @@
## Unreleased
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`
- Origin 绘图新增声明式 Recipe可组合 14 个二维面板、左右坐标轴、误差棒和逐系列样式对已有专业软件任务不满意时Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
- 修复 Origin 二维复合图中多条系列被错误套用同一种红色三角样式、标题和图例缺失或重叠的问题;颜色、线型、点型、误差棒、坐标轴和图例现在会按请求分别呈现。

View File

@ -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`,复用已真机验证的 14 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 真机核对的组合执行器,未连接或写入生产数据库。

View File

@ -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",

View File

@ -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

View File

@ -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())

View File

@ -25,12 +25,12 @@
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\origin-worker\worker.py">
<Content Include="..\adapters\origin.plot@v2\worker.py">
<Link>adapters\origin.plot@v2\worker.py</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
<Content Include="..\origin-worker\requirements.txt">
<Content Include="..\adapters\origin.plot@v2\requirements.txt">
<Link>adapters\origin.plot@v2\requirements.txt</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>

View File

@ -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",

View File

@ -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"),

View File

@ -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