feat(origin): add annotations and acceptance harness
This commit is contained in:
parent
d2da9b243f
commit
8ab548a1cd
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||||
|
|
||||||
最后更新:2026-08-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.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 阶段。
|
- **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
6
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.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 只保留当前和上一版工程文件。
|
声明式绘图使用 `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 版本完成真机验证后再增量开放。
|
科研统计图使用独立单图 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 交付目录,不维护第二份源码。
|
两个 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。
|
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。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@
|
||||||
"workspace": {
|
"workspace": {
|
||||||
"state_output": "project",
|
"state_output": "project",
|
||||||
"preview_outputs": ["figure_png"],
|
"preview_outputs": ["figure_png"],
|
||||||
"required_adapter_version": "1.0.0"
|
"required_adapter_version": "1.1.0"
|
||||||
},
|
},
|
||||||
"request_schema": {
|
"request_schema": {
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
|
@ -185,6 +185,11 @@
|
||||||
"template": {"const": "publication_double_column"},
|
"template": {"const": "publication_double_column"},
|
||||||
"title": {"type": "string", "maxLength": 500},
|
"title": {"type": "string", "maxLength": 500},
|
||||||
"title_style": {"$ref": "#/$defs/text_style"},
|
"title_style": {"$ref": "#/$defs/text_style"},
|
||||||
|
"annotations": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 64,
|
||||||
|
"items": {"$ref": "#/$defs/annotation"}
|
||||||
|
},
|
||||||
"canvas": {
|
"canvas": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["width_mm", "height_mm"],
|
"required": ["width_mm", "height_mm"],
|
||||||
|
|
@ -216,7 +221,10 @@
|
||||||
"if": {"properties": {"type": {"enum": ["multi_panel", "recipe"]}}},
|
"if": {"properties": {"type": {"enum": ["multi_panel", "recipe"]}}},
|
||||||
"then": {
|
"then": {
|
||||||
"required": ["layout", "panels"],
|
"required": ["layout", "panels"],
|
||||||
"not": {"required": ["series"]}
|
"not": {"anyOf": [
|
||||||
|
{"required": ["series"]},
|
||||||
|
{"required": ["annotations"]}
|
||||||
|
]}
|
||||||
},
|
},
|
||||||
"else": {
|
"else": {
|
||||||
"required": ["series"],
|
"required": ["series"],
|
||||||
|
|
@ -379,9 +387,14 @@
|
||||||
"not": {"anyOf": [
|
"not": {"anyOf": [
|
||||||
{"required": ["x_axis"]},
|
{"required": ["x_axis"]},
|
||||||
{"required": ["y_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"},
|
"minimum": {"type": "number"},
|
||||||
"maximum": {"type": "number"},
|
"maximum": {"type": "number"},
|
||||||
"major_step": {"type": "number", "exclusiveMinimum": 0},
|
"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_angle": {"type": "number", "minimum": -180, "maximum": 180},
|
||||||
"tick_label_font_size": {"type": "number", "minimum": 6, "maximum": 72},
|
"tick_label_font_size": {"type": "number", "minimum": 6, "maximum": 72},
|
||||||
"title_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}
|
"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": {
|
"series_style": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
|
@ -517,6 +581,11 @@
|
||||||
"description": "面板上方标题;它不替代 y_axis.title。需要纵轴名称和单位时应同时设置 y_axis。"
|
"description": "面板上方标题;它不替代 y_axis.title。需要纵轴名称和单位时应同时设置 y_axis。"
|
||||||
},
|
},
|
||||||
"title_style": {"$ref": "#/$defs/text_style"},
|
"title_style": {"$ref": "#/$defs/text_style"},
|
||||||
|
"annotations": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 64,
|
||||||
|
"items": {"$ref": "#/$defs/annotation"}
|
||||||
|
},
|
||||||
"x_axis": {"$ref": "#/$defs/axis"},
|
"x_axis": {"$ref": "#/$defs/axis"},
|
||||||
"y_axis": {
|
"y_axis": {
|
||||||
"$ref": "#/$defs/axis",
|
"$ref": "#/$defs/axis",
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -271,6 +271,8 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 100,
|
"maximum": 100,
|
||||||
"major_step": 1,
|
"major_step": 1,
|
||||||
|
"minor_ticks": 4,
|
||||||
|
"reverse": True,
|
||||||
"tick_label_angle": 45,
|
"tick_label_angle": 45,
|
||||||
"tick_label_font_size": 10,
|
"tick_label_font_size": 10,
|
||||||
"title_font_size": 12,
|
"title_font_size": 12,
|
||||||
|
|
@ -286,11 +288,96 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
{
|
{
|
||||||
"x.label.rotate": 45,
|
"x.label.rotate": 45,
|
||||||
"x.label.pt": 10,
|
"x.label.pt": 10,
|
||||||
|
"x.minorTicks": 4,
|
||||||
|
"x.reverse": 1,
|
||||||
"x.grid.show": 3,
|
"x.grid.show": 3,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
|
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:
|
def test_rescale_applies_log_scales_before_deriving_limits(self) -> None:
|
||||||
class FakeAxis:
|
class FakeAxis:
|
||||||
def __init__(self, name, events):
|
def __init__(self, name, events):
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class SoftwareContractTests(unittest.TestCase):
|
||||||
self.assertEqual(contract.output_namespace, "origin")
|
self.assertEqual(contract.output_namespace, "origin")
|
||||||
self.assertEqual(contract.workspace.state_output, "project")
|
self.assertEqual(contract.workspace.state_output, "project")
|
||||||
self.assertEqual(contract.workspace.preview_outputs, ("figure_png",))
|
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(
|
self.assertEqual(
|
||||||
set(contract.expected_outputs(request)),
|
set(contract.expected_outputs(request)),
|
||||||
{"project", "figure_png", "plot_spec", "provenance"},
|
{"project", "figure_png", "plot_spec", "provenance"},
|
||||||
|
|
@ -62,13 +62,16 @@ class SoftwareContractTests(unittest.TestCase):
|
||||||
"origin.plot@v2": {
|
"origin.plot@v2": {
|
||||||
"health": "ready",
|
"health": "ready",
|
||||||
"available_slots": 1,
|
"available_slots": 1,
|
||||||
"adapter_version": "1.0.0",
|
"adapter_version": "1.1.0",
|
||||||
"workspace_protocol_version": 1,
|
"workspace_protocol_version": 1,
|
||||||
"features": ["line", "heatmap"],
|
"features": ["line", "heatmap"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime))
|
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.assertEqual(node_available_slots("origin.plot@v2", current_runtime), 1)
|
||||||
self.assertFalse(node_supports_request(contract, _request("bar"), current_runtime))
|
self.assertFalse(node_supports_request(contract, _request("bar"), current_runtime))
|
||||||
current_runtime["capability_runtime"]["origin.plot@v2"].pop(
|
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))
|
self.assertFalse(node_supports_request(contract, multi_panel, current_runtime))
|
||||||
current_runtime["capability_runtime"]["origin.plot@v2"].update({
|
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))
|
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))
|
self.assertFalse(node_supports_request(contract, recipe, current_runtime))
|
||||||
current_runtime["capability_runtime"]["origin.plot@v2"].update({
|
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))
|
self.assertTrue(node_supports_request(contract, recipe, current_runtime))
|
||||||
|
|
||||||
|
|
@ -135,7 +138,7 @@ class SoftwareContractTests(unittest.TestCase):
|
||||||
self.assertEqual(normalized, bubble)
|
self.assertEqual(normalized, bubble)
|
||||||
self.assertFalse(node_supports_request(contract, bubble, current_runtime))
|
self.assertFalse(node_supports_request(contract, bubble, current_runtime))
|
||||||
current_runtime["capability_runtime"]["origin.plot@v2"].update({
|
current_runtime["capability_runtime"]["origin.plot@v2"].update({
|
||||||
"adapter_version": "1.0.0",
|
"adapter_version": "1.1.0",
|
||||||
"features": ["bubble"],
|
"features": ["bubble"],
|
||||||
})
|
})
|
||||||
self.assertTrue(node_supports_request(contract, bubble, current_runtime))
|
self.assertTrue(node_supports_request(contract, bubble, current_runtime))
|
||||||
|
|
@ -146,11 +149,38 @@ class SoftwareContractTests(unittest.TestCase):
|
||||||
self.assertEqual(normalized, area)
|
self.assertEqual(normalized, area)
|
||||||
self.assertFalse(node_supports_request(contract, area, current_runtime))
|
self.assertFalse(node_supports_request(contract, area, current_runtime))
|
||||||
current_runtime["capability_runtime"]["origin.plot@v2"].update({
|
current_runtime["capability_runtime"]["origin.plot@v2"].update({
|
||||||
"adapter_version": "1.0.0",
|
"adapter_version": "1.1.0",
|
||||||
"features": ["area"],
|
"features": ["area"],
|
||||||
})
|
})
|
||||||
self.assertTrue(node_supports_request(contract, area, current_runtime))
|
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:
|
def test_semantic_version_comparison_is_numeric(self) -> None:
|
||||||
self.assertTrue(version_at_least("0.10.0", "0.4.0"))
|
self.assertTrue(version_at_least("0.10.0", "0.4.0"))
|
||||||
self.assertFalse(version_at_least("0.3.9", "0.4.0"))
|
self.assertFalse(version_at_least("0.3.9", "0.4.0"))
|
||||||
|
|
|
||||||
|
|
@ -752,7 +752,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
|
||||||
node.node_id = uuid4()
|
node.node_id = uuid4()
|
||||||
node.capabilities = ["origin.plot@v2"]
|
node.capabilities = ["origin.plot@v2"]
|
||||||
node.runtime = {"capability_runtime": {"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,
|
"workspace_protocol_version": 1,
|
||||||
"features": ["line"],
|
"features": ["line"],
|
||||||
}}}
|
}}}
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
|
||||||
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
|
connection = (PROJECT / "NodeConnectionLoop.cs").read_text(encoding="utf-8")
|
||||||
self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script)
|
self.assertIn('"adapters\\origin.plot@v2\\adapter.json"', script)
|
||||||
self.assertIn('"adapters\\origin.plot@v2\\worker.py"', 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.assertIn('"..\\software-contracts\\origin.plot.v2.json"', script)
|
||||||
self.assertNotIn("dotnet", script.lower())
|
self.assertNotIn("dotnet", script.lower())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@ windows-node\package-windows-node.bat
|
||||||
|
|
||||||
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要在对应软件卡中更新 runtime。
|
日常只更新 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` 的升级节点继续沿用原目录。
|
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 为准。
|
推荐在 Node 主窗口“专业软件”区域完成后续配置:Origin、ANSYS、Blender 各有独立软件卡,均可自动检测或手工选择受支持的位置,并单独“安装/更新运行环境”。安装动作只接受内置软件定义和随包发布的固定 requirements,不执行用户输入的命令;新环境先在同一数据根目录的临时目录构建,成功后再替换对应 runtime。手工路径保存在当前专用 Windows 账号下,Node 启动 Worker 时注入固定环境变量;“自动检测”可清除手工路径并回到环境变量、COM、卸载注册表、PATH 或标准目录发现。Origin 的手选程序用于确定安装位置和读取版本,实际执行仍以 COM probe 为准。
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"capability": "origin.plot@v2",
|
"capability": "origin.plot@v2",
|
||||||
"adapter_version": "1.0.1",
|
"adapter_version": "1.1.0",
|
||||||
"runtime": "python",
|
"runtime": "python",
|
||||||
"runtime_id": "origin",
|
"runtime_id": "origin",
|
||||||
"entrypoint": "worker.py",
|
"entrypoint": "worker.py",
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ PANEL_LAYOUT = {
|
||||||
(2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10},
|
(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},
|
(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:
|
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")
|
raise ValueError("PANEL_LEFT_AXIS_REQUIRES_SERIES")
|
||||||
for axis_name in ("x_axis", "y_axis", "right_y_axis"):
|
for axis_name in ("x_axis", "y_axis", "right_y_axis"):
|
||||||
_validate_axis(panel.get(axis_name), axis_name)
|
_validate_axis(panel.get(axis_name), axis_name)
|
||||||
|
_validate_annotations(panel.get("annotations"))
|
||||||
for axis_name in ("x_axis", "y_axis"):
|
for axis_name in ("x_axis", "y_axis"):
|
||||||
_validate_axis(plot.get(axis_name), axis_name)
|
_validate_axis(plot.get(axis_name), axis_name)
|
||||||
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
|
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
|
||||||
|
|
@ -282,6 +283,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
|
||||||
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
|
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
|
||||||
else:
|
else:
|
||||||
series = plot["series"]
|
series = plot["series"]
|
||||||
|
_validate_annotations(plot.get("annotations"))
|
||||||
used_inputs = {item["input"] for item in series}
|
used_inputs = {item["input"] for item in series}
|
||||||
if used_inputs != input_keys:
|
if used_inputs != input_keys:
|
||||||
raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY")
|
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)
|
_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:
|
def _validate_axis(value: Any, name: str) -> None:
|
||||||
axis = value if isinstance(value, dict) else {}
|
axis = value if isinstance(value, dict) else {}
|
||||||
minimum = axis.get("minimum")
|
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:
|
if "title_font_size" in settings:
|
||||||
title_object = {"x": "xb", "y": "yl", "y2": "yr", "z": "zf"}[name]
|
title_object = {"x": "xb", "y": "yl", "y2": "yr", "z": "zf"}[name]
|
||||||
layer.label(title_object).set_int("fsize", round(settings["title_font_size"]))
|
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:
|
if "grid" in settings:
|
||||||
grid_value = {"none": 0, "major": 1, "major_minor": 3}[settings["grid"]]
|
grid_value = {"none": 0, "major": 1, "major_minor": 3}[settings["grid"]]
|
||||||
layer.set_int(f"{name}.grid.show", grid_value)
|
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:
|
def _rescale_with_axis_scales(layer: Any, axes: list[tuple[str, Any]]) -> None:
|
||||||
"""Apply axis transforms before Origin derives automatic limits.
|
"""Apply axis transforms before Origin derives automatic limits.
|
||||||
|
|
||||||
|
|
@ -1190,6 +1321,8 @@ def _build_recipe_graph(
|
||||||
_share_axis_limits(primary_layers, "x")
|
_share_axis_limits(primary_layers, "x")
|
||||||
if layout.get("share_y") and len(primary_layers) > 1:
|
if layout.get("share_y") and len(primary_layers) > 1:
|
||||||
_share_axis_limits(primary_layers, "y")
|
_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(
|
_apply_page_title(
|
||||||
graph,
|
graph,
|
||||||
primary_layers[0],
|
primary_layers[0],
|
||||||
|
|
@ -1386,6 +1519,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
if plot_type == "bubble" and style:
|
if plot_type == "bubble" and style:
|
||||||
style = {key: value for key, value in style.items() if key != "symbol_size"}
|
style = {key: value for key, value in style.items() if key != "symbol_size"}
|
||||||
_apply_series_style(origin_plot, style)
|
_apply_series_style(origin_plot, style)
|
||||||
|
_apply_annotations(layer, plot_spec.get("annotations"))
|
||||||
if plot_type in STACKED_PLOT_TYPES:
|
if plot_type in STACKED_PLOT_TYPES:
|
||||||
_replace_series_legend(layer, series_specs)
|
_replace_series_legend(layer, series_specs)
|
||||||
if plot_type == "surface_3d":
|
if plot_type == "surface_3d":
|
||||||
|
|
|
||||||
|
|
@ -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\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\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 "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
|
copy /y "..\software-contracts\origin.plot.v2.json" "!PACKAGE_DIR!\origin.plot.v2.json" >nul
|
||||||
if errorlevel 1 goto :failed
|
if errorlevel 1 goto :failed
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue