fix(origin): correct logarithmic axis scaling
This commit is contained in:
parent
4426ef9214
commit
03371a8825
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。
|
||||
|
||||
- Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
|
||||
|
||||
- 修复 Origin 二维复合图中多条系列被错误套用同一种红色三角样式、标题和图例缺失或重叠的问题;颜色、线型、点型、误差棒、坐标轴和图例现在会按请求分别呈现。
|
||||
|
|
|
|||
|
|
@ -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 真机核对的组合执行器,未连接或写入生产数据库。
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue