feat(origin): add annotations and acceptance harness

This commit is contained in:
caoqianming 2026-08-24 10:06:51 +08:00
parent d2da9b243f
commit 8ab548a1cd
13 changed files with 1217 additions and 14 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-08-21(Blender Scene Recipe v3bump 0.67.0)
最后更新:2026-08-24(Origin 真机 QA harness MVP)
---
@ -20,6 +20,9 @@
---
## 已完成关键能力
- **08-24 / 0.67.0 / Origin 真机 QA harness MVP**Origin adapter 新增独立 `acceptance.py`,以 annotations、2×2 Recipe、heatmap、surface_3d、stacked、band 六个固定合成用例通过生产 `worker.py` 子进程执行 OPJU/PNG/SVG/PDF 全格式任务,再由独立 Origin 会话重开工程、核对图层/系列/工作表/矩阵与固定数值 oracle 并复导三种图件;报告记录环境指纹、请求/产物摘要、像素尺寸和 Origin 进程释放结果,失败也原子落盘。独立 adapter 包同步携带验收脚本Origin/合同/Job/Node 专项 121 项 unittest、ruff 致命规则、py_compile 与独立 adapter 打包校验通过,目标 Origin 2024 真机执行待部署节点完成,未连接或写入数据库。
- **08-21 / 0.67.0 / Origin 出版级标注与坐标增强第一批**`origin.plot@v2` / adapter 1.1.0 新增坐标反向、次刻度数量,以及按数据坐标定位的参考线、参考区间和文字标注;普通二维单图在 `plot.annotations` 声明Recipe / multi-panel 在各 `panel.annotations` 声明,固定 Worker 编译为受控 Origin 图形对象,不开放 LabTalk、模板或对象名。Workspace 最低 adapter 基线同步提升到 1.1.0,避免旧 adapter 接收不认识的共享字段Origin/合同/Job/Node 专项 114 项 unittest 通过Origin 2024 的 OPJU、PNG、SVG、PDF 真机视觉验收待目标节点执行,未连接或写入数据库。
- **08-21 / 0.67.0 / Blender 通用 Scene Recipe v3**:按明确破坏性升级授权移除 `blender.scene.author@v2`,新增唯一 `recipe` 入口的 `blender.scene.author@v3` / adapter 1.0.0;可组合十类基础/工业几何、文字、父子层级、集合、五类受控修改器、材质、灯光和最多八个自定义相机视图,回转窑降为可与其他对象组合的 generator。Worker 拆为固定启动器、Recipe 编译器、内置 generator 和输出编排,机械限制请求/对象/mesh 规模并拒绝动态代码、路径、插件和节点脚本;每次按完整目标状态从空场景重建,旧 v2 Workspace 文件不删除但不再续作。99 项 Blender/合同/Job/Node/发布专项 unittest、.NET build、独立 adapter 与完整 Node 打包及 Blender 3.6.12 全构件双视图/GLB 真机烟测通过,视觉复核后修正 box/plane 尺寸烘焙;全量 711 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 5 项Blender 5.2 目标节点验收待部署后执行,未连接或迁移生产数据库。
- **08-21 / 0.66.0 / 静态多文件 Web 项目预览**Agent 可在 task 中创建并测试正常的多文件前端项目,构建后用 `publish_web_preview` 发布 `dist/build` 等静态输出;对话直播和历史回看都会显示可打开、停止的预览卡片。新增 task-scoped `web_previews` 生命周期与签名资源网关,支持相对资源、常见根绝对资源、正确 MIME 和 SPA 路由回退;路径穿越、符号链接、文件数量/大小有机械限制HTML 在 iframe 与响应头双重 opaque sandbox 中运行,不能获得 zcbot 同源权限。coding skill 与系统提示同步采用技术栈路由:已有项目保持原栈,新建 Web/通用软件默认 Node.js + TypeScript、前端默认 Vite科学计算、数据分析、机器学习和材料研发在更合适时继续使用 Python随后按“依赖安装→测试→构建→预览”执行。skill 官方校验、技术栈与预览专项 18 项、全量 43 项 Node 及定向 66 项 Python 回归通过;全量 Python 710 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 5 项),未连接或迁移生产数据库。动态后端/开发服务器预览留待独立 runtime 阶段。

6
RUN.md
View File

@ -1142,6 +1142,8 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2`panels` 数量为 14两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind``line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列;顶层 `x_axis/y_axis/legend` 作为全部 panel 的默认值panel 内同名设置可单独覆盖,`right_y_axis` 仍按 panel 设置。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名也不支持把等高线、3D 曲面、三元图或热图混入 panel。
二维笛卡尔单图可在 `plot.annotations`Recipe / multi-panel 可在各 `panel.annotations` 声明最多 64 个标注。`reference_line` 使用 `axis=x|y + value`,可设置颜色、线宽、线型和标签;`reference_band` 使用 `axis + from/to`,可设置填充色、透明度和标签,且 `from` 必须小于 `to``text` 使用数据坐标 `x/y + text`,可设置颜色、字号、旋转和白色背景。坐标轴另支持 `minor_ticks=0..10``reverse=true|false`。这些字段不用于 polar、pie、surface_3d 或 ternary请求只能传声明值Worker 内部生成对象名和固定命令。adapter 1.1.0 是当前 Workspace 最低版本,发布时先整体替换 Node 的 `adapters\origin.plot@v2\` 并重启,确认 probe 为 ready 后再上线云端契约,避免旧 adapter 在升级窗口内停止接单。
声明式绘图使用 `plot.type=recipe`、`recipe_version=1`,第一版有意复用上述 `layout/panels` 结构和限制,作为新的组合入口而不是开放脚本。对已有结果继续调整时,先用 `software_job_status(job_id)` 取得 `editable_request`,在其中形成完整的新 `operation``outputs`,再调用 `software_job_revise`;服务端自动复用源 Job 的 inputs并要求 source Job 是该 Workspace 当前 head。Origin Worker 打开 Node 上的上一版 OPJU将新工作表和图添加到工程后保存为新的 `current`;失败时恢复 `rollback`。Job 历史不被覆盖,但 Node 只保留当前和上一版工程文件。
科研统计图使用独立单图 type。`box` 和 `histogram` 的系列填写 `input/y`,直接使用原始观测列并采用 Origin 默认箱线统计与自动分箱;`stacked_column` 至少提供两条同类 `input/x/y` 系列;`bubble` 使用 `input/x/y/size`,每行 size 必须是有限正数;`band` 使用 `input/x/y/lower/upper`,其中 y 是中心线且每行 lower 不得大于 upper。当前 schema 不开放自定义箱线百分位、直方图分箱和分布拟合参数,也不允许把这五类统计图作为 `multi_panel.series[].kind`;在目标 Origin 版本完成真机验证后再增量开放。
@ -1152,7 +1154,9 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约或 `worker.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、验收脚本、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约、`worker.py` 或 `acceptance.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。
Origin adapter 1.1.0 起随包提供固定真机 QA harness但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、band也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle复导三种图件并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。
Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v3-adapter.zip`,替换规则与 Origin 相同。升级时先从托盘退出 Node删除程序目录中的 `adapters\blender.scene.author@v1\`、`adapters\blender.scene.author@v2\`,整体放入 v3 目录后重启;数据根目录中的旧 Workspace 不在清理范围。Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。

View File

@ -90,7 +90,7 @@
"workspace": {
"state_output": "project",
"preview_outputs": ["figure_png"],
"required_adapter_version": "1.0.0"
"required_adapter_version": "1.1.0"
},
"request_schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
@ -185,6 +185,11 @@
"template": {"const": "publication_double_column"},
"title": {"type": "string", "maxLength": 500},
"title_style": {"$ref": "#/$defs/text_style"},
"annotations": {
"type": "array",
"maxItems": 64,
"items": {"$ref": "#/$defs/annotation"}
},
"canvas": {
"type": "object",
"required": ["width_mm", "height_mm"],
@ -216,7 +221,10 @@
"if": {"properties": {"type": {"enum": ["multi_panel", "recipe"]}}},
"then": {
"required": ["layout", "panels"],
"not": {"required": ["series"]}
"not": {"anyOf": [
{"required": ["series"]},
{"required": ["annotations"]}
]}
},
"else": {
"required": ["series"],
@ -379,9 +387,14 @@
"not": {"anyOf": [
{"required": ["x_axis"]},
{"required": ["y_axis"]},
{"required": ["z_axis"]}
{"required": ["z_axis"]},
{"required": ["annotations"]}
]}
}
},
{
"if": {"properties": {"type": {"enum": ["surface_3d", "ternary"]}}},
"then": {"not": {"required": ["annotations"]}}
}
]
}
@ -456,6 +469,8 @@
"minimum": {"type": "number"},
"maximum": {"type": "number"},
"major_step": {"type": "number", "exclusiveMinimum": 0},
"minor_ticks": {"type": "integer", "minimum": 0, "maximum": 10},
"reverse": {"type": "boolean"},
"tick_label_angle": {"type": "number", "minimum": -180, "maximum": 180},
"tick_label_font_size": {"type": "number", "minimum": 6, "maximum": 72},
"title_font_size": {"type": "number", "minimum": 6, "maximum": 72},
@ -469,6 +484,55 @@
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
},
"annotation": {
"oneOf": [
{
"type": "object",
"required": ["kind", "axis", "value"],
"additionalProperties": false,
"properties": {
"kind": {"const": "reference_line"},
"axis": {"enum": ["x", "y"]},
"value": {"type": "number"},
"color": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
"line_width": {"type": "number", "minimum": 0.1, "maximum": 20},
"line_style": {"enum": ["solid", "dash", "dot", "dash_dot", "dash_dot_dot"]},
"label": {"type": "string", "minLength": 1, "maxLength": 200},
"label_font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
},
{
"type": "object",
"required": ["kind", "axis", "from", "to"],
"additionalProperties": false,
"properties": {
"kind": {"const": "reference_band"},
"axis": {"enum": ["x", "y"]},
"from": {"type": "number"},
"to": {"type": "number"},
"color": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
"transparency": {"type": "integer", "minimum": 0, "maximum": 100},
"label": {"type": "string", "minLength": 1, "maxLength": 200},
"label_font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
},
{
"type": "object",
"required": ["kind", "text", "x", "y"],
"additionalProperties": false,
"properties": {
"kind": {"const": "text"},
"text": {"type": "string", "minLength": 1, "maxLength": 500},
"x": {"type": "number"},
"y": {"type": "number"},
"color": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"},
"font_size": {"type": "number", "minimum": 6, "maximum": 72},
"rotation": {"type": "number", "minimum": -180, "maximum": 180},
"background": {"enum": ["none", "white"]}
}
}
]
},
"series_style": {
"type": "object",
"additionalProperties": false,
@ -517,6 +581,11 @@
"description": "面板上方标题;它不替代 y_axis.title。需要纵轴名称和单位时应同时设置 y_axis。"
},
"title_style": {"$ref": "#/$defs/text_style"},
"annotations": {
"type": "array",
"maxItems": 64,
"items": {"$ref": "#/$defs/annotation"}
},
"x_axis": {"$ref": "#/$defs/axis"},
"y_axis": {
"$ref": "#/$defs/axis",

View File

@ -0,0 +1,156 @@
from __future__ import annotations
import importlib.util
import json
import struct
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
from core.software_contracts import get_contract
ROOT = Path(__file__).resolve().parents[1]
ACCEPTANCE_PATH = (
ROOT / "windows-node" / "adapters" / "origin.plot@v2" / "acceptance.py"
)
SPEC = importlib.util.spec_from_file_location("zcbot_origin_acceptance", ACCEPTANCE_PATH)
assert SPEC and SPEC.loader
acceptance = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(acceptance)
class OriginAcceptanceTests(unittest.TestCase):
def test_fixed_cases_are_valid_contract_requests(self) -> None:
contract = get_contract("origin.plot@v2")
cases = acceptance._cases()
self.assertEqual(
list(cases),
["annotations", "recipe_2x2", "heatmap", "surface_3d", "stacked", "band"],
)
for case in cases.values():
normalized, digest = contract.normalize_request(case["request"])
self.assertEqual(normalized, case["request"])
self.assertEqual(len(digest), 64)
self.assertEqual(
{item["format"] for item in case["request"]["outputs"]},
{"opju", "png", "svg", "pdf"},
)
def test_scientific_oracles_use_known_values(self) -> None:
cases = acceptance._cases()
self.assertEqual(cases["stacked"]["oracle"]["stacked_totals"], [15, 18, 17, 22])
self.assertEqual(cases["heatmap"]["oracle"]["grid_shape"], [5, 7])
self.assertTrue(cases["band"]["oracle"]["lower_not_above_center"])
self.assertTrue(cases["band"]["oracle"]["center_not_above_upper"])
def test_stage_uses_stable_digest_and_keyed_csv_inputs(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
case = acceptance._cases()["annotations"]
job_dir = acceptance._stage(root, "annotations", case)
record = json.loads(
(job_dir / "request" / "request.json").read_text(encoding="utf-8")
)
_, digest = acceptance._canonical_request(case["request"])
self.assertEqual(record["request_digest"], digest)
self.assertEqual(record["request"], case["request"])
self.assertTrue((job_dir / "input" / "sample" / "sample.csv").is_file())
def test_project_data_validation_recomputes_stacked_oracle(self) -> None:
class Sheet:
def __init__(self, name, labels, rows):
self.lname = name
self.labels = labels
self.rows = rows
def get_labels(self, _kind):
return self.labels
def to_list2(self, **_arguments):
return self.rows
case = acceptance._cases()["stacked"]
headers, rows = acceptance._csv_fixture(case["files"]["sample"])
staging = [[row[0], row[1], row[2], row[3]] for row in rows]
workbook = [
Sheet("sample", headers, rows),
Sheet("stacked_plot_data", ["age", "相 A", "相 B", "相 C"], staging),
]
result = acceptance._validate_project_data([workbook], [], case)
self.assertTrue(result["oracle_validation"]["input_sheets_match"])
self.assertEqual(
result["oracle_validation"]["stacked_totals"],
[15.0, 18.0, 17.0, 22.0],
)
def test_manifest_validation_checks_all_outputs_and_png_size(self) -> None:
with tempfile.TemporaryDirectory() as directory:
job_dir = Path(directory)
output = job_dir / "output"
output.mkdir()
case = acceptance._cases()["annotations"]
width = round(300 * 160 / 25.4)
files = {
"project.opju": b"O" * 64,
"figure.png": b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + struct.pack(">II", width, 1000),
"figure.svg": b"<svg xmlns='http://www.w3.org/2000/svg'></svg>",
"figure.pdf": b"%PDF-1.7\n%%EOF",
}
for name, content in files.items():
(output / name).write_bytes(content)
acceptance.worker._atomic_json(output / "plot-spec.json", case["request"])
acceptance.worker._atomic_json(
output / "provenance.json",
{"adapter_version": acceptance.worker.ADAPTER_VERSION},
)
paths = [
output / "project.opju", output / "figure.png", output / "figure.svg",
output / "figure.pdf", output / "plot-spec.json", output / "provenance.json",
]
terminal = {
"status": "succeeded",
"artifact_manifest": [
acceptance.worker._manifest(path, "application/octet-stream")
for path in paths
],
}
result = acceptance._validate_manifest(job_dir, case["request"], terminal)
self.assertEqual(result["png_pixels"], [width, 1000])
self.assertEqual(set(result["artifacts"]), acceptance.EXPECTED_ARTIFACTS)
def test_suite_always_writes_atomic_failure_report(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "acceptance"
with (
patch.object(acceptance, "_environment_fingerprint", return_value={"test": True}),
patch.object(acceptance, "_origin_processes", return_value={}),
patch.object(acceptance, "_run_case", side_effect=RuntimeError("boom")),
self.assertRaisesRegex(RuntimeError, "boom"),
):
acceptance.run_suite(root, ["annotations"])
report = json.loads((root / "acceptance-report.json").read_text(encoding="utf-8"))
self.assertFalse(report["passed"])
self.assertEqual(report["failure"]["type"], "RuntimeError")
self.assertEqual(report["failure"]["completed_cases"], 0)
def test_suite_records_selected_case_success(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "acceptance"
result = {"case": "annotations", "elapsed_seconds": 1.0}
with (
patch.object(acceptance, "_environment_fingerprint", return_value={"test": True}),
patch.object(acceptance, "_origin_processes", return_value={}),
patch.object(acceptance, "_run_case", return_value=result),
):
report = acceptance.run_suite(root, ["annotations"])
self.assertTrue(report["passed"])
self.assertEqual(report["cases"], [result])
persisted = json.loads(
(root / "acceptance-report.json").read_text(encoding="utf-8")
)
self.assertTrue(persisted["passed"])
if __name__ == "__main__":
unittest.main()

View File

@ -271,6 +271,8 @@ class OriginWorkerUnitTests(unittest.TestCase):
"minimum": 1,
"maximum": 100,
"major_step": 1,
"minor_ticks": 4,
"reverse": True,
"tick_label_angle": 45,
"tick_label_font_size": 10,
"title_font_size": 12,
@ -286,11 +288,96 @@ class OriginWorkerUnitTests(unittest.TestCase):
{
"x.label.rotate": 45,
"x.label.pt": 10,
"x.minorTicks": 4,
"x.reverse": 1,
"x.grid.show": 3,
},
)
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
def test_annotations_compile_to_fixed_origin_objects(self) -> None:
class Label:
def __init__(self, text, x, y):
self.text = text
self.x = x
self.y = y
self.name = ""
self.values = {}
def set_int(self, name, value):
self.values[name] = value
def set_float(self, name, value):
self.values[name] = value
class Obj:
def __init__(self):
self.commands = []
def LT_execute(self, command):
self.commands.append(command)
class Layer:
def __init__(self):
self.obj = Obj()
self.xlim = (0, 10)
self.ylim = (0, 100)
self.labels = []
self.activated = False
def activate(self):
self.activated = True
def add_label(self, text, x, y):
label = Label(text, x, y)
self.labels.append(label)
return label
layer = Layer()
worker._apply_annotations(layer, [
{
"kind": "reference_line", "axis": "x", "value": 3.5,
"color": "#CC0000", "line_width": 1.5, "line_style": "dash_dot",
"label": "Peak",
},
{
"kind": "reference_band", "axis": "y", "from": 20, "to": 40,
"color": "#3366CC", "transparency": 75, "label": "Window",
},
{
"kind": "text", "text": "Sample A", "x": 8, "y": 90,
"color": "#222222", "font_size": 11, "rotation": 15,
"background": "white",
},
])
self.assertTrue(layer.activated)
self.assertIn("draw -n ZCBOT_ANN_001 -l -v 3.5", layer.obj.commands[0])
self.assertIn("ZCBOT_ANN_001.lineType=4", layer.obj.commands[0])
self.assertIn("draw -n ZCBOT_ANN_002 -b", layer.obj.commands[2])
self.assertIn("ZCBOT_ANN_002.y1=20", layer.obj.commands[2])
self.assertIn("ZCBOT_ANN_002.y3=40", layer.obj.commands[2])
self.assertIn("ZCBOT_ANN_002.transparency=75", layer.obj.commands[2])
self.assertEqual(
[(label.name, label.text) for label in layer.labels],
[
("ZCBOT_ANN_001_LABEL", "Peak"),
("ZCBOT_ANN_002_LABEL", "Window"),
("ZCBOT_ANN_003", "Sample A"),
],
)
self.assertEqual(layer.labels[-1].values["attach"], 2)
self.assertEqual(layer.labels[-1].values["background"], 4)
self.assertEqual(layer.labels[-1].values["rotate"], 15)
def test_annotation_semantics_reject_invalid_band_range(self) -> None:
request = self._request()
request["operation"]["plot"]["annotations"] = [{
"kind": "reference_band", "axis": "x", "from": 5, "to": 5,
}]
with self.assertRaisesRegex(ValueError, "ANNOTATION_BAND_RANGE_INVALID"):
worker._validate_semantics(request)
def test_rescale_applies_log_scales_before_deriving_limits(self) -> None:
class FakeAxis:
def __init__(self, name, events):

View File

@ -42,7 +42,7 @@ class SoftwareContractTests(unittest.TestCase):
self.assertEqual(contract.output_namespace, "origin")
self.assertEqual(contract.workspace.state_output, "project")
self.assertEqual(contract.workspace.preview_outputs, ("figure_png",))
self.assertEqual(contract.workspace.required_adapter_version, "1.0.0")
self.assertEqual(contract.workspace.required_adapter_version, "1.1.0")
self.assertEqual(
set(contract.expected_outputs(request)),
{"project", "figure_png", "plot_spec", "provenance"},
@ -62,13 +62,16 @@ class SoftwareContractTests(unittest.TestCase):
"origin.plot@v2": {
"health": "ready",
"available_slots": 1,
"adapter_version": "1.0.0",
"adapter_version": "1.1.0",
"workspace_protocol_version": 1,
"features": ["line", "heatmap"],
}
}
}
self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"]["adapter_version"] = "1.0.1"
self.assertFalse(node_supports_request(contract, _request("line"), current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"]["adapter_version"] = "1.1.0"
self.assertEqual(node_available_slots("origin.plot@v2", current_runtime), 1)
self.assertFalse(node_supports_request(contract, _request("bar"), current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].pop(
@ -99,7 +102,7 @@ class SoftwareContractTests(unittest.TestCase):
})
self.assertFalse(node_supports_request(contract, multi_panel, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "1.0.0",
"adapter_version": "1.1.0",
})
self.assertTrue(node_supports_request(contract, multi_panel, current_runtime))
@ -116,7 +119,7 @@ class SoftwareContractTests(unittest.TestCase):
})
self.assertFalse(node_supports_request(contract, recipe, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "1.0.0",
"adapter_version": "1.1.0",
})
self.assertTrue(node_supports_request(contract, recipe, current_runtime))
@ -135,7 +138,7 @@ class SoftwareContractTests(unittest.TestCase):
self.assertEqual(normalized, bubble)
self.assertFalse(node_supports_request(contract, bubble, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "1.0.0",
"adapter_version": "1.1.0",
"features": ["bubble"],
})
self.assertTrue(node_supports_request(contract, bubble, current_runtime))
@ -146,11 +149,38 @@ class SoftwareContractTests(unittest.TestCase):
self.assertEqual(normalized, area)
self.assertFalse(node_supports_request(contract, area, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "1.0.0",
"adapter_version": "1.1.0",
"features": ["area"],
})
self.assertTrue(node_supports_request(contract, area, current_runtime))
def test_origin_contract_accepts_controlled_annotations_and_axis_options(self) -> None:
contract = get_contract("origin.plot@v2")
request = _request("line")
request["operation"]["plot"].update({
"x_axis": {"minor_ticks": 4, "reverse": True},
"annotations": [
{
"kind": "reference_line", "axis": "x", "value": 29.4,
"color": "#CC0000", "line_style": "dash", "label": "Peak",
},
{
"kind": "reference_band", "axis": "y", "from": 10, "to": 20,
"color": "#3366CC", "transparency": 80,
},
{
"kind": "text", "text": "Sample A", "x": 40, "y": 80,
"font_size": 10, "background": "white",
},
],
})
normalized, _ = contract.normalize_request(request)
self.assertEqual(normalized, request)
request["operation"]["plot"]["type"] = "polar"
with self.assertRaises(ValueError):
contract.normalize_request(request)
def test_semantic_version_comparison_is_numeric(self) -> None:
self.assertTrue(version_at_least("0.10.0", "0.4.0"))
self.assertFalse(version_at_least("0.3.9", "0.4.0"))

View File

@ -752,7 +752,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
node.node_id = uuid4()
node.capabilities = ["origin.plot@v2"]
node.runtime = {"capability_runtime": {"origin.plot@v2": {
"health": "ready", "available_slots": 1, "adapter_version": "1.0.0",
"health": "ready", "available_slots": 1, "adapter_version": "1.1.0",
"workspace_protocol_version": 1,
"features": ["line"],
}}}

View File

@ -496,6 +496,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script)
self.assertIn('"adapters\\origin.plot@v2\\worker.py"', script)
self.assertIn('"adapters\\origin.plot@v2\\acceptance.py"', script)
self.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script)
self.assertNotIn("dotnet", script.lower())

View File

@ -16,6 +16,8 @@ windows-node\package-windows-node.bat
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要在对应软件卡中更新 runtime。
Origin adapter 1.1.0 随包提供独立 CLI 真机 QA harness使用固定合成数据覆盖 annotations、2×2 Recipe、heatmap、surface_3d、stacked 和 band并验证 OPJU 重开、数据与数值 oracle、三种图件复导及 Origin 进程释放;该入口暂不接入 Node UI运行命令和验收边界见根目录 `RUN.md`
Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。
推荐在 Node 主窗口“专业软件”区域完成后续配置Origin、ANSYS、Blender 各有独立软件卡,均可自动检测或手工选择受支持的位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements不执行用户输入的命令新环境先在同一数据根目录的临时目录构建成功后再替换对应 runtime。手工路径保存在当前专用 Windows 账号下Node 启动 Worker 时注入固定环境变量“自动检测”可清除手工路径并回到环境变量、COM、卸载注册表、PATH 或标准目录发现。Origin 的手选程序用于确定安装位置和读取版本,实际执行仍以 COM probe 为准。

View File

@ -0,0 +1,716 @@
"""Run the fixed Origin plot acceptance suite on a dedicated Windows node."""
from __future__ import annotations
import argparse
import csv
import hashlib
import importlib.util
import json
import locale
import os
import platform
import struct
import subprocess
import sys
import time
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from pathlib import Path
from typing import Any
from uuid import NAMESPACE_URL, uuid5
ADAPTER_DIR = Path(__file__).resolve().parent
WORKER_PATH = ADAPTER_DIR / "worker.py"
_WORKER_SPEC = importlib.util.spec_from_file_location(
"zcbot_origin_acceptance_worker", WORKER_PATH
)
if _WORKER_SPEC is None or _WORKER_SPEC.loader is None:
raise RuntimeError("ORIGIN_WORKER_IMPORT_FAILED")
worker = importlib.util.module_from_spec(_WORKER_SPEC)
_WORKER_SPEC.loader.exec_module(worker)
REPORT_SCHEMA_VERSION = 1
OUTPUTS = [
{"key": "project", "type": "project", "format": "opju"},
{"key": "figure_png", "type": "figure", "format": "png", "options": {"dpi": 300}},
{"key": "figure_svg", "type": "figure", "format": "svg"},
{"key": "figure_pdf", "type": "figure", "format": "pdf"},
]
EXPECTED_ARTIFACTS = {
"project",
"figure_png",
"figure_svg",
"figure_pdf",
"plot_spec",
"provenance",
}
ORIGIN_PROCESS_PATTERN = r"^origin(?:\d+)?(?:_?\d+)?(?:64)?\.exe$"
def _artifact_id(case_name: str, input_key: str) -> str:
return str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance:{case_name}:{input_key}"))
def _input(case_name: str, key: str) -> dict[str, str]:
return {"key": key, "artifact_id": _artifact_id(case_name, key)}
def _cases() -> dict[str, dict[str, Any]]:
annotations_csv = "x,y\n" + "\n".join(
f"{x},{10 + 3 * x + (x % 3) * 2}" for x in range(11)
) + "\n"
recipe_csv = "age,strength,modulus,porosity\n" + "\n".join(
f"{age},{strength},{modulus},{porosity}"
for age, strength, modulus, porosity in (
(1, 12, 18, 27), (3, 24, 23, 22), (7, 38, 28, 18), (28, 55, 33, 13)
)
) + "\n"
grid_rows = [
(x, y, round(20 + 1.5 * x - 0.8 * y + 0.12 * x * y, 4))
for y in range(5)
for x in range(7)
]
grid_csv = "temperature,time,value\n" + "\n".join(
f"{x},{y},{z}" for x, y, z in reversed(grid_rows)
) + "\n"
stacked_rows = [(1, 3, 5, 7), (3, 4, 6, 8), (7, 5, 5, 7), (28, 8, 6, 8)]
stacked_csv = "age,phase_a,phase_b,phase_c\n" + "\n".join(
",".join(str(value) for value in row) for row in stacked_rows
) + "\n"
band_rows = [
(x, 80 - 4 * x, 76 - 4 * x, 84 - 4 * x) for x in range(11)
]
band_csv = "time,center,lower,upper\n" + "\n".join(
",".join(str(value) for value in row) for row in band_rows
) + "\n"
return {
"annotations": {
"files": {"sample": annotations_csv},
"request": {
"schema_version": 2,
"inputs": [_input("annotations", "sample")],
"operation": {"plot": {
"type": "line",
"title": "中文标注与参考区域",
"canvas": {"width_mm": 160, "height_mm": 100},
"series": [{
"input": "sample", "x": "x", "y": "y", "label": "试样 A",
"style": {"color": "#3366CC", "line_width": 1.5},
}],
"x_axis": {
"title": "龄期", "unit": "d", "minor_ticks": 4,
"reverse": True, "grid": "major_minor",
},
"y_axis": {"title": "抗压强度", "unit": "MPa"},
"legend": {"enabled": True, "position": "top_left"},
"annotations": [
{
"kind": "reference_line", "axis": "x", "value": 7,
"color": "#CC0000", "line_style": "dash", "label": "关键龄期",
},
{
"kind": "reference_band", "axis": "y", "from": 25, "to": 35,
"color": "#F4A261", "transparency": 80, "label": "目标区间",
},
{
"kind": "text", "text": "受控文字标注", "x": 2, "y": 42,
"color": "#222222", "font_size": 10, "background": "white",
},
],
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 1,
"annotation_names": ["ZCBOT_ANN_001", "ZCBOT_ANN_002", "ZCBOT_ANN_003"],
},
"oracle": {"reference_line_x": 7, "reference_band_y": [25, 35]},
},
"recipe_2x2": {
"files": {"sample": recipe_csv},
"request": {
"schema_version": 2,
"inputs": [_input("recipe_2x2", "sample")],
"operation": {"plot": {
"type": "recipe", "recipe_version": 1,
"title": "材料性能 2×2 组合图",
"canvas": {"width_mm": 180, "height_mm": 150},
"layout": {"rows": 2, "columns": 2, "share_x": True},
"x_axis": {"title": "龄期", "unit": "d"},
"panels": [
{
"key": "strength", "panel_label": "(a)",
"title": "抗压强度", "y_axis": {"title": "强度", "unit": "MPa"},
"series": [{
"input": "sample", "x": "age", "y": "strength",
"kind": "line_scatter", "label": "强度",
}],
},
{
"key": "modulus", "panel_label": "(b)",
"title": "弹性模量", "y_axis": {"title": "模量", "unit": "GPa"},
"series": [{
"input": "sample", "x": "age", "y": "modulus",
"kind": "line_scatter", "label": "模量",
}],
},
{
"key": "porosity", "panel_label": "(c)",
"title": "显气孔率", "y_axis": {"title": "气孔率", "unit": "%"},
"series": [{
"input": "sample", "x": "age", "y": "porosity",
"kind": "column", "label": "气孔率",
}],
},
{
"key": "comparison", "panel_label": "(d)",
"title": "归一化趋势", "y_axis": {"title": "指标值"},
"series": [
{
"input": "sample", "x": "age", "y": "strength",
"kind": "line", "label": "强度",
},
{
"input": "sample", "x": "age", "y": "modulus",
"kind": "line", "label": "模量",
},
],
},
],
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 4,
"minimum_plot_counts": [1, 1, 1, 2],
"minimum_worksheets": 1,
},
"oracle": {"panel_count": 4, "series_count": 5},
},
"heatmap": {
"files": {"grid": grid_csv},
"request": {
"schema_version": 2,
"inputs": [_input("heatmap", "grid")],
"operation": {"plot": {
"type": "heatmap", "title": "烧结工艺窗口热图",
"canvas": {"width_mm": 150, "height_mm": 110},
"series": [{
"input": "grid", "x": "temperature", "y": "time", "z": "value",
}],
"x_axis": {"title": "温度", "unit": "°C"},
"y_axis": {"title": "保温时间", "unit": "h"},
"z_axis": {"title": "抗压强度", "unit": "MPa"},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 1,
"minimum_matrices": 1,
},
"oracle": {"grid_shape": [5, 7], "first_cell": grid_rows[0][2], "last_cell": grid_rows[-1][2]},
},
"surface_3d": {
"files": {"grid": grid_csv},
"request": {
"schema_version": 2,
"inputs": [_input("surface_3d", "grid")],
"operation": {"plot": {
"type": "surface_3d", "title": "三维工艺响应面",
"canvas": {"width_mm": 160, "height_mm": 120},
"series": [{
"input": "grid", "x": "temperature", "y": "time", "z": "value",
}],
"x_axis": {"title": "温度", "unit": "°C"},
"y_axis": {"title": "保温时间", "unit": "h"},
"z_axis": {"title": "性能", "unit": "MPa"},
"legend": {"enabled": True},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [1],
"minimum_worksheets": 1,
},
"oracle": {"point_count": len(grid_rows)},
},
"stacked": {
"files": {"sample": stacked_csv},
"request": {
"schema_version": 2,
"inputs": [_input("stacked", "sample")],
"operation": {"plot": {
"type": "stacked_column", "title": "物相组成",
"canvas": {"width_mm": 150, "height_mm": 100},
"series": [
{"input": "sample", "x": "age", "y": "phase_a", "label": "相 A"},
{"input": "sample", "x": "age", "y": "phase_b", "label": "相 B"},
{"input": "sample", "x": "age", "y": "phase_c", "label": "相 C"},
],
"x_axis": {"title": "龄期", "unit": "d"},
"y_axis": {"title": "相含量", "unit": "%"},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [3],
"minimum_worksheets": 2,
},
"oracle": {"stacked_totals": [sum(row[1:]) for row in stacked_rows]},
},
"band": {
"files": {"sample": band_csv},
"request": {
"schema_version": 2,
"inputs": [_input("band", "sample")],
"operation": {"plot": {
"type": "band", "title": "耐久性能衰减区间",
"canvas": {"width_mm": 150, "height_mm": 100},
"series": [{
"input": "sample", "x": "time", "y": "center",
"lower": "lower", "upper": "upper", "label": "均值与区间",
"style": {"color": "#2A9D8F", "transparency": 60},
}],
"x_axis": {"title": "暴露时间", "unit": ""},
"y_axis": {"title": "相对动弹模", "unit": "%"},
}},
"outputs": OUTPUTS,
},
"expect": {
"minimum_graph_layers": 1,
"minimum_plot_counts": [3],
"minimum_worksheets": 1,
},
"oracle": {
"lower_not_above_center": all(row[2] <= row[1] for row in band_rows),
"center_not_above_upper": all(row[1] <= row[3] for row in band_rows),
},
},
}
def _canonical_request(request: dict[str, Any]) -> tuple[str, str]:
encoded = json.dumps(request, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return encoded, hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _stage(root: Path, case_name: str, case: dict[str, Any]) -> Path:
job_dir = root / case_name
request_dir = job_dir / "request"
request_dir.mkdir(parents=True)
for input_key, content in case["files"].items():
input_dir = job_dir / "input" / input_key
input_dir.mkdir(parents=True)
(input_dir / f"{input_key}.csv").write_text(content, encoding="utf-8", newline="")
_, digest = _canonical_request(case["request"])
record = {
"job_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance-job:{case_name}")),
"lease_id": str(uuid5(NAMESPACE_URL, f"zcbot-origin-acceptance-lease:{case_name}")),
"request_digest": digest,
"request": case["request"],
}
worker._atomic_json(request_dir / "request.json", record)
return job_dir
def _png_dimensions(path: Path) -> tuple[int, int]:
data = path.read_bytes()[:24]
if len(data) != 24 or not data.startswith(b"\x89PNG\r\n\x1a\n"):
raise RuntimeError("PNG_EXPORT_INVALID")
return struct.unpack(">II", data[16:24])
def _normalized_cell(value: Any) -> Any:
if value is None or value == "":
return None
if isinstance(value, bool):
return value
try:
number = float(value)
except (TypeError, ValueError):
return str(value)
return int(number) if number.is_integer() else round(number, 12)
def _csv_fixture(content: str) -> tuple[list[str], list[list[Any]]]:
rows = list(csv.reader(content.splitlines()))
return rows[0], [[_normalized_cell(value) for value in row] for row in rows[1:]]
def _worksheet_snapshot(sheet: Any) -> dict[str, Any]:
labels = list(sheet.get_labels("L"))
rows = [list(row) for row in sheet.to_list2(c1=0, c2=max(0, len(labels) - 1))]
while rows and all(value in (None, "") for value in rows[-1]):
rows.pop()
return {
"name": str(sheet.lname),
"labels": labels,
"rows": [[_normalized_cell(value) for value in row[: len(labels)]] for row in rows],
}
def _validate_project_data(
workbooks: list[Any], matrices: list[Any], case: dict[str, Any]
) -> dict[str, Any]:
sheets = [_worksheet_snapshot(sheet) for book in workbooks for sheet in book]
by_name = {item["name"]: item for item in sheets}
for input_key, content in case["files"].items():
if input_key not in by_name:
raise RuntimeError(f"OPJU_INPUT_SHEET_MISSING:{input_key}")
headers, rows = _csv_fixture(content)
actual = by_name[input_key]
if actual["labels"][: len(headers)] != headers:
raise RuntimeError(f"OPJU_INPUT_LABELS_MISMATCH:{input_key}")
actual_rows = [row[: len(headers)] for row in actual["rows"][: len(rows)]]
if actual_rows != rows:
raise RuntimeError(f"OPJU_INPUT_VALUES_MISMATCH:{input_key}")
oracle_validation: dict[str, Any] = {"input_sheets_match": True}
if "stacked_totals" in case["oracle"]:
staging = by_name.get("stacked_plot_data")
if staging is None:
raise RuntimeError("OPJU_STACKED_STAGING_MISSING")
totals = [sum(float(value) for value in row[1:4]) for row in staging["rows"]]
expected = [float(value) for value in case["oracle"]["stacked_totals"]]
if totals != expected:
raise RuntimeError("OPJU_STACKED_TOTALS_MISMATCH")
oracle_validation["stacked_totals"] = totals
if "grid_shape" in case["oracle"]:
matrix_sheets = [sheet for book in matrices for sheet in book]
if not matrix_sheets:
raise RuntimeError("OPJU_HEATMAP_MATRIX_MISSING")
matrix = matrix_sheets[-1].to_np2d()
shape = [int(value) for value in matrix.shape]
if shape != case["oracle"]["grid_shape"]:
raise RuntimeError(f"OPJU_HEATMAP_MATRIX_SHAPE_INVALID:{shape}")
first = _normalized_cell(matrix[0, 0])
last = _normalized_cell(matrix[-1, -1])
if first != case["oracle"]["first_cell"] or last != case["oracle"]["last_cell"]:
raise RuntimeError("OPJU_HEATMAP_MATRIX_VALUES_MISMATCH")
oracle_validation.update({"matrix_shape": shape, "matrix_corners": [first, last]})
return {
"worksheet_count": len(sheets),
"worksheets": [
{"name": item["name"], "columns": len(item["labels"]), "rows": len(item["rows"])}
for item in sheets
],
"oracle_validation": oracle_validation,
}
def _validate_manifest(job_dir: Path, request: dict[str, Any], terminal: dict[str, Any]) -> dict[str, Any]:
if terminal.get("status") != "succeeded":
raise RuntimeError(f"WORKER_FAILED:{terminal.get('error')}")
manifest = terminal.get("artifact_manifest") or []
by_id = {item["artifact_id"]: item for item in manifest}
if set(by_id) != EXPECTED_ARTIFACTS:
raise RuntimeError(f"ARTIFACT_MANIFEST_MISMATCH:{sorted(by_id)}")
output = job_dir / "output"
paths = {
"project": output / "project.opju",
"figure_png": output / "figure.png",
"figure_svg": output / "figure.svg",
"figure_pdf": output / "figure.pdf",
"plot_spec": output / "plot-spec.json",
"provenance": output / "provenance.json",
}
extensions = {
"project": "opju", "figure_png": "png", "figure_svg": "svg", "figure_pdf": "pdf"
}
for artifact_id, path in paths.items():
if not path.is_file():
raise RuntimeError(f"ARTIFACT_MISSING:{artifact_id}")
if artifact_id in extensions:
worker._validate_artifact(path, extensions[artifact_id])
item = by_id[artifact_id]
if item["sha256"] != worker._file_sha256(path) or item["size_bytes"] != path.stat().st_size:
raise RuntimeError(f"ARTIFACT_DIGEST_MISMATCH:{artifact_id}")
if json.loads(paths["plot_spec"].read_text(encoding="utf-8")) != request:
raise RuntimeError("PLOT_SPEC_MISMATCH")
provenance = json.loads(paths["provenance"].read_text(encoding="utf-8"))
if provenance.get("adapter_version") != worker.ADAPTER_VERSION:
raise RuntimeError("PROVENANCE_ADAPTER_VERSION_MISMATCH")
width, height = _png_dimensions(paths["figure_png"])
canvas = request["operation"]["plot"].get("canvas") or {"width_mm": 160}
expected_width = round(300 * canvas["width_mm"] / 25.4)
if abs(width - expected_width) > 1 or height <= 0:
raise RuntimeError("PNG_DIMENSIONS_INVALID")
return {
"png_pixels": [width, height],
"artifacts": {
artifact_id: {
"size_bytes": paths[artifact_id].stat().st_size,
"sha256": worker._file_sha256(paths[artifact_id]),
}
for artifact_id in sorted(paths)
},
}
def _reopen_and_export(job_dir: Path, case: dict[str, Any]) -> dict[str, Any]:
import originpro as op
project = job_dir / "output" / "project.opju"
reopen_dir = job_dir / "reopen"
reopen_dir.mkdir()
op.set_show(False)
try:
if not op.open(str(project), readonly=True):
raise RuntimeError("OPJU_REOPEN_FAILED")
graphs = list(op.pages("g"))
workbooks = list(op.pages("w"))
matrices = list(op.pages("m"))
if not graphs:
raise RuntimeError("OPJU_GRAPH_MISSING")
graph = graphs[-1]
layer_count = len(graph)
plot_counts = [len(layer.plot_list()) for layer in graph]
expected = case["expect"]
if layer_count < expected["minimum_graph_layers"]:
raise RuntimeError("OPJU_LAYER_COUNT_INVALID")
for index, minimum in enumerate(expected.get("minimum_plot_counts") or []):
if index >= len(plot_counts) or plot_counts[index] < minimum:
raise RuntimeError(f"OPJU_PLOT_COUNT_INVALID:{index}")
worksheet_count = sum(len(book) for book in workbooks)
if worksheet_count < expected.get("minimum_worksheets", 0):
raise RuntimeError("OPJU_WORKSHEET_COUNT_INVALID")
matrix_count = sum(len(book) for book in matrices)
if matrix_count < expected.get("minimum_matrices", 0):
raise RuntimeError("OPJU_MATRIX_COUNT_INVALID")
for name in expected.get("annotation_names") or []:
if graph[0].label(name) is None:
raise RuntimeError(f"OPJU_ANNOTATION_MISSING:{name}")
project_data = _validate_project_data(workbooks, matrices, case)
graph.activate()
worker._configure_origin_session(op)
svg = reopen_dir / "figure.svg"
pdf = reopen_dir / "figure.pdf"
exported_svg = Path(graph.save_fig(str(svg), type="svg", width=0, ratio=100)).resolve()
if exported_svg != svg.resolve() or not svg.is_file():
raise RuntimeError("OPJU_REOPEN_SVG_EXPORT_FAILED")
normalized_svg, _ = worker._strip_origin_svg_text_baselines(svg.read_bytes())
svg.write_bytes(normalized_svg)
worker._validate_artifact(svg, "svg")
exported_pdf = Path(graph.save_fig(str(pdf), type="pdf", width=0, ratio=100)).resolve()
if exported_pdf != pdf.resolve() or not pdf.is_file():
raise RuntimeError("OPJU_REOPEN_PDF_EXPORT_FAILED")
worker._strip_origin_pdf_text_baselines(pdf)
worker._validate_artifact(pdf, "pdf")
canvas = case["request"]["operation"]["plot"].get("canvas") or {"width_mm": 160}
pixel_width = round(300 * canvas["width_mm"] / 25.4)
png = reopen_dir / "figure.png"
rendered_width, _ = worker._render_pdf_to_png(pdf, png, pixel_width)
if abs(rendered_width - pixel_width) > 1:
raise RuntimeError("OPJU_REOPEN_PNG_WIDTH_INVALID")
worker._validate_artifact(png, "png")
return {
"graph_count": len(graphs),
"workbook_count": len(workbooks),
"worksheet_count": worksheet_count,
"matrix_book_count": len(matrices),
"matrix_count": matrix_count,
"layer_count": layer_count,
"plot_counts": plot_counts,
"project_data": project_data,
"exports": {
path.name: {"size_bytes": path.stat().st_size, "sha256": worker._file_sha256(path)}
for path in (png, svg, pdf)
},
}
finally:
if op.oext:
op.exit()
def _environment_fingerprint() -> dict[str, Any]:
try:
originpro_version = version("originpro")
except PackageNotFoundError:
originpro_version = "unknown"
origin_version = None
try:
import winreg
origin_version = worker._registered_origin_version(winreg)
except (ImportError, OSError):
pass
display: dict[str, int] = {}
if sys.platform == "win32":
try:
import ctypes
user32 = ctypes.windll.user32
display = {
"width_pixels": int(user32.GetSystemMetrics(0)),
"height_pixels": int(user32.GetSystemMetrics(1)),
"system_dpi": int(user32.GetDpiForSystem()),
}
except (AttributeError, OSError):
display = {}
return {
"adapter_version": worker.ADAPTER_VERSION,
"origin_version": origin_version,
"originpro_version": originpro_version,
"python_version": platform.python_version(),
"platform": platform.platform(),
"locale": locale.getlocale(),
"display": display,
"execution_mode": "hidden",
"configured_origin_executable": bool(os.environ.get("ZCBOT_ORIGIN_EXE")),
}
def _origin_processes() -> dict[int, str]:
if sys.platform != "win32":
return {}
completed = subprocess.run(
["tasklist.exe", "/fo", "csv", "/nh"],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
processes: dict[int, str] = {}
import re
for row in csv.reader(completed.stdout.splitlines()):
name = row[0].casefold() if row else ""
if len(row) >= 2 and re.fullmatch(ORIGIN_PROCESS_PATTERN, name):
processes[int(row[1])] = row[0]
return processes
def _wait_for_origin_release(
baseline: dict[int, str], timeout_seconds: int
) -> dict[int, str]:
deadline = time.monotonic() + timeout_seconds
while True:
remaining = {
pid: name for pid, name in _origin_processes().items() if pid not in baseline
}
if not remaining:
return {}
if time.monotonic() >= deadline:
return remaining
time.sleep(2)
def _run_case(
root: Path,
case_name: str,
case: dict[str, Any],
baseline_processes: dict[int, str],
release_wait: int,
) -> dict[str, Any]:
job_dir = _stage(root, case_name, case)
started = time.monotonic()
completed = subprocess.run(
[sys.executable, str(WORKER_PATH), str(job_dir)],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
check=False,
)
elapsed = time.monotonic() - started
terminal_path = job_dir / "terminal.json"
if not terminal_path.is_file():
raise RuntimeError(f"WORKER_TERMINAL_MISSING:{completed.stderr[-500:]}")
terminal = json.loads(terminal_path.read_text(encoding="utf-8"))
if completed.returncode != 0:
raise RuntimeError(f"WORKER_PROCESS_FAILED:{completed.stderr[-500:]}")
validation = _validate_manifest(job_dir, case["request"], terminal)
reopened = _reopen_and_export(job_dir, case)
remaining = _wait_for_origin_release(baseline_processes, release_wait)
if remaining:
raise RuntimeError(f"ORIGIN_PROCESS_REMAINS:{remaining}")
_, digest = _canonical_request(case["request"])
return {
"case": case_name,
"elapsed_seconds": round(elapsed, 3),
"request_digest": digest,
"oracle": case["oracle"],
"validation": validation,
"reopen": reopened,
"origin_processes_released": True,
}
def run_suite(
root: Path,
selected: list[str] | None = None,
*,
release_wait: int = 60,
) -> dict[str, Any]:
cases = _cases()
names = selected or list(cases)
unknown = sorted(set(names) - cases.keys())
if unknown:
raise ValueError(f"UNKNOWN_ACCEPTANCE_CASES:{','.join(unknown)}")
root.mkdir(parents=True, exist_ok=False)
baseline_processes = _origin_processes()
report = {
"schema_version": REPORT_SCHEMA_VERSION,
"started_at": datetime.now(timezone.utc).isoformat(),
"environment": _environment_fingerprint(),
"baseline_origin_processes": baseline_processes,
"selected_cases": names,
"cases": [],
"passed": False,
}
report_path = root / "acceptance-report.json"
try:
for index, name in enumerate(names, start=1):
report["cases"].append(
_run_case(root, name, cases[name], baseline_processes, release_wait)
)
worker._atomic_json(report_path, report)
print(f"[OK] Origin acceptance {index}/{len(names)}: {name}")
report["passed"] = True
return report
except Exception as exc:
report["failure"] = {
"type": type(exc).__name__,
"detail": str(exc)[:1000],
"completed_cases": len(report["cases"]),
}
raise
finally:
report["completed_at"] = datetime.now(timezone.utc).isoformat()
worker._atomic_json(report_path, report)
def main() -> int:
parser = argparse.ArgumentParser(description="Run fixed Origin plot acceptance cases.")
parser.add_argument("--work-root", type=Path, required=True)
parser.add_argument("--release-wait", type=int, default=60)
parser.add_argument(
"--case",
action="append",
choices=tuple(_cases()),
dest="cases",
help="Run only the named case; repeat the option to select multiple cases.",
)
args = parser.parse_args()
if sys.platform != "win32":
raise RuntimeError("Origin acceptance requires Windows")
if args.release_wait < 1:
raise ValueError("release-wait must be positive")
root = args.work_root.resolve()
report = run_suite(root, args.cases, release_wait=args.release_wait)
print(f"[OK] Origin acceptance passed. Report: {root / 'acceptance-report.json'}")
print(f"[INFO] Cases: {len(report['cases'])}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,6 +1,6 @@
{
"capability": "origin.plot@v2",
"adapter_version": "1.0.1",
"adapter_version": "1.1.0",
"runtime": "python",
"runtime_id": "origin",
"entrypoint": "worker.py",

View File

@ -75,7 +75,7 @@ PANEL_LAYOUT = {
(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 = "1.0.1"
ADAPTER_VERSION = "1.1.0"
def _server_executable(command: str) -> Path:
@ -273,6 +273,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
raise ValueError("PANEL_LEFT_AXIS_REQUIRES_SERIES")
for axis_name in ("x_axis", "y_axis", "right_y_axis"):
_validate_axis(panel.get(axis_name), axis_name)
_validate_annotations(panel.get("annotations"))
for axis_name in ("x_axis", "y_axis"):
_validate_axis(plot.get(axis_name), axis_name)
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
@ -282,6 +283,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
else:
series = plot["series"]
_validate_annotations(plot.get("annotations"))
used_inputs = {item["input"] for item in series}
if used_inputs != input_keys:
raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY")
@ -313,6 +315,19 @@ def _validate_semantics(request: dict[str, Any]) -> None:
_validate_axis(plot.get(axis_name), axis_name)
def _validate_annotations(value: Any) -> None:
for item in value if isinstance(value, list) else []:
numeric_fields = (
("value",) if item["kind"] == "reference_line"
else ("from", "to") if item["kind"] == "reference_band"
else ("x", "y")
)
if any(not math.isfinite(float(item[name])) for name in numeric_fields):
raise ValueError("ANNOTATION_COORDINATE_NOT_FINITE")
if item["kind"] == "reference_band" and item["from"] >= item["to"]:
raise ValueError("ANNOTATION_BAND_RANGE_INVALID")
def _validate_axis(value: Any, name: str) -> None:
axis = value if isinstance(value, dict) else {}
minimum = axis.get("minimum")
@ -633,11 +648,127 @@ def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
if "title_font_size" in settings:
title_object = {"x": "xb", "y": "yl", "y2": "yr", "z": "zf"}[name]
layer.label(title_object).set_int("fsize", round(settings["title_font_size"]))
if "minor_ticks" in settings:
layer.set_int(f"{name}.minorTicks", int(settings["minor_ticks"]))
if "reverse" in settings:
layer.set_int(f"{name}.reverse", int(settings["reverse"]))
if "grid" in settings:
grid_value = {"none": 0, "major": 1, "major_minor": 3}[settings["grid"]]
layer.set_int(f"{name}.grid.show", grid_value)
def _lt_number(value: Any) -> str:
number = float(value)
if not math.isfinite(number):
raise ValueError("ANNOTATION_COORDINATE_NOT_FINITE")
return format(number, ".15g")
def _style_graphic_object(layer: Any, name: str, color: str) -> None:
layer.obj.LT_execute(f'{name}.color=color("{color}");')
def _add_annotation_label(
layer: Any,
*,
name: str,
text: str,
x: float,
y: float,
color: str,
font_size: float,
rotation: float = 0,
background: str = "none",
) -> None:
label = layer.add_label(text, x, y)
label.name = name
label.set_int("attach", 2)
label.set_int("background", 4 if background == "white" else 0)
label.set_int("fsize", round(font_size))
label.set_float("rotate", float(rotation))
_style_graphic_object(layer, name, color)
def _apply_annotations(layer: Any, annotations: Any) -> None:
if not isinstance(annotations, list) or not annotations:
return
layer.activate()
x_begin, x_end = layer.xlim
y_begin, y_end = layer.ylim
x_low, x_high = sorted((float(x_begin), float(x_end)))
y_low, y_high = sorted((float(y_begin), float(y_end)))
x_margin = (x_high - x_low) * 0.02
y_margin = (y_high - y_low) * 0.02
for index, item in enumerate(annotations, start=1):
name = f"ZCBOT_ANN_{index:03d}"
kind = item["kind"]
color = item.get("color", "#666666")
if kind == "text":
_add_annotation_label(
layer,
name=name,
text=item["text"],
x=float(item["x"]),
y=float(item["y"]),
color=color,
font_size=float(item.get("font_size", 10)),
rotation=float(item.get("rotation", 0)),
background=item.get("background", "none"),
)
continue
if kind == "reference_line":
orientation = "-v" if item["axis"] == "x" else "-h"
layer.obj.LT_execute(
f"draw -n {name} -l {orientation} {_lt_number(item['value'])};"
f'{name}.color=color("{color}");'
f"{name}.lineWidth={_lt_number(item.get('line_width', 1))};"
f"{name}.lineType={LINE_STYLES[item.get('line_style', 'dash')]};"
f"{name}.attach=2;{name}.clip=1;"
)
if item.get("label"):
x = float(item["value"]) + x_margin if item["axis"] == "x" else x_low + x_margin
y = y_high - y_margin if item["axis"] == "x" else float(item["value"]) + y_margin
_add_annotation_label(
layer,
name=f"{name}_LABEL",
text=item["label"],
x=x,
y=y,
color=color,
font_size=float(item.get("label_font_size", 9)),
)
continue
low = float(item["from"])
high = float(item["to"])
corners = (
((low, y_low), (high, y_low), (high, y_high), (low, y_high))
if item["axis"] == "x"
else ((x_low, low), (x_high, low), (x_high, high), (x_low, high))
)
coordinates = "".join(
f"{name}.x{point}={_lt_number(x)};{name}.y{point}={_lt_number(y)};"
for point, (x, y) in enumerate(corners, start=1)
)
layer.obj.LT_execute(
f"draw -n {name} -b;{name}.attach=2;{coordinates}"
f'{name}.color=color("{color}");{name}.fillColor=color("{color}");'
f"{name}.transparency={int(item.get('transparency', 80))};"
f"{name}.clip=1;"
)
if item.get("label"):
x = (low + high) / 2 if item["axis"] == "x" else x_low + x_margin
y = y_high - y_margin if item["axis"] == "x" else (low + high) / 2
_add_annotation_label(
layer,
name=f"{name}_LABEL",
text=item["label"],
x=x,
y=y,
color=color,
font_size=float(item.get("label_font_size", 9)),
)
def _rescale_with_axis_scales(layer: Any, axes: list[tuple[str, Any]]) -> None:
"""Apply axis transforms before Origin derives automatic limits.
@ -1190,6 +1321,8 @@ def _build_recipe_graph(
_share_axis_limits(primary_layers, "x")
if layout.get("share_y") and len(primary_layers) > 1:
_share_axis_limits(primary_layers, "y")
for panel, layer in zip(panels, primary_layers, strict=True):
_apply_annotations(layer, panel.get("annotations"))
_apply_page_title(
graph,
primary_layers[0],
@ -1386,6 +1519,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
if plot_type == "bubble" and style:
style = {key: value for key, value in style.items() if key != "symbol_size"}
_apply_series_style(origin_plot, style)
_apply_annotations(layer, plot_spec.get("annotations"))
if plot_type in STACKED_PLOT_TYPES:
_replace_series_legend(layer, series_specs)
if plot_type == "surface_3d":

View File

@ -22,6 +22,7 @@ mkdir "!PACKAGE_DIR!"
copy /y "adapters\origin.plot@v2\adapter.json" "!PACKAGE_DIR!\adapter.json" >nul
copy /y "adapters\origin.plot@v2\worker.py" "!PACKAGE_DIR!\worker.py" >nul
copy /y "adapters\origin.plot@v2\acceptance.py" "!PACKAGE_DIR!\acceptance.py" >nul
copy /y "adapters\origin.plot@v2\requirements.txt" "!PACKAGE_DIR!\requirements.txt" >nul
copy /y "..\software-contracts\origin.plot.v2.json" "!PACKAGE_DIR!\origin.plot.v2.json" >nul
if errorlevel 1 goto :failed