diff --git a/PROGRESS.md b/PROGRESS.md index 45a4751..643fd11 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-21(Blender Scene Recipe v3,bump 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 阶段。 diff --git a/RUN.md b/RUN.md index 40b026d..dad9fd0 100644 --- a/RUN.md +++ b/RUN.md @@ -1142,6 +1142,8 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动 二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2,`panels` 数量为 1–4;两个 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 卡片已安装的受管解释器运行:`\runtimes\origin\Scripts\python.exe \adapters\origin.plot@v2\acceptance.py --work-root \origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、band,也可重复传入 `--case ` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF,再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle,复导三种图件,并等待 Origin 进程退出;必要时可用 `--release-wait ` 调整释放等待时间。最终报告位于 `\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。 diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index bc1887d..70edc95 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -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", diff --git a/tests/test_origin_acceptance.py b/tests/test_origin_acceptance.py new file mode 100644 index 0000000..f2feb43 --- /dev/null +++ b/tests/test_origin_acceptance.py @@ -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"", + "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() diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index 78afc53..622b08f 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -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): diff --git a/tests/test_software_contracts.py b/tests/test_software_contracts.py index 4e04675..0df70c2 100644 --- a/tests/test_software_contracts.py +++ b/tests/test_software_contracts.py @@ -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")) diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 259f473..8dd42d9 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -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"], }}} diff --git a/tests/test_windows_node_source.py b/tests/test_windows_node_source.py index 03fb0bb..c1d5c15 100644 --- a/tests/test_windows_node_source.py +++ b/tests/test_windows_node_source.py @@ -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()) diff --git a/windows-node/README.md b/windows-node/README.md index 6c1b49e..ee43169 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -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 位于 `\runtimes\\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。 推荐在 Node 主窗口“专业软件”区域完成后续配置:Origin、ANSYS、Blender 各有独立软件卡,均可自动检测或手工选择受支持的位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements,不执行用户输入的命令;新环境先在同一数据根目录的临时目录构建,成功后再替换对应 runtime。手工路径保存在当前专用 Windows 账号下,Node 启动 Worker 时注入固定环境变量;“自动检测”可清除手工路径并回到环境变量、COM、卸载注册表、PATH 或标准目录发现。Origin 的手选程序用于确定安装位置和读取版本,实际执行仍以 COM probe 为准。 diff --git a/windows-node/adapters/origin.plot@v2/acceptance.py b/windows-node/adapters/origin.plot@v2/acceptance.py new file mode 100644 index 0000000..0d79ff6 --- /dev/null +++ b/windows-node/adapters/origin.plot@v2/acceptance.py @@ -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()) diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index 17c04e7..b2913b5 100644 --- a/windows-node/adapters/origin.plot@v2/adapter.json +++ b/windows-node/adapters/origin.plot@v2/adapter.json @@ -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", diff --git a/windows-node/adapters/origin.plot@v2/worker.py b/windows-node/adapters/origin.plot@v2/worker.py index 24760bf..a7b4c61 100644 --- a/windows-node/adapters/origin.plot@v2/worker.py +++ b/windows-node/adapters/origin.plot@v2/worker.py @@ -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": diff --git a/windows-node/package-origin-adapter.bat b/windows-node/package-origin-adapter.bat index d65d1f0..d83cb66 100644 --- a/windows-node/package-origin-adapter.bat +++ b/windows-node/package-origin-adapter.bat @@ -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