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