fix(origin): 修复多面板布局与导出基线
This commit is contained in:
parent
03371a8825
commit
79d48b98c9
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。
|
||||
|
||||
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。
|
||||
|
||||
- Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
"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, [
|
||||
worker._rescale_with_axis_scales(
|
||||
layer,
|
||||
[
|
||||
("x", {"scale": "log10", "minimum": 1, "maximum": 100}),
|
||||
("y", {"scale": "ln"}),
|
||||
])
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(layer.events, [
|
||||
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, [
|
||||
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, [
|
||||
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, [
|
||||
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": {
|
||||
"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",
|
||||
}],
|
||||
"series": [
|
||||
{
|
||||
"input": "sample",
|
||||
"x": "age",
|
||||
"y": "strength",
|
||||
"kind": "scatter",
|
||||
"y_error": "sd",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"key": "thermal",
|
||||
"series": [
|
||||
{
|
||||
"input": "sample", "x": "temperature", "y": "tg",
|
||||
"input": "sample",
|
||||
"x": "temperature",
|
||||
"y": "tg",
|
||||
"kind": "line",
|
||||
},
|
||||
{
|
||||
"input": "sample", "x": "temperature", "y": "dtg",
|
||||
"kind": "line", "y_axis": "right",
|
||||
"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": {
|
||||
"operation": {
|
||||
"plot": {
|
||||
"type": "recipe",
|
||||
"recipe_version": 1,
|
||||
"layout": {"rows": 1, "columns": 1},
|
||||
"panels": [{
|
||||
"panels": [
|
||||
{
|
||||
"key": "main",
|
||||
"series": [{
|
||||
"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), {
|
||||
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}),
|
||||
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]])
|
||||
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,
|
||||
}
|
||||
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, [
|
||||
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},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"adapter_version": ADAPTER_VERSION,
|
||||
"software": "OriginPro",
|
||||
"software_version": software_version,
|
||||
"health": health,
|
||||
"detail": detail,
|
||||
}, ensure_ascii=False))
|
||||
},
|
||||
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(
|
||||
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()
|
||||
)
|
||||
).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"],
|
||||
|
|
|
|||
Loading…
Reference in New Issue