fix(origin): 修复多面板布局与导出基线
This commit is contained in:
parent
03371a8825
commit
79d48b98c9
|
|
@ -8,6 +8,8 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- 改进 Origin 多面板图排版:由 Origin 统一排列图层,共享横轴时仅在底行显示横轴标题和刻度标签,图例可自动避让数据;同时消除中文标题和坐标轴文字在导出图片中的异常横线。
|
||||||
|
|
||||||
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。
|
- 修复 Origin 对数坐标图可能从 `1E-10` 开始、导致有效数据挤在图形右侧的问题;多面板未单独填写纵轴名称时,也会优先使用面板标题或数据列名,不再显示笼统的 `Y`。
|
||||||
|
|
||||||
- Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
|
- Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `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
|
### 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 检查通过,未连接或写入生产数据库。
|
- **08-17 / Unreleased / Origin 对数坐标自动缩放修复**:adapter 提升至 0.9.1,单图、多面板及右 Y 轴统一在 Origin 自动计算范围前应用坐标轴尺度,避免线性范围中的零值切换为对数轴后被强制展开到 `1E-10`;显式范围仍在自动缩放后覆盖,保持请求权威。多面板缺少 `y_axis` 时改用面板标题或数据列名回退,并在契约中提示对数轴提供正数范围及纵轴标题。相关 88 项 unittest、Python 编译、契约 JSON 和 diff 检查通过,未连接或写入生产数据库。
|
||||||
|
|
||||||
### 2026-08-14
|
### 2026-08-14
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,8 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {"type": "boolean"},
|
"enabled": {"type": "boolean"},
|
||||||
"position": {
|
"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}
|
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
|
||||||
}
|
}
|
||||||
|
|
@ -523,7 +524,8 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {"type": "boolean"},
|
"enabled": {"type": "boolean"},
|
||||||
"position": {
|
"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}
|
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,17 @@ from unittest.mock import patch
|
||||||
|
|
||||||
WORKER_PATH = (
|
WORKER_PATH = (
|
||||||
Path(__file__).resolve().parents[1]
|
Path(__file__).resolve().parents[1]
|
||||||
/ "windows-node" / "adapters" / "origin.plot@v2" / "worker.py"
|
/ "windows-node"
|
||||||
|
/ "adapters"
|
||||||
|
/ "origin.plot@v2"
|
||||||
|
/ "worker.py"
|
||||||
)
|
)
|
||||||
ADAPTER_MANIFEST_PATH = (
|
ADAPTER_MANIFEST_PATH = (
|
||||||
Path(__file__).resolve().parents[1]
|
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)
|
SPEC = importlib.util.spec_from_file_location("zcbot_origin_worker", WORKER_PATH)
|
||||||
assert SPEC and SPEC.loader
|
assert SPEC and SPEC.loader
|
||||||
|
|
@ -26,10 +32,12 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
def _request() -> dict:
|
def _request() -> dict:
|
||||||
return {
|
return {
|
||||||
"inputs": [{"key": "sample"}],
|
"inputs": [{"key": "sample"}],
|
||||||
"operation": {"plot": {
|
"operation": {
|
||||||
|
"plot": {
|
||||||
"type": "line",
|
"type": "line",
|
||||||
"series": [{"input": "sample", "x": "x", "y": "y"}],
|
"series": [{"input": "sample", "x": "x", "y": "y"}],
|
||||||
}},
|
}
|
||||||
|
},
|
||||||
"outputs": [{"key": "figure_png", "format": "png"}],
|
"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:
|
with patch.object(worker, "_windows_file_version", return_value="2025") as read:
|
||||||
self.assertEqual(worker._registered_origin_version(FakeRegistry), "2025")
|
self.assertEqual(worker._registered_origin_version(FakeRegistry), "2025")
|
||||||
read.assert_called_once_with(
|
read.assert_called_once_with(Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe"))
|
||||||
Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe")
|
|
||||||
)
|
|
||||||
FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid"
|
FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid"
|
||||||
self.assertIsNone(worker._registered_origin_version(FakeRegistry))
|
self.assertIsNone(worker._registered_origin_version(FakeRegistry))
|
||||||
|
|
||||||
|
|
@ -78,7 +84,10 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
csv_path = root / "input.csv"
|
csv_path = root / "input.csv"
|
||||||
csv_path.write_text("x,y\n1,2\n3,4\n", encoding="utf-8")
|
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 = root / "input.json"
|
||||||
json_path.write_text(json.dumps([{"x": 1, "y": 2}, {"x": 3, "y": 4}]), encoding="utf-8")
|
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)
|
self.assertEqual(manifest["size_bytes"], 2)
|
||||||
|
|
||||||
def test_axis_title_includes_units(self) -> None:
|
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")
|
self.assertEqual(worker._axis_title(None, "Time"), "Time")
|
||||||
|
|
||||||
def test_layout_helpers_apply_validated_values(self) -> None:
|
def test_layout_helpers_apply_validated_values(self) -> None:
|
||||||
|
|
@ -125,21 +136,38 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
class FakeLabel:
|
class FakeLabel:
|
||||||
def set_int(_, prop, value):
|
def set_int(_, prop, value):
|
||||||
self.labels[(name, prop)] = value
|
self.labels[(name, prop)] = value
|
||||||
|
|
||||||
return FakeLabel()
|
return FakeLabel()
|
||||||
|
|
||||||
layer = FakeLayer()
|
layer = FakeLayer()
|
||||||
worker._apply_axis(layer, "x", {
|
worker._apply_axis(
|
||||||
"title": "Time", "unit": "d", "scale": "log10",
|
layer,
|
||||||
"minimum": 1, "maximum": 100, "major_step": 1,
|
"x",
|
||||||
"tick_label_angle": 45, "tick_label_font_size": 10,
|
{
|
||||||
"title_font_size": 12, "grid": "major_minor",
|
"title": "Time",
|
||||||
}, "X")
|
"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"].title, "Time (d)")
|
||||||
self.assertEqual(layer.axes["x"].scale, "log10")
|
self.assertEqual(layer.axes["x"].scale, "log10")
|
||||||
self.assertEqual(layer.axes["x"].limits, (1, 100, 1))
|
self.assertEqual(layer.axes["x"].limits, (1, 100, 1))
|
||||||
self.assertEqual(layer.values, {
|
self.assertEqual(
|
||||||
"x.label.rotate": 45, "x.label.pt": 10, "x.grid.show": 3,
|
layer.values,
|
||||||
})
|
{
|
||||||
|
"x.label.rotate": 45,
|
||||||
|
"x.label.pt": 10,
|
||||||
|
"x.grid.show": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
|
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
|
||||||
|
|
||||||
def test_rescale_applies_log_scales_before_deriving_limits(self) -> None:
|
def test_rescale_applies_log_scales_before_deriving_limits(self) -> None:
|
||||||
|
|
@ -161,9 +189,7 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
class FakeLayer:
|
class FakeLayer:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.events = []
|
self.events = []
|
||||||
self.axes = {
|
self.axes = {name: FakeAxis(name, self.events) for name in ("x", "y")}
|
||||||
name: FakeAxis(name, self.events) for name in ("x", "y")
|
|
||||||
}
|
|
||||||
|
|
||||||
def axis(self, name):
|
def axis(self, name):
|
||||||
return self.axes[name]
|
return self.axes[name]
|
||||||
|
|
@ -172,16 +198,22 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
self.events.append(("rescale",))
|
self.events.append(("rescale",))
|
||||||
|
|
||||||
layer = FakeLayer()
|
layer = FakeLayer()
|
||||||
worker._rescale_with_axis_scales(layer, [
|
worker._rescale_with_axis_scales(
|
||||||
|
layer,
|
||||||
|
[
|
||||||
("x", {"scale": "log10", "minimum": 1, "maximum": 100}),
|
("x", {"scale": "log10", "minimum": 1, "maximum": 100}),
|
||||||
("y", {"scale": "ln"}),
|
("y", {"scale": "ln"}),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(layer.events, [
|
self.assertEqual(
|
||||||
|
layer.events,
|
||||||
|
[
|
||||||
("scale", "x", "log10"),
|
("scale", "x", "log10"),
|
||||||
("scale", "y", "ln"),
|
("scale", "y", "ln"),
|
||||||
("rescale",),
|
("rescale",),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_rescale_does_not_apply_explicit_limits_before_rescaling(self) -> None:
|
def test_rescale_does_not_apply_explicit_limits_before_rescaling(self) -> None:
|
||||||
class FakeAxis:
|
class FakeAxis:
|
||||||
|
|
@ -202,9 +234,12 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
self.rescaled = True
|
self.rescaled = True
|
||||||
|
|
||||||
layer = FakeLayer()
|
layer = FakeLayer()
|
||||||
worker._rescale_with_axis_scales(layer, [
|
worker._rescale_with_axis_scales(
|
||||||
|
layer,
|
||||||
|
[
|
||||||
("x", {"minimum": 1, "maximum": 100}),
|
("x", {"minimum": 1, "maximum": 100}),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
|
|
||||||
self.assertTrue(layer.rescaled)
|
self.assertTrue(layer.rescaled)
|
||||||
self.assertEqual(layer.axis_value.scale, "linear")
|
self.assertEqual(layer.axis_value.scale, "linear")
|
||||||
|
|
@ -225,10 +260,17 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
self.values[name] = value
|
self.values[name] = value
|
||||||
|
|
||||||
plot = FakePlot()
|
plot = FakePlot()
|
||||||
worker._apply_series_style(plot, {
|
worker._apply_series_style(
|
||||||
"color": "#3366CC", "line_width": 1.5, "line_style": "dash_dot",
|
plot,
|
||||||
"symbol": "diamond", "symbol_size": 8, "transparency": 20,
|
{
|
||||||
})
|
"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.color, (51, 102, 204))
|
||||||
self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4})
|
self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4})
|
||||||
self.assertEqual(plot.symbol_kind, 5)
|
self.assertEqual(plot.symbol_kind, 5)
|
||||||
|
|
@ -270,26 +312,34 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
{"input": "second", "x": "temperature", "y": "value", "label": "28 d"},
|
{"input": "second", "x": "temperature", "y": "value", "label": "28 d"},
|
||||||
]
|
]
|
||||||
resolved, labels = worker._resolve_series(input_data, series)
|
resolved, labels = worker._resolve_series(input_data, series)
|
||||||
self.assertEqual(resolved, [
|
self.assertEqual(
|
||||||
|
resolved,
|
||||||
|
[
|
||||||
{"input": "first", "label": "7 d", "x": 0, "y": 1},
|
{"input": "first", "label": "7 d", "x": 0, "y": 1},
|
||||||
{"input": "second", "label": "28 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"})
|
self.assertEqual(labels, {("first", 1): "7 d", ("second", 1): "28 d"})
|
||||||
|
|
||||||
def test_shared_y_column_rejects_conflicting_labels(self) -> None:
|
def test_shared_y_column_rejects_conflicting_labels(self) -> None:
|
||||||
input_data = {"sample": (["x1", "x2", "y"], [[1, 2, 3]])}
|
input_data = {"sample": (["x1", "x2", "y"], [[1, 2, 3]])}
|
||||||
with self.assertRaisesRegex(ValueError, "SERIES_LABEL_CONFLICT"):
|
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": "x1", "y": "y", "label": "First"},
|
||||||
{"input": "sample", "x": "x2", "y": "y", "label": "Second"},
|
{"input": "sample", "x": "x2", "y": "y", "label": "Second"},
|
||||||
])
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_worker_owns_cross_field_semantic_validation(self) -> None:
|
def test_worker_owns_cross_field_semantic_validation(self) -> None:
|
||||||
request = self._request()
|
request = self._request()
|
||||||
worker._validate_semantics(request)
|
worker._validate_semantics(request)
|
||||||
|
|
||||||
request["operation"]["plot"]["x_axis"] = {
|
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"):
|
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
||||||
worker._validate_semantics(request)
|
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:
|
def test_multi_panel_semantics_cover_layout_axes_and_total_series(self) -> None:
|
||||||
request = {
|
request = {
|
||||||
"inputs": [{"key": "sample"}],
|
"inputs": [{"key": "sample"}],
|
||||||
"operation": {"plot": {
|
"operation": {
|
||||||
|
"plot": {
|
||||||
"type": "multi_panel",
|
"type": "multi_panel",
|
||||||
"layout": {"rows": 1, "columns": 2, "share_x": True},
|
"layout": {"rows": 1, "columns": 2, "share_x": True},
|
||||||
"panels": [
|
"panels": [
|
||||||
{
|
{
|
||||||
"key": "strength",
|
"key": "strength",
|
||||||
"series": [{
|
"series": [
|
||||||
"input": "sample", "x": "age", "y": "strength",
|
{
|
||||||
"kind": "scatter", "y_error": "sd",
|
"input": "sample",
|
||||||
}],
|
"x": "age",
|
||||||
|
"y": "strength",
|
||||||
|
"kind": "scatter",
|
||||||
|
"y_error": "sd",
|
||||||
|
}
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "thermal",
|
"key": "thermal",
|
||||||
"series": [
|
"series": [
|
||||||
{
|
{
|
||||||
"input": "sample", "x": "temperature", "y": "tg",
|
"input": "sample",
|
||||||
|
"x": "temperature",
|
||||||
|
"y": "tg",
|
||||||
"kind": "line",
|
"kind": "line",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"input": "sample", "x": "temperature", "y": "dtg",
|
"input": "sample",
|
||||||
"kind": "line", "y_axis": "right",
|
"x": "temperature",
|
||||||
|
"y": "dtg",
|
||||||
|
"kind": "line",
|
||||||
|
"y_axis": "right",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"right_y_axis": {"title": "DTG"},
|
"right_y_axis": {"title": "DTG"},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}},
|
}
|
||||||
|
},
|
||||||
"outputs": [{"key": "figure_png", "format": "png"}],
|
"outputs": [{"key": "figure_png", "format": "png"}],
|
||||||
}
|
}
|
||||||
worker._validate_semantics(request)
|
worker._validate_semantics(request)
|
||||||
|
|
@ -338,10 +400,13 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
worker._validate_semantics(request)
|
worker._validate_semantics(request)
|
||||||
|
|
||||||
request["operation"]["plot"]["layout"] = {
|
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"] = {
|
request["operation"]["plot"]["panels"][0]["x_axis"] = {
|
||||||
"scale": "log10", "minimum": 0,
|
"scale": "log10",
|
||||||
|
"minimum": 0,
|
||||||
}
|
}
|
||||||
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
||||||
worker._validate_semantics(request)
|
worker._validate_semantics(request)
|
||||||
|
|
@ -352,7 +417,8 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
|
|
||||||
request["operation"]["plot"]["panels"][0].pop("x_axis")
|
request["operation"]["plot"]["panels"][0].pop("x_axis")
|
||||||
request["operation"]["plot"]["x_axis"] = {
|
request["operation"]["plot"]["x_axis"] = {
|
||||||
"scale": "log10", "minimum": 0,
|
"scale": "log10",
|
||||||
|
"minimum": 0,
|
||||||
}
|
}
|
||||||
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
|
||||||
worker._validate_semantics(request)
|
worker._validate_semantics(request)
|
||||||
|
|
@ -360,20 +426,26 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
def test_recipe_uses_the_composition_semantics(self) -> None:
|
def test_recipe_uses_the_composition_semantics(self) -> None:
|
||||||
request = {
|
request = {
|
||||||
"inputs": [{"key": "sample"}],
|
"inputs": [{"key": "sample"}],
|
||||||
"operation": {"plot": {
|
"operation": {
|
||||||
|
"plot": {
|
||||||
"type": "recipe",
|
"type": "recipe",
|
||||||
"recipe_version": 1,
|
"recipe_version": 1,
|
||||||
"layout": {"rows": 1, "columns": 1},
|
"layout": {"rows": 1, "columns": 1},
|
||||||
"panels": [{
|
"panels": [
|
||||||
|
{
|
||||||
"key": "main",
|
"key": "main",
|
||||||
"series": [{
|
"series": [
|
||||||
|
{
|
||||||
"input": "sample",
|
"input": "sample",
|
||||||
"x": "age",
|
"x": "age",
|
||||||
"y": "strength",
|
"y": "strength",
|
||||||
"kind": "line_scatter",
|
"kind": "line_scatter",
|
||||||
}],
|
}
|
||||||
}],
|
],
|
||||||
}},
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
"outputs": [{"key": "figure_png", "format": "png"}],
|
"outputs": [{"key": "figure_png", "format": "png"}],
|
||||||
}
|
}
|
||||||
worker._validate_semantics(request)
|
worker._validate_semantics(request)
|
||||||
|
|
@ -397,18 +469,19 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
worker._panel_setting(plot, panel, "y_axis"),
|
worker._panel_setting(plot, panel, "y_axis"),
|
||||||
{"title": "Strength", "unit": "MPa"},
|
{"title": "Strength", "unit": "MPa"},
|
||||||
)
|
)
|
||||||
self.assertEqual(worker._panel_legend(plot, panel), {
|
self.assertEqual(
|
||||||
|
worker._panel_legend(plot, panel),
|
||||||
|
{
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"position": "top_right",
|
"position": "top_right",
|
||||||
"font_size": 8,
|
"font_size": 8,
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def test_panel_y_axis_fallback_prefers_title_then_column_name(self) -> None:
|
def test_panel_y_axis_fallback_prefers_title_then_column_name(self) -> None:
|
||||||
series = [{"y": "CS_20C"}]
|
series = [{"y": "CS_20C"}]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
worker._panel_y_axis_fallback(
|
worker._panel_y_axis_fallback({"title": "抗压强度 (MPa)"}, series),
|
||||||
{"title": "抗压强度 (MPa)"}, series
|
|
||||||
),
|
|
||||||
"抗压强度 (MPa)",
|
"抗压强度 (MPa)",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|
@ -445,8 +518,12 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
layer = FakeLayer()
|
layer = FakeLayer()
|
||||||
series = [
|
series = [
|
||||||
{
|
{
|
||||||
"input": "sample", "x": "age", "y": "strength",
|
"input": "sample",
|
||||||
"kind": "scatter", "x_error": "age_sd", "y_error": "strength_sd",
|
"x": "age",
|
||||||
|
"y": "strength",
|
||||||
|
"kind": "scatter",
|
||||||
|
"x_error": "age_sd",
|
||||||
|
"y_error": "strength_sd",
|
||||||
},
|
},
|
||||||
{"input": "sample", "x": "age", "y": "fit", "kind": "line"},
|
{"input": "sample", "x": "age", "y": "fit", "kind": "line"},
|
||||||
]
|
]
|
||||||
|
|
@ -455,24 +532,47 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
{"input": "sample", "x": 0, "y": 4},
|
{"input": "sample", "x": 0, "y": 4},
|
||||||
]
|
]
|
||||||
worker._add_xy_plots(layer, {"sample": "worksheet"}, series, resolved)
|
worker._add_xy_plots(layer, {"sample": "worksheet"}, series, resolved)
|
||||||
self.assertEqual(layer.calls, [
|
self.assertEqual(
|
||||||
("worksheet", {"coly": 1, "colx": 0, "type": "s", "colxerr": 2, "colyerr": 3}),
|
layer.calls,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"worksheet",
|
||||||
|
{"coly": 1, "colx": 0, "type": "s", "colxerr": 2, "colyerr": 3},
|
||||||
|
),
|
||||||
("worksheet", {"coly": 4, "colx": 0, "type": "l"}),
|
("worksheet", {"coly": 4, "colx": 0, "type": "l"}),
|
||||||
])
|
],
|
||||||
|
)
|
||||||
self.assertFalse(layer.grouped)
|
self.assertFalse(layer.grouped)
|
||||||
|
|
||||||
def test_series_support_xyz_and_error_roles(self) -> None:
|
def test_series_support_xyz_and_error_roles(self) -> None:
|
||||||
resolved, labels = worker._resolve_series(
|
resolved, labels = worker._resolve_series(
|
||||||
{"sample": (["x", "y", "z", "x_sd", "y_sd"], [[0, 1, 2, 0.1, 0.2]])},
|
{"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): "测量值"})
|
self.assertEqual(labels, {("sample", 1): "测量值"})
|
||||||
|
|
||||||
def test_series_support_second_batch_roles(self) -> None:
|
def test_series_support_second_batch_roles(self) -> None:
|
||||||
|
|
@ -483,23 +583,44 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
[[0, 1, 2, 0.5, 1.5]],
|
[[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:
|
def test_second_batch_value_validation_rejects_invalid_data(self) -> None:
|
||||||
input_data = {
|
input_data = {"sample": (["x", "y", "size", "lower", "upper"], [[0, 1, 0, 2, 1]])}
|
||||||
"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"):
|
with self.assertRaisesRegex(ValueError, "SIZE_VALUE_NOT_POSITIVE"):
|
||||||
worker._validate_bubble_values(input_data, resolved)
|
worker._validate_bubble_values(input_data, resolved)
|
||||||
with self.assertRaisesRegex(ValueError, "BAND_BOUNDS_INVERTED"):
|
with self.assertRaisesRegex(ValueError, "BAND_BOUNDS_INVERTED"):
|
||||||
|
|
@ -653,9 +774,13 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
self.assertEqual(layer.group_call, (True, 0, 1))
|
self.assertEqual(layer.group_call, (True, 0, 1))
|
||||||
self.assertTrue(layer.activated)
|
self.assertTrue(layer.activated)
|
||||||
self.assertEqual(layer.command, "layer -b s 1")
|
self.assertEqual(layer.command, "layer -b s 1")
|
||||||
self.assertEqual(op.sheet.columns[1:], [
|
self.assertEqual(
|
||||||
(1, [10, 12], "A"), (2, [5, 6], "B"),
|
op.sheet.columns[1:],
|
||||||
])
|
[
|
||||||
|
(1, [10, 12], "A"),
|
||||||
|
(2, [5, 6], "B"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_stacked_graph_rejects_mismatched_x_values(self) -> None:
|
def test_stacked_graph_rejects_mismatched_x_values(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "STACKED_PLOT_X_VALUES_MISMATCH"):
|
with self.assertRaisesRegex(ValueError, "STACKED_PLOT_X_VALUES_MISMATCH"):
|
||||||
|
|
@ -696,10 +821,13 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
return self.legend
|
return self.legend
|
||||||
|
|
||||||
layer = Layer()
|
layer = Layer()
|
||||||
worker._replace_series_legend(layer, [
|
worker._replace_series_legend(
|
||||||
|
layer,
|
||||||
|
[
|
||||||
{"y": "a", "label": "Phase A"},
|
{"y": "a", "label": "Phase A"},
|
||||||
{"y": "phase_b"},
|
{"y": "phase_b"},
|
||||||
])
|
],
|
||||||
|
)
|
||||||
self.assertEqual(layer.name, "Legend")
|
self.assertEqual(layer.name, "Legend")
|
||||||
self.assertEqual(layer.legend.text, r"\l(1) Phase A" "\n" r"\l(2) phase_b")
|
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["left"], 382)
|
||||||
self.assertEqual(layer.title.values["top"], 229)
|
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:
|
def test_heatmap_matrix_accepts_complete_unordered_grid(self) -> None:
|
||||||
matrix, xy_map = worker._heatmap_matrix(
|
matrix, xy_map = worker._heatmap_matrix(
|
||||||
[[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]],
|
[[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},
|
{"x": 0, "y": 1, "z": 2},
|
||||||
)
|
)
|
||||||
with self.assertRaisesRegex(ValueError, "HEATMAP_COORDINATES_DUPLICATED"):
|
with self.assertRaisesRegex(ValueError, "HEATMAP_COORDINATES_DUPLICATED"):
|
||||||
worker._heatmap_matrix(
|
worker._heatmap_matrix([[0, 10, 1], [0, 10, 2]], {"x": 0, "y": 1, "z": 2})
|
||||||
[[0, 10, 1], [0, 10, 2]], {"x": 0, "y": 1, "z": 2}
|
|
||||||
)
|
|
||||||
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_NOT_REGULAR"):
|
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_NOT_REGULAR"):
|
||||||
worker._heatmap_matrix(
|
worker._heatmap_matrix(
|
||||||
[
|
[
|
||||||
[0, 10, 1], [1, 10, 2], [3, 10, 3],
|
[0, 10, 1],
|
||||||
[0, 20, 4], [1, 20, 5], [3, 20, 6],
|
[1, 10, 2],
|
||||||
|
[3, 10, 3],
|
||||||
|
[0, 20, 4],
|
||||||
|
[1, 20, 5],
|
||||||
|
[3, 20, 6],
|
||||||
],
|
],
|
||||||
{"x": 0, "y": 1, "z": 2},
|
{"x": 0, "y": 1, "z": 2},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -378,7 +378,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
|
||||||
"grid": "major",
|
"grid": "major",
|
||||||
},
|
},
|
||||||
"y_axis": {"minimum": 0, "maximum": 80, "major_step": 10},
|
"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": [{
|
"outputs": [{
|
||||||
"key": "figure_png", "type": "figure", "format": "png",
|
"key": "figure_png", "type": "figure", "format": "png",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"capability": "origin.plot@v2",
|
"capability": "origin.plot@v2",
|
||||||
"adapter_version": "0.9.1",
|
"adapter_version": "0.9.2",
|
||||||
"runtime": "python",
|
"runtime": "python",
|
||||||
"runtime_id": "origin",
|
"runtime_id": "origin",
|
||||||
"entrypoint": "worker.py",
|
"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
|
installs packages, evaluates user code, downloads data, or resolves paths from the
|
||||||
request. terminal.json is its only terminal-state contract.
|
request. terminal.json is its only terminal-state contract.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
|
|
@ -67,18 +68,13 @@ LEGEND_POSITIONS = {
|
||||||
"bottom_left": (700, 7200),
|
"bottom_left": (700, 7200),
|
||||||
"bottom_right": (6800, 7200),
|
"bottom_right": (6800, 7200),
|
||||||
}
|
}
|
||||||
PANEL_GEOMETRY = {
|
PANEL_LAYOUT = {
|
||||||
(1, 1): ((13, 14, 74, 72),),
|
(1, 1): {"left": 13, "right": 13, "top": 14, "bottom": 14, "xgap": 0, "ygap": 0},
|
||||||
(1, 2): ((9, 14, 38, 72), (58, 14, 38, 72)),
|
(1, 2): {"left": 9, "right": 4, "top": 14, "bottom": 14, "xgap": 11, "ygap": 0},
|
||||||
(2, 1): ((13, 14, 74, 31), (13, 57, 74, 31)),
|
(2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10},
|
||||||
(2, 2): (
|
(2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10},
|
||||||
(9, 14, 38, 31),
|
|
||||||
(58, 14, 38, 31),
|
|
||||||
(9, 57, 38, 31),
|
|
||||||
(58, 57, 38, 31),
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
ADAPTER_VERSION = "0.9.1"
|
ADAPTER_VERSION = "0.9.2"
|
||||||
|
|
||||||
|
|
||||||
def _server_executable(command: str) -> Path:
|
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 = ctypes.WinDLL("version", use_last_error=True)
|
||||||
version_api.GetFileVersionInfoSizeW.argtypes = [
|
version_api.GetFileVersionInfoSizeW.argtypes = [
|
||||||
wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)
|
wintypes.LPCWSTR,
|
||||||
|
ctypes.POINTER(wintypes.DWORD),
|
||||||
]
|
]
|
||||||
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
|
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
|
||||||
version_api.GetFileVersionInfoW.argtypes = [
|
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.GetFileVersionInfoW.restype = wintypes.BOOL
|
||||||
version_api.VerQueryValueW.argtypes = [
|
version_api.VerQueryValueW.argtypes = [
|
||||||
|
|
@ -138,9 +138,7 @@ def _windows_file_version(path: Path) -> str:
|
||||||
raise ctypes.WinError(ctypes.get_last_error())
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
value = ctypes.c_void_p()
|
value = ctypes.c_void_p()
|
||||||
value_size = wintypes.UINT()
|
value_size = wintypes.UINT()
|
||||||
if not version_api.VerQueryValueW(
|
if not version_api.VerQueryValueW(buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)):
|
||||||
buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)
|
|
||||||
):
|
|
||||||
raise ctypes.WinError(ctypes.get_last_error())
|
raise ctypes.WinError(ctypes.get_last_error())
|
||||||
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
|
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
|
||||||
translation = ctypes.c_void_p()
|
translation = ctypes.c_void_p()
|
||||||
|
|
@ -187,9 +185,7 @@ def _windows_file_version(path: Path) -> str:
|
||||||
|
|
||||||
def _registered_origin_version(winreg: Any) -> str | None:
|
def _registered_origin_version(winreg: Any) -> str | None:
|
||||||
try:
|
try:
|
||||||
with winreg.OpenKey(
|
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID") as clsid_key:
|
||||||
winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"
|
|
||||||
) as clsid_key:
|
|
||||||
clsid = winreg.QueryValueEx(clsid_key, None)[0]
|
clsid = winreg.QueryValueEx(clsid_key, None)[0]
|
||||||
with winreg.OpenKey(
|
with winreg.OpenKey(
|
||||||
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
|
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
|
||||||
|
|
@ -217,16 +213,27 @@ def _probe() -> int:
|
||||||
originpro_version = version("originpro")
|
originpro_version = version("originpro")
|
||||||
detail = f"Origin COM 与托管 Python 运行时可用(originpro {originpro_version})"
|
detail = f"Origin COM 与托管 Python 运行时可用(originpro {originpro_version})"
|
||||||
del originpro
|
del originpro
|
||||||
except (FileNotFoundError, ImportError, OSError, PackageNotFoundError, RuntimeError) as exc:
|
except (
|
||||||
|
FileNotFoundError,
|
||||||
|
ImportError,
|
||||||
|
OSError,
|
||||||
|
PackageNotFoundError,
|
||||||
|
RuntimeError,
|
||||||
|
) as exc:
|
||||||
health = "unavailable"
|
health = "unavailable"
|
||||||
detail = str(exc)
|
detail = str(exc)
|
||||||
print(json.dumps({
|
print(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
"adapter_version": ADAPTER_VERSION,
|
"adapter_version": ADAPTER_VERSION,
|
||||||
"software": "OriginPro",
|
"software": "OriginPro",
|
||||||
"software_version": software_version,
|
"software_version": software_version,
|
||||||
"health": health,
|
"health": health,
|
||||||
"detail": detail,
|
"detail": detail,
|
||||||
}, ensure_ascii=False))
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -261,10 +268,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
|
||||||
_validate_axis(plot.get(axis_name), axis_name)
|
_validate_axis(plot.get(axis_name), axis_name)
|
||||||
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
|
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
|
||||||
if layout.get(share_name):
|
if layout.get(share_name):
|
||||||
settings = [
|
settings = [_panel_setting(plot, panel, axis_name) or {} for panel in panels]
|
||||||
_panel_setting(plot, panel, axis_name) or {}
|
|
||||||
for panel in panels
|
|
||||||
]
|
|
||||||
if any(value != settings[0] for value in settings[1:]):
|
if any(value != settings[0] for value in settings[1:]):
|
||||||
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
|
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
|
||||||
else:
|
else:
|
||||||
|
|
@ -281,11 +285,16 @@ def _validate_semantics(request: dict[str, Any]) -> None:
|
||||||
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
|
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
|
||||||
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
|
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
|
||||||
required_roles = (
|
required_roles = (
|
||||||
("x", "y", "z") if plot_type in XYZ_PLOT_TYPES
|
("x", "y", "z")
|
||||||
else ("x", "y", "y_error") if plot_type == "y_error"
|
if plot_type in XYZ_PLOT_TYPES
|
||||||
else ("y",) if plot_type in {"box", "histogram"}
|
else ("x", "y", "y_error")
|
||||||
else ("x", "y", "size") if plot_type == "bubble"
|
if plot_type == "y_error"
|
||||||
else ("x", "y", "lower", "upper") if plot_type == "band"
|
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")
|
else ("x", "y")
|
||||||
)
|
)
|
||||||
for item in series:
|
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):
|
if isinstance(value, list) and value and all(isinstance(item, dict) for item in value):
|
||||||
headers = list(value[0])
|
headers = list(value[0])
|
||||||
return headers, [[item.get(name) for name in headers] for item in value]
|
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)
|
headers = list(value)
|
||||||
length = max(len(value[name]) for name in headers)
|
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")
|
raise ValueError("JSON_INPUT_SHAPE_UNSUPPORTED")
|
||||||
if suffix == ".xlsx":
|
if suffix == ".xlsx":
|
||||||
from openpyxl import load_workbook
|
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]:
|
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:
|
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"))
|
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:
|
def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
|
||||||
settings = spec if isinstance(spec, dict) else {}
|
settings = spec if isinstance(spec, dict) else {}
|
||||||
axis = layer.axis(name)
|
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)
|
layer.set_int(f"{name}.grid.show", grid_value)
|
||||||
|
|
||||||
|
|
||||||
def _rescale_with_axis_scales(
|
def _rescale_with_axis_scales(layer: Any, axes: list[tuple[str, Any]]) -> None:
|
||||||
layer: Any, axes: list[tuple[str, Any]]
|
|
||||||
) -> None:
|
|
||||||
"""Apply axis transforms before Origin derives automatic limits.
|
"""Apply axis transforms before Origin derives automatic limits.
|
||||||
|
|
||||||
Origin's linear rescale commonly includes zero. Switching that result to a
|
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)
|
label.show = legend.get("enabled", True)
|
||||||
if "font_size" in legend:
|
if "font_size" in legend:
|
||||||
label.set_int("fsize", round(legend["font_size"]))
|
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:
|
if attach_to_layer:
|
||||||
x_fraction, y_fraction = {
|
x_fraction, y_fraction = {
|
||||||
"top_left": (0.03, 0.04),
|
"top_left": (0.03, 0.04),
|
||||||
|
|
@ -498,9 +557,7 @@ def _apply_legend(
|
||||||
}[legend["position"]]
|
}[legend["position"]]
|
||||||
if vertical_offset:
|
if vertical_offset:
|
||||||
y_fraction += 0.22 if legend["position"].startswith("top") else -0.22
|
y_fraction += 0.22 if legend["position"].startswith("top") else -0.22
|
||||||
left, top = _panel_page_pixel(
|
left, top = _panel_page_pixel(graph, geometry, x_fraction, y_fraction)
|
||||||
graph, geometry, x_fraction, y_fraction
|
|
||||||
)
|
|
||||||
label.set_int("attach", 1)
|
label.set_int("attach", 1)
|
||||||
label.set_int("left", round(left))
|
label.set_int("left", round(left))
|
||||||
label.set_int("top", round(top))
|
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))
|
title.set_int("top", round(graph.get_float("height") * 0.01))
|
||||||
|
|
||||||
|
|
||||||
def _panel_setting(
|
def _panel_setting(plot_spec: dict[str, Any], panel: dict[str, Any], name: str) -> Any:
|
||||||
plot_spec: dict[str, Any], panel: dict[str, Any], name: str
|
|
||||||
) -> Any:
|
|
||||||
value = panel.get(name)
|
value = panel.get(name)
|
||||||
return value if value is not None else plot_spec.get(name)
|
return value if value is not None else plot_spec.get(name)
|
||||||
|
|
||||||
|
|
||||||
def _panel_y_axis_fallback(
|
def _panel_y_axis_fallback(panel: dict[str, Any], series_specs: list[dict[str, Any]]) -> str:
|
||||||
panel: dict[str, Any], series_specs: list[dict[str, Any]]
|
|
||||||
) -> str:
|
|
||||||
"""Prefer meaningful declarative metadata over Origin's generic ``Y``."""
|
"""Prefer meaningful declarative metadata over Origin's generic ``Y``."""
|
||||||
return str(panel.get("title") or series_specs[0].get("y") or "Y")
|
return str(panel.get("title") or series_specs[0].get("y") or "Y")
|
||||||
|
|
||||||
|
|
||||||
def _panel_legend(
|
def _panel_legend(plot_spec: dict[str, Any], panel: dict[str, Any]) -> dict[str, Any]:
|
||||||
plot_spec: dict[str, Any], panel: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
return {
|
||||||
"enabled": True,
|
"enabled": True,
|
||||||
"position": "top_right",
|
"position": "top_right",
|
||||||
|
|
@ -619,7 +670,9 @@ def _panel_legend(
|
||||||
|
|
||||||
def _input_file(job_dir: Path, key: str) -> Path:
|
def _input_file(job_dir: Path, key: str) -> Path:
|
||||||
directory = job_dir / "input" / key
|
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:
|
if len(files) != 1:
|
||||||
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
|
raise ValueError(f"INPUT_FILE_COUNT_INVALID:{key}")
|
||||||
return files[0]
|
return files[0]
|
||||||
|
|
@ -636,9 +689,8 @@ def _resolve_series(
|
||||||
headers, _ = input_data[input_key]
|
headers, _ = input_data[input_key]
|
||||||
role_indexes = {
|
role_indexes = {
|
||||||
role: _column_index(headers, series[role], role)
|
role: _column_index(headers, series[role], role)
|
||||||
for role in (
|
for role in ("x", "y", "z", "size", "lower", "upper", "x_error", "y_error")
|
||||||
"x", "y", "z", "size", "lower", "upper", "x_error", "y_error"
|
if role in series
|
||||||
) if role in series
|
|
||||||
}
|
}
|
||||||
y_index = role_indexes["y"]
|
y_index = role_indexes["y"]
|
||||||
label = series.get("label")
|
label = series.get("label")
|
||||||
|
|
@ -767,15 +819,13 @@ def _add_xy_plots(
|
||||||
plot_count = len(layer.plot_list())
|
plot_count = len(layer.plot_list())
|
||||||
plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
|
plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
|
||||||
plots.append(plot)
|
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):
|
if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs):
|
||||||
layer.group()
|
layer.group()
|
||||||
else:
|
else:
|
||||||
# Explicit series styles require independent plots.
|
# Explicit series styles require independent plots.
|
||||||
layer.group(False)
|
layer.group(False)
|
||||||
for plot, errors, series_spec in zip(
|
for plot, errors, series_spec in zip(plots, error_plots, series_specs, strict=True):
|
||||||
plots, error_plots, series_specs, strict=True
|
|
||||||
):
|
|
||||||
style = series_spec.get("style")
|
style = series_spec.get("style")
|
||||||
_apply_series_style(plot, style)
|
_apply_series_style(plot, style)
|
||||||
for error_plot in errors:
|
for error_plot in errors:
|
||||||
|
|
@ -790,9 +840,7 @@ def _configure_bubble_plot(
|
||||||
resolved: dict[str, Any],
|
resolved: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"])
|
origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"])
|
||||||
origin_plot.symbol_sizefactor = float(
|
origin_plot.symbol_sizefactor = float((series_spec.get("style") or {}).get("symbol_size", 10))
|
||||||
(series_spec.get("style") or {}).get("symbol_size", 10)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_stacked_graph(
|
def _build_stacked_graph(
|
||||||
|
|
@ -856,24 +904,15 @@ def _build_recipe_graph(
|
||||||
while len(graph) < len(panels):
|
while len(graph) < len(panels):
|
||||||
graph.add_layer(0)
|
graph.add_layer(0)
|
||||||
_apply_canvas(graph, plot_spec.get("canvas"))
|
_apply_canvas(graph, plot_spec.get("canvas"))
|
||||||
primary_layers = list(graph)[:len(panels)]
|
primary_layers = list(graph)[: len(panels)]
|
||||||
for layer, (left, top, width, height) in zip(
|
geometries = _arrange_panel_layers(primary_layers, grid)
|
||||||
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)
|
|
||||||
|
|
||||||
resolved_offset = 0
|
resolved_offset = 0
|
||||||
for panel, layer, geometry in zip(
|
for panel_index, (panel, layer, geometry) in enumerate(
|
||||||
panels, primary_layers, PANEL_GEOMETRY[grid], strict=True
|
zip(panels, primary_layers, geometries, strict=True)
|
||||||
):
|
):
|
||||||
panel_series = panel["series"]
|
panel_series = panel["series"]
|
||||||
resolved_panel = resolved_series[
|
resolved_panel = resolved_series[resolved_offset : resolved_offset + len(panel_series)]
|
||||||
resolved_offset:resolved_offset + len(panel_series)
|
|
||||||
]
|
|
||||||
resolved_offset += len(panel_series)
|
resolved_offset += len(panel_series)
|
||||||
left_pairs = [
|
left_pairs = [
|
||||||
(spec, resolved)
|
(spec, resolved)
|
||||||
|
|
@ -892,11 +931,18 @@ def _build_recipe_graph(
|
||||||
y_axis = _panel_setting(plot_spec, panel, "y_axis")
|
y_axis = _panel_setting(plot_spec, panel, "y_axis")
|
||||||
_rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)])
|
_rescale_with_axis_scales(layer, [("x", x_axis), ("y", y_axis)])
|
||||||
_apply_axis(
|
_apply_axis(
|
||||||
layer, "x", x_axis,
|
layer,
|
||||||
|
"x",
|
||||||
|
x_axis,
|
||||||
str(left_specs[0]["x"]),
|
str(left_specs[0]["x"]),
|
||||||
)
|
)
|
||||||
_apply_axis(
|
_apply_axis(layer, "y", y_axis, _panel_y_axis_fallback(panel, left_specs))
|
||||||
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)
|
legend = _panel_legend(plot_spec, panel)
|
||||||
_replace_series_legend(layer, left_specs, left_plots)
|
_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 = layer.add_label(str(panel["panel_label"]))
|
||||||
panel_label.set_int("background", 0)
|
panel_label.set_int("background", 0)
|
||||||
panel_label.set_int("attach", 1)
|
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_left, panel_top = _panel_page_pixel(graph, geometry, 0.02, -0.14)
|
||||||
panel_label.set_float("left", round(panel_left))
|
panel_label.set_float("left", round(panel_left))
|
||||||
panel_label.set_float("top", round(panel_top))
|
panel_label.set_float("top", round(panel_top))
|
||||||
|
|
@ -928,9 +976,7 @@ def _build_recipe_graph(
|
||||||
right_layer = graph.add_layer(2)
|
right_layer = graph.add_layer(2)
|
||||||
right_specs = [item[0] for item in right_pairs]
|
right_specs = [item[0] for item in right_pairs]
|
||||||
right_resolved = [item[1] for item in right_pairs]
|
right_resolved = [item[1] for item in right_pairs]
|
||||||
right_plots = _add_xy_plots(
|
right_plots = _add_xy_plots(right_layer, worksheets, right_specs, right_resolved)
|
||||||
right_layer, worksheets, right_specs, right_resolved
|
|
||||||
)
|
|
||||||
right_y_axis = panel.get("right_y_axis")
|
right_y_axis = panel.get("right_y_axis")
|
||||||
_rescale_with_axis_scales(right_layer, [("y2", right_y_axis)])
|
_rescale_with_axis_scales(right_layer, [("y2", right_y_axis)])
|
||||||
_apply_axis(right_layer, "y2", right_y_axis, "Right Y")
|
_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)
|
zip(series_specs, resolved_series, strict=True)
|
||||||
):
|
):
|
||||||
worksheet = worksheets[resolved["input"]]
|
worksheet = worksheets[resolved["input"]]
|
||||||
upper_plot = layer.add_plot(
|
upper_plot = layer.add_plot(worksheet, coly=resolved["upper"], colx=resolved["x"], type="l")
|
||||||
worksheet, coly=resolved["upper"], colx=resolved["x"], type="l"
|
lower_plot = layer.add_plot(worksheet, coly=resolved["lower"], 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)
|
upper_plot.set_fill_area(type=9)
|
||||||
_apply_series_style(upper_plot, series_spec.get("style"))
|
_apply_series_style(upper_plot, series_spec.get("style"))
|
||||||
_apply_series_style(lower_plot, series_spec.get("style"))
|
_apply_series_style(lower_plot, series_spec.get("style"))
|
||||||
center_plot = layer.add_plot(
|
center_plot = layer.add_plot(worksheet, coly=resolved["y"], colx=resolved["x"], type="l")
|
||||||
worksheet, coly=resolved["y"], colx=resolved["x"], type="l"
|
|
||||||
)
|
|
||||||
center_plots.append(center_plot)
|
center_plots.append(center_plot)
|
||||||
label = series_spec.get("label") or series_spec["y"]
|
label = series_spec.get("label") or series_spec["y"]
|
||||||
legend_entries.append(f"\\l({index * 3 + 3}) {label}")
|
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_spec = request["operation"]["plot"]
|
||||||
plot_type = plot_spec["type"]
|
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")
|
raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
|
||||||
series_specs = (
|
series_specs = (
|
||||||
[item for panel in plot_spec["panels"] for item in panel["series"]]
|
[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)
|
op.set_show(False)
|
||||||
try:
|
try:
|
||||||
op.new()
|
op.new()
|
||||||
|
_configure_origin_session(op)
|
||||||
worksheets: dict[str, Any] = {}
|
worksheets: dict[str, Any] = {}
|
||||||
for input_spec in input_specs:
|
for input_spec in input_specs:
|
||||||
input_key = input_spec["key"]
|
input_key = input_spec["key"]
|
||||||
|
|
@ -1040,9 +1084,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
)
|
)
|
||||||
band_legend = None
|
band_legend = None
|
||||||
if plot_type in COMPOSITION_PLOT_TYPES:
|
if plot_type in COMPOSITION_PLOT_TYPES:
|
||||||
graph = _build_recipe_graph(
|
graph = _build_recipe_graph(op, plot_spec, worksheets, resolved_series)
|
||||||
op, plot_spec, worksheets, resolved_series
|
|
||||||
)
|
|
||||||
layer = graph[0]
|
layer = graph[0]
|
||||||
origin_plots = []
|
origin_plots = []
|
||||||
elif plot_type == "band":
|
elif plot_type == "band":
|
||||||
|
|
@ -1079,9 +1121,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
graph = op.new_graph(template=template)
|
graph = op.new_graph(template=template)
|
||||||
layer = graph[0]
|
layer = graph[0]
|
||||||
origin_plots = []
|
origin_plots = []
|
||||||
for series_spec, resolved in zip(
|
for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
|
||||||
series_specs, resolved_series, strict=True
|
|
||||||
):
|
|
||||||
arguments = {
|
arguments = {
|
||||||
"coly": resolved["y"],
|
"coly": resolved["y"],
|
||||||
"type": origin_plot_type,
|
"type": origin_plot_type,
|
||||||
|
|
@ -1092,9 +1132,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
arguments["colz"] = resolved["z"]
|
arguments["colz"] = resolved["z"]
|
||||||
if plot_type == "y_error":
|
if plot_type == "y_error":
|
||||||
arguments["colyerr"] = resolved["y_error"]
|
arguments["colyerr"] = resolved["y_error"]
|
||||||
origin_plot = layer.add_plot(
|
origin_plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
|
||||||
worksheets[resolved["input"]], **arguments
|
|
||||||
)
|
|
||||||
if plot_type == "bubble":
|
if plot_type == "bubble":
|
||||||
_configure_bubble_plot(op, origin_plot, series_spec, resolved)
|
_configure_bubble_plot(op, origin_plot, series_spec, resolved)
|
||||||
origin_plots.append(origin_plot)
|
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")))
|
axes.append(("z", plot_spec.get("z_axis")))
|
||||||
_rescale_with_axis_scales(layer, axes)
|
_rescale_with_axis_scales(layer, axes)
|
||||||
_apply_axis(
|
_apply_axis(
|
||||||
layer, "x", plot_spec.get("x_axis"),
|
layer,
|
||||||
str(series_specs[0].get("x") or "X")
|
"x",
|
||||||
|
plot_spec.get("x_axis"),
|
||||||
|
str(series_specs[0].get("x") or "X"),
|
||||||
)
|
)
|
||||||
_apply_axis(
|
_apply_axis(
|
||||||
layer, "y", plot_spec.get("y_axis"),
|
layer,
|
||||||
|
"y",
|
||||||
|
plot_spec.get("y_axis"),
|
||||||
str(series_specs[0].get("y") or "Y"),
|
str(series_specs[0].get("y") or "Y"),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
layer.rescale()
|
layer.rescale()
|
||||||
if plot_type == "surface_3d":
|
if plot_type == "surface_3d":
|
||||||
_apply_axis(
|
_apply_axis(
|
||||||
layer, "z", plot_spec.get("z_axis"),
|
layer,
|
||||||
str(series_specs[0].get("z") or "Z")
|
"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):
|
for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
|
||||||
style = series_spec.get("style")
|
style = series_spec.get("style")
|
||||||
|
|
@ -1156,12 +1200,14 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
for extension in ("png", "svg", "pdf"):
|
for extension in ("png", "svg", "pdf"):
|
||||||
if extension in formats:
|
if extension in formats:
|
||||||
target = output / f"figure.{extension}"
|
target = output / f"figure.{extension}"
|
||||||
exported = Path(graph.save_fig(
|
exported = Path(
|
||||||
|
graph.save_fig(
|
||||||
str(target),
|
str(target),
|
||||||
type=extension,
|
type=extension,
|
||||||
width=pixel_width if extension == "png" else 0,
|
width=pixel_width if extension == "png" else 0,
|
||||||
ratio=100 if extension in {"svg", "pdf"} else 0,
|
ratio=100 if extension in {"svg", "pdf"} else 0,
|
||||||
)).resolve()
|
)
|
||||||
|
).resolve()
|
||||||
if exported != target.resolve() or not target.is_file():
|
if exported != target.resolve() or not target.is_file():
|
||||||
raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED")
|
raise RuntimeError(f"{extension.upper()}_EXPORT_FAILED")
|
||||||
_validate_artifact(target, extension)
|
_validate_artifact(target, extension)
|
||||||
|
|
@ -1208,7 +1254,9 @@ def main() -> int:
|
||||||
job_dir = Path(sys.argv[1])
|
job_dir = Path(sys.argv[1])
|
||||||
request_record: dict[str, Any] = {}
|
request_record: dict[str, Any] = {}
|
||||||
try:
|
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)
|
artifacts = run(job_dir)
|
||||||
terminal = {
|
terminal = {
|
||||||
"job_id": request_record["job_id"],
|
"job_id": request_record["job_id"],
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue