feat(origin): add publication layout controls

This commit is contained in:
caoqianming 2026-08-14 13:32:07 +08:00
parent 25616d12cc
commit c4ce89afa0
12 changed files with 371 additions and 48 deletions

View File

@ -8,6 +8,7 @@
## Unreleased ## Unreleased
- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。
- 新增专业软件 Job 中心Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。 - 新增专业软件 Job 中心Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。
- 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。 - 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。
- 专业软件生成的正式文件会保存在当前对话的正确目录并记录来源;运行参数和溯源信息收纳到隐藏元数据目录,减少产物列表噪声。 - 专业软件生成的正式文件会保存在当前对话的正确目录并记录来源;运行参数和溯源信息收纳到隐藏元数据目录,减少产物列表噪声。

View File

@ -472,7 +472,7 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c
第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 116 个 artifact并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input/<key>/<filename>`,同时校验大小与 SHA-256不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease重连按 job、lease、digest 恢复下载或幂等补报终态不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。 第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 116 个 artifact并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input/<key>/<filename>`,同时校验大小与 SHA-256不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease重连按 job、lease、digest 恢复下载或幂等补报终态不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。
第四阶段落地固定 Origin WorkerNode 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,`series[]` 以 `x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin避免无法证明的双执行。 第四阶段落地固定 Origin WorkerNode 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,`series[]` 以 `x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,并以可选的 `canvas`、轴排版、图例、标题和 series style 统一表达出版级尺寸与样式,旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin避免无法证明的双执行。
第五阶段完成输出上传与发布Node 只按固定 manifest ID 逐项流式 PUT并携带 Node、lease、request digest 与内容摘要;云端重新绑定任务身份,不信 Node 提供的路径或媒体类型。文件先进入用户根下隐藏暂存区,固定文件名、单文件/总大小和 SHA-256 全部验证后,把 plot spec、provenance 整理进 `.meta/`,再将完整目录原子移动到 `<working_dir>/origin/<job_id>/`。PNG/SVG/PDF/OPJU 等正式输出登记平台 artifact UUID 和 `software_job_id``.meta/` 只落真实文件;成功状态返回 task-relative `output_dir`Agent 以该目录为起点按需搜索。重复 PUT、complete 和重连均按摘要幂等部分上传不可见只有完整集合才能发布。Origin 执行槽与上传确认是两个正交状态:本地已有终态且固定 Worker 已退出时即释放软件执行槽,成功但尚无 `upload-complete.json` 的任务继续后台补传;若云端已经是 succeeded重复 PUT/complete 必须按数据库持久化 manifest 校验并直接确认,不得按新版本目录规则重新发布旧 Job。 第五阶段完成输出上传与发布Node 只按固定 manifest ID 逐项流式 PUT并携带 Node、lease、request digest 与内容摘要;云端重新绑定任务身份,不信 Node 提供的路径或媒体类型。文件先进入用户根下隐藏暂存区,固定文件名、单文件/总大小和 SHA-256 全部验证后,把 plot spec、provenance 整理进 `.meta/`,再将完整目录原子移动到 `<working_dir>/origin/<job_id>/`。PNG/SVG/PDF/OPJU 等正式输出登记平台 artifact UUID 和 `software_job_id``.meta/` 只落真实文件;成功状态返回 task-relative `output_dir`Agent 以该目录为起点按需搜索。重复 PUT、complete 和重连均按摘要幂等部分上传不可见只有完整集合才能发布。Origin 执行槽与上传确认是两个正交状态:本地已有终态且固定 Worker 已退出时即释放软件执行槽,成功但尚无 `upload-complete.json` 的任务继续后台补传;若云端已经是 succeeded重复 PUT/complete 必须按数据库持久化 manifest 校验并直接确认,不得按新版本目录规则重新发布旧 Job。

View File

@ -22,6 +22,8 @@
### 2026-08-14 ### 2026-08-14
- **08-14 / Unreleased / Origin 出版级排版参数**`origin.plot@v2` 新增可选画布、轴范围/尺度/步长/刻度排版/网格、标题与图例排版以及逐系列颜色、线型、点型和透明度PNG 宽度按物理画布与 DPI 计算旧请求默认行为不变。共享契约、Node 二次校验、固定 Worker 与运行文档已同步,专项 68 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 .NET build 通过;全量 608 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过,未连接或写入生产 DB。
- **08-14 / Unreleased / 专业软件契约与语言无关 adapter 边界**:将 capability 请求 schema、输入额度、输出 manifest、feature/adapter 版本要求和兼容运行时集中到 `software-contracts/*.json`Core 自动发现契约并通用完成校验、工具 schema、调度、上传发布不再包含 Origin 分支。Windows Node 以单一 adapter registry 派生注册能力、配置校验、界面和运行时上报,通用 Host 只处理 job 目录协议adapter 可由 .NET 内置或固定进程以任意语言实现Origin 的 Python Worker 仅是当前实现。调度同时按 feature/adapter 版本选节点,并跳过队首暂不可执行 Job。专项 82 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 .NET build 通过;全量 605 项仅 3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。 - **08-14 / Unreleased / 专业软件契约与语言无关 adapter 边界**:将 capability 请求 schema、输入额度、输出 manifest、feature/adapter 版本要求和兼容运行时集中到 `software-contracts/*.json`Core 自动发现契约并通用完成校验、工具 schema、调度、上传发布不再包含 Origin 分支。Windows Node 以单一 adapter registry 派生注册能力、配置校验、界面和运行时上报,通用 Host 只处理 job 目录协议adapter 可由 .NET 内置或固定进程以任意语言实现Origin 的 Python Worker 仅是当前实现。调度同时按 feature/adapter 版本选节点,并跳过队首暂不可执行 Job。专项 82 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 .NET build 通过;全量 605 项仅 3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。
- **08-14 / Unreleased / Origin 统一图型契约扩展**:`origin.plot@v2` 保持单一入口,`series[]` 增加 `z/y_error` 数据角色并由 `plot.type` 判别校验,在兼容既有 XY 请求的同时新增 column、bar、grouped_column、y_error、contour、surface_3d、ternary、heatmap规则热图在 Worker 内拒绝缺格、重复坐标、非等间距和非有限数值。云端、Agent 工具、Node 二次校验和固定 Worker 已同步,专项 61 项 unittest、Python 编译与 .NET build 通过,未连接或写入生产 DB。 - **08-14 / Unreleased / Origin 统一图型契约扩展**:`origin.plot@v2` 保持单一入口,`series[]` 增加 `z/y_error` 数据角色并由 `plot.type` 判别校验,在兼容既有 XY 请求的同时新增 column、bar、grouped_column、y_error、contour、surface_3d、ternary、heatmap规则热图在 Worker 内拒绝缺格、重复坐标、非等间距和非有限数值。云端、Agent 工具、Node 二次校验和固定 Worker 已同步,专项 61 项 unittest、Python 编译与 .NET build 通过,未连接或写入生产 DB。

2
RUN.md
View File

@ -1100,7 +1100,7 @@ install-windows-node.bat
若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,设置机器级 `ZCBOT_ORIGIN_PYTHON` 为绝对 `python.exe` 路径后重启 Node。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `origin-worker/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入,`line`、`scatter`、`line_scatter` 与 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin/<job_id>/`plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,设置机器级 `ZCBOT_ORIGIN_PYTHON` 为绝对 `python.exe` 路径后重启 Node。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `origin-worker/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入,`line`、`scatter`、`line_scatter` 与 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin/<job_id>/`plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status` 和 `software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 116 个输入、跨输入系列和多个显式输出,只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`Agent可在该目录内搜索并分析正式输出的 artifact 带 `software_job_id`供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status` 和 `software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 116 个输入、跨输入系列和多个显式输出`plot.canvas` 可指定毫米画布轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`Agent可在该目录内搜索并分析正式输出的 artifact 带 `software_job_id`供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
专业软件契约位于 `software-contracts/*.json`Core 会在启动时自动发现。新增 feature 时更新对应契约与 Node adapter新增软件时新增契约并只在 Windows Node 的 `NodeAdapterRegistry.CreateDefault` 注册已安装实现。通用协议不要求 adapter 使用 Python实现可为 .NET 内置代码或由固定 runner 启动的任意语言受信进程Origin 安装器创建 Python 3.12 runtime 只服务当前 Origin adapter。 专业软件契约位于 `software-contracts/*.json`Core 会在启动时自动发现。新增 feature 时更新对应契约与 Node adapter新增软件时新增契约并只在 Windows Node 的 `NodeAdapterRegistry.CreateDefault` 注册已安装实现。通用协议不要求 adapter 使用 Python实现可为 .NET 内置代码或由固定 runner 启动的任意语言受信进程Origin 安装器创建 Python 3.12 runtime 只服务当前 Origin adapter。

View File

@ -55,17 +55,17 @@
}, },
"feature_path": ["operation", "plot", "type"], "feature_path": ["operation", "plot", "type"],
"features": { "features": {
"line": "0.3.0", "line": "0.5.0",
"scatter": "0.3.0", "scatter": "0.5.0",
"line_scatter": "0.3.0", "line_scatter": "0.5.0",
"column": "0.4.0", "column": "0.5.0",
"bar": "0.4.0", "bar": "0.5.0",
"grouped_column": "0.4.0", "grouped_column": "0.5.0",
"y_error": "0.4.0", "y_error": "0.5.0",
"contour": "0.4.0", "contour": "0.5.0",
"surface_3d": "0.4.0", "surface_3d": "0.5.0",
"ternary": "0.4.0", "ternary": "0.5.0",
"heatmap": "0.4.0" "heatmap": "0.5.0"
}, },
"summary": { "summary": {
"title_path": ["operation", "plot", "title"] "title_path": ["operation", "plot", "title"]
@ -137,12 +137,23 @@
"y": {"type": "string", "minLength": 1, "maxLength": 128}, "y": {"type": "string", "minLength": 1, "maxLength": 128},
"z": {"type": "string", "minLength": 1, "maxLength": 128}, "z": {"type": "string", "minLength": 1, "maxLength": 128},
"y_error": {"type": "string", "minLength": 1, "maxLength": 128}, "y_error": {"type": "string", "minLength": 1, "maxLength": 128},
"label": {"type": "string", "minLength": 1, "maxLength": 200} "label": {"type": "string", "minLength": 1, "maxLength": 200},
"style": {"$ref": "#/$defs/series_style"}
} }
} }
}, },
"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"},
"canvas": {
"type": "object",
"required": ["width_mm", "height_mm"],
"additionalProperties": false,
"properties": {
"width_mm": {"type": "number", "minimum": 40, "maximum": 1000},
"height_mm": {"type": "number", "minimum": 40, "maximum": 1000}
}
},
"x_axis": {"$ref": "#/$defs/axis"}, "x_axis": {"$ref": "#/$defs/axis"},
"y_axis": {"$ref": "#/$defs/axis"}, "y_axis": {"$ref": "#/$defs/axis"},
"z_axis": {"$ref": "#/$defs/axis"}, "z_axis": {"$ref": "#/$defs/axis"},
@ -150,8 +161,11 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"enabled": {"const": true}, "enabled": {"type": "boolean"},
"position": {"const": "top_right"} "position": {
"enum": ["top_left", "top_right", "bottom_left", "bottom_right"]
},
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
} }
}, },
"error_bars": {"type": "null"} "error_bars": {"type": "null"}
@ -279,7 +293,33 @@
"properties": { "properties": {
"title": {"type": "string"}, "title": {"type": "string"},
"unit": {"type": "string"}, "unit": {"type": "string"},
"scale": {"const": "linear"} "scale": {"enum": ["linear", "log10", "ln", "log2"]},
"minimum": {"type": "number"},
"maximum": {"type": "number"},
"major_step": {"type": "number", "exclusiveMinimum": 0},
"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},
"grid": {"enum": ["none", "major", "major_minor"]}
}
},
"text_style": {
"type": "object",
"additionalProperties": false,
"properties": {
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
},
"series_style": {
"type": "object",
"additionalProperties": false,
"properties": {
"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"]},
"symbol": {"enum": ["circle", "square", "triangle_up", "diamond", "cross", "plus"]},
"symbol_size": {"type": "number", "minimum": 1, "maximum": 100},
"transparency": {"type": "integer", "minimum": 0, "maximum": 100}
} }
} }
} }

View File

@ -40,6 +40,77 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.assertEqual(worker._axis_title({"title": "Stress", "unit": "MPa"}, "Y"), "Stress (MPa)") self.assertEqual(worker._axis_title({"title": "Stress", "unit": "MPa"}, "Y"), "Stress (MPa)")
self.assertEqual(worker._axis_title(None, "Time"), "Time") self.assertEqual(worker._axis_title(None, "Time"), "Time")
def test_layout_helpers_apply_validated_values(self) -> None:
class FakeAxis:
title = ""
scale = "linear"
limits = None
def set_limits(self, begin, end, step):
self.limits = (begin, end, step)
class FakeLayer:
def __init__(self):
self.axes = {name: FakeAxis() for name in ("x", "y", "z")}
self.values = {}
self.labels = {}
def axis(self, name):
return self.axes[name]
def set_float(self, name, value):
self.values[name] = value
def set_int(self, name, value):
self.values[name] = value
def label(self, name):
class FakeLabel:
def set_int(_, prop, value):
self.labels[(name, prop)] = value
return FakeLabel()
layer = FakeLayer()
worker._apply_axis(layer, "x", {
"title": "Time", "unit": "d", "scale": "log10",
"minimum": 1, "maximum": 100, "major_step": 1,
"tick_label_angle": 45, "tick_label_font_size": 10,
"title_font_size": 12, "grid": "major_minor",
}, "X")
self.assertEqual(layer.axes["x"].title, "Time (d)")
self.assertEqual(layer.axes["x"].scale, "log10")
self.assertEqual(layer.axes["x"].limits, (1, 100, 1))
self.assertEqual(layer.values, {
"x.label.rotate": 45, "x.label.pt": 10, "x.grid.show": 3,
})
self.assertEqual(layer.labels, {("xb", "fsize"): 12})
def test_series_style_maps_to_origin_properties(self) -> None:
class FakePlot:
def __init__(self):
self.values = {}
self.color = None
self.symbol_kind = None
self.symbol_size = None
self.transparency = None
def set_float(self, name, value):
self.values[name] = value
def set_int(self, name, value):
self.values[name] = value
plot = FakePlot()
worker._apply_series_style(plot, {
"color": "#3366CC", "line_width": 1.5, "line_style": "dash_dot",
"symbol": "diamond", "symbol_size": 8, "transparency": 20,
})
self.assertEqual(plot.color, (51, 102, 204))
self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4})
self.assertEqual(plot.symbol_kind, 3)
self.assertEqual(plot.symbol_size, 8)
self.assertEqual(plot.transparency, 20)
def test_keyed_input_directory_requires_exactly_one_file(self) -> None: def test_keyed_input_directory_requires_exactly_one_file(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)

View File

@ -45,14 +45,14 @@ class SoftwareContractTests(unittest.TestCase):
"available_slots": 1, "available_slots": 1,
"origin": {"health": "ready", "adapter_version": "0.3.0"}, "origin": {"health": "ready", "adapter_version": "0.3.0"},
} }
self.assertTrue(node_supports_request(contract, _request("line"), legacy_runtime)) self.assertFalse(node_supports_request(contract, _request("line"), legacy_runtime))
self.assertFalse(node_supports_request(contract, _request("heatmap"), legacy_runtime)) self.assertFalse(node_supports_request(contract, _request("heatmap"), legacy_runtime))
current_runtime = { current_runtime = {
"capability_runtime": { "capability_runtime": {
"origin.plot@v2": { "origin.plot@v2": {
"health": "ready", "health": "ready",
"available_slots": 1, "available_slots": 1,
"adapter_version": "0.4.0", "adapter_version": "0.5.0",
"features": ["line", "heatmap"], "features": ["line", "heatmap"],
} }
} }

View File

@ -18,8 +18,8 @@ from core.software_jobs import (
abandon_offer, abandon_offer,
list_jobs, list_jobs,
mark_node_jobs_disconnected, mark_node_jobs_disconnected,
record_job_terminal,
offer_next_job, offer_next_job,
record_job_terminal,
replay_succeeded_outputs, replay_succeeded_outputs,
request_job_cancel, request_job_cancel,
respond_to_offer, respond_to_offer,
@ -324,8 +324,8 @@ class SoftwareJobProtocolTests(unittest.TestCase):
base_plot = request["operation"]["plot"] base_plot = request["operation"]["plot"]
for case_plot in ( for case_plot in (
{**base_plot, "template": "custom"}, {**base_plot, "template": "custom"},
{**base_plot, "x_axis": {"scale": "log10"}}, {**base_plot, "x_axis": {"scale": "symlog"}},
{**base_plot, "legend": {"enabled": False}}, {**base_plot, "legend": {"position": "outside"}},
{**base_plot, "series": [base_plot["series"][0], base_plot["series"][0]]}, {**base_plot, "series": [base_plot["series"][0], base_plot["series"][0]]},
): ):
with self.subTest(plot=case_plot), self.assertRaisesRegex( with self.subTest(plot=case_plot), self.assertRaisesRegex(
@ -347,6 +347,41 @@ class SoftwareJobProtocolTests(unittest.TestCase):
{**request, "outputs": [{"key": "project", "type": "figure", "format": "png"}]} {**request, "outputs": [{"key": "project", "type": "figure", "format": "png"}]}
) )
def test_origin_request_accepts_publication_layout_options(self) -> None:
request = {
"schema_version": 2,
"inputs": [{"key": "sample", "artifact_id": str(uuid4())}],
"operation": {"plot": {
"type": "line_scatter",
"series": [{
"input": "sample", "x": "time", "y": "strength",
"style": {
"color": "#3366CC", "line_width": 1.5,
"line_style": "dash", "symbol": "circle",
"symbol_size": 8, "transparency": 10,
},
}],
"title": "Strength development",
"title_style": {"font_size": 16},
"canvas": {"width_mm": 180, "height_mm": 120},
"x_axis": {
"scale": "log10", "minimum": 1, "maximum": 100,
"major_step": 1, "tick_label_angle": 45,
"tick_label_font_size": 10, "title_font_size": 12,
"grid": "major",
},
"y_axis": {"minimum": 0, "maximum": 80, "major_step": 10},
"legend": {"enabled": False, "position": "top_left", "font_size": 9},
}},
"outputs": [{
"key": "figure_png", "type": "figure", "format": "png",
"options": {"dpi": 600},
}],
}
normalized, digest = _canonical_request("origin.plot@v2", request)
self.assertEqual(normalized, request)
self.assertEqual(len(digest), 64)
def test_origin_request_supports_unified_series_roles(self) -> None: def test_origin_request_supports_unified_series_roles(self) -> None:
artifact_id = str(uuid4()) artifact_id = str(uuid4())
outputs = [{"key": "figure_png", "type": "figure", "format": "png"}] outputs = [{"key": "figure_png", "type": "figure", "format": "png"}]
@ -459,10 +494,10 @@ class SoftwareJobProtocolTests(unittest.TestCase):
node = type("Node", (), {})() node = type("Node", (), {})()
node.node_id = uuid4() node.node_id = uuid4()
node.capabilities = ["origin.plot@v2"] node.capabilities = ["origin.plot@v2"]
node.runtime = { node.runtime = {"capability_runtime": {"origin.plot@v2": {
"available_slots": 1, "health": "ready", "available_slots": 1, "adapter_version": "0.5.0",
"origin": {"health": "ready", "adapter_version": "0.3.0"}, "features": ["line"],
} }}}
jobs = [] jobs = []
for plot_type in ("heatmap", "line"): for plot_type in ("heatmap", "line"):
job = type("Job", (), {})() job = type("Job", (), {})()

View File

@ -12,7 +12,7 @@ windows-node\package-windows-node.bat
产物位于 `windows-node\dist\zcbot-windows-node-<version>-win-x64.zip`,旁边同时生成 SHA-256 文件。默认不包含 .NET Runtime目标机器需预装 .NET 10 Desktop Runtime需要免安装 .NET 的离线包时执行 `package-windows-node.bat --self-contained`。打包脚本是纯 BAT使用 .NET SDK 以及 Windows 自带的 `tar.exe`、`certutil.exe`。 产物位于 `windows-node\dist\zcbot-windows-node-<version>-win-x64.zip`,旁边同时生成 SHA-256 文件。默认不包含 .NET Runtime目标机器需预装 .NET 10 Desktop Runtime需要免安装 .NET 的离线包时执行 `package-windows-node.bat --self-contained`。打包脚本是纯 BAT使用 .NET SDK 以及 Windows 自带的 `tar.exe`、`certutil.exe`。
固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口使用实际运行 Node 的专用 Windows 账号直接双击: 固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式,生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口使用实际运行 Node 的专用 Windows 账号直接双击:
```text ```text
install-windows-node.bat install-windows-node.bat

View File

@ -509,7 +509,7 @@ internal sealed class JobInboxStore(string jobsDirectory)
if (plot.ValueKind != JsonValueKind.Object if (plot.ValueKind != JsonValueKind.Object
|| !HasOnlyProperties( || !HasOnlyProperties(
plot, "type", "series", "template", "title", "x_axis", "y_axis", "z_axis", plot, "type", "series", "template", "title", "x_axis", "y_axis", "z_axis",
"legend", "error_bars") "legend", "error_bars", "title_style", "canvas")
|| !plot.TryGetProperty("type", out var plotType) || !plot.TryGetProperty("type", out var plotType)
|| plotType.GetString() is not { } plotTypeName || plotType.GetString() is not { } plotTypeName
|| !supportedPlotTypes.Contains(plotTypeName, StringComparer.Ordinal) || !supportedPlotTypes.Contains(plotTypeName, StringComparer.Ordinal)
@ -520,6 +520,8 @@ internal sealed class JobInboxStore(string jobsDirectory)
|| series.GetArrayLength() is < 1 or > 16 || series.GetArrayLength() is < 1 or > 16
|| plot.TryGetProperty("template", out var template) || plot.TryGetProperty("template", out var template)
&& template.GetString() != "publication_double_column" && template.GetString() != "publication_double_column"
|| !IsValidTextStyle(plot, "title_style")
|| !IsValidCanvas(plot)
|| !IsValidAxis(plot, "x_axis") || !IsValidAxis(plot, "x_axis")
|| !IsValidAxis(plot, "y_axis") || !IsValidAxis(plot, "y_axis")
|| !IsValidAxis(plot, "z_axis") || !IsValidAxis(plot, "z_axis")
@ -548,7 +550,7 @@ internal sealed class JobInboxStore(string jobsDirectory)
foreach (var item in series.EnumerateArray()) foreach (var item in series.EnumerateArray())
{ {
if (item.ValueKind != JsonValueKind.Object if (item.ValueKind != JsonValueKind.Object
|| !HasOnlyProperties(item, "input", "x", "y", "z", "y_error", "label") || !HasOnlyProperties(item, "input", "x", "y", "z", "y_error", "label", "style")
|| !item.TryGetProperty("input", out var input) || !item.TryGetProperty("input", out var input)
|| input.GetString() is not { } inputKey || input.GetString() is not { } inputKey
|| !inputKeys.Contains(inputKey) || !inputKeys.Contains(inputKey)
@ -559,6 +561,7 @@ internal sealed class JobInboxStore(string jobsDirectory)
|| item.TryGetProperty("label", out var label) || item.TryGetProperty("label", out var label)
&& (label.ValueKind != JsonValueKind.String && (label.ValueKind != JsonValueKind.String
|| label.GetString()!.Length is < 1 or > 200) || label.GetString()!.Length is < 1 or > 200)
|| !IsValidSeriesStyle(item)
|| !identities.Add(string.Join( || !identities.Add(string.Join(
"\0", new[] { inputKey }.Concat(requiredRoles.Select( "\0", new[] { inputKey }.Concat(requiredRoles.Select(
role => item.GetProperty(role).GetString()!))))) role => item.GetProperty(role).GetString()!)))))
@ -603,24 +606,101 @@ internal sealed class JobInboxStore(string jobsDirectory)
private static bool IsValidAxis(JsonElement plot, string name) private static bool IsValidAxis(JsonElement plot, string name)
{ {
if (!plot.TryGetProperty(name, out var axis)) return true; if (!plot.TryGetProperty(name, out var axis)) return true;
return axis.ValueKind == JsonValueKind.Object if (axis.ValueKind != JsonValueKind.Object
&& HasOnlyProperties(axis, "title", "unit", "scale") || !HasOnlyProperties(
&& (!axis.TryGetProperty("title", out var title) || title.ValueKind == JsonValueKind.String) axis, "title", "unit", "scale", "minimum", "maximum", "major_step",
&& (!axis.TryGetProperty("unit", out var unit) || unit.ValueKind == JsonValueKind.String) "tick_label_angle", "tick_label_font_size", "title_font_size", "grid"))
&& (!axis.TryGetProperty("scale", out var scale) || scale.GetString() == "linear"); {
return false;
}
if (axis.TryGetProperty("title", out var titleValue)
&& titleValue.ValueKind != JsonValueKind.String
|| axis.TryGetProperty("unit", out var unitValue)
&& unitValue.ValueKind != JsonValueKind.String
|| axis.TryGetProperty("scale", out var scale)
&& scale.GetString() is not ("linear" or "log10" or "ln" or "log2")
|| axis.TryGetProperty("grid", out var grid)
&& grid.GetString() is not ("none" or "major" or "major_minor")
|| !IsOptionalNumber(axis, "minimum")
|| !IsOptionalNumber(axis, "maximum")
|| !IsOptionalNumber(axis, "major_step", 0, double.PositiveInfinity, false)
|| !IsOptionalNumber(axis, "tick_label_angle", -180, 180)
|| !IsOptionalNumber(axis, "tick_label_font_size", 6, 72)
|| !IsOptionalNumber(axis, "title_font_size", 6, 72))
{
return false;
}
return !axis.TryGetProperty("minimum", out var minimum)
|| !axis.TryGetProperty("maximum", out var maximum)
|| minimum.GetDouble() < maximum.GetDouble();
} }
private static bool IsValidLegend(JsonElement plot) private static bool IsValidLegend(JsonElement plot)
{ {
if (!plot.TryGetProperty("legend", out var legend)) return true; if (!plot.TryGetProperty("legend", out var legend)) return true;
return legend.ValueKind == JsonValueKind.Object return legend.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(legend, "enabled", "position") && HasOnlyProperties(legend, "enabled", "position", "font_size")
&& (!legend.TryGetProperty("enabled", out var enabled) && (!legend.TryGetProperty("enabled", out var enabled)
|| enabled.ValueKind == JsonValueKind.True) || enabled.ValueKind is JsonValueKind.True or JsonValueKind.False)
&& (!legend.TryGetProperty("position", out var position) && (!legend.TryGetProperty("position", out var position)
|| position.GetString() == "top_right"); || position.GetString() is "top_left" or "top_right" or "bottom_left" or "bottom_right")
&& IsOptionalNumber(legend, "font_size", 6, 72);
} }
private static bool IsValidTextStyle(JsonElement parent, string name) =>
!parent.TryGetProperty(name, out var style)
|| style.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(style, "font_size")
&& IsOptionalNumber(style, "font_size", 6, 72);
private static bool IsValidCanvas(JsonElement plot) =>
!plot.TryGetProperty("canvas", out var canvas)
|| canvas.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(canvas, "width_mm", "height_mm")
&& canvas.TryGetProperty("width_mm", out _)
&& canvas.TryGetProperty("height_mm", out _)
&& IsOptionalNumber(canvas, "width_mm", 40, 1000)
&& IsOptionalNumber(canvas, "height_mm", 40, 1000);
private static bool IsValidSeriesStyle(JsonElement series)
{
if (!series.TryGetProperty("style", out var style)) return true;
return style.ValueKind == JsonValueKind.Object
&& HasOnlyProperties(
style, "color", "line_width", "line_style", "symbol", "symbol_size",
"transparency")
&& (!style.TryGetProperty("color", out var color)
|| color.ValueKind == JsonValueKind.String && IsHexColor(color.GetString()!))
&& (!style.TryGetProperty("line_style", out var lineStyle)
|| lineStyle.GetString() is "solid" or "dash" or "dot" or "dash_dot" or "dash_dot_dot")
&& (!style.TryGetProperty("symbol", out var symbol)
|| symbol.GetString() is "circle" or "square" or "triangle_up" or "diamond" or "cross" or "plus")
&& IsOptionalNumber(style, "line_width", 0.1, 20)
&& IsOptionalNumber(style, "symbol_size", 1, 100)
&& (!style.TryGetProperty("transparency", out var transparency)
|| transparency.TryGetInt32(out var value) && value is >= 0 and <= 100);
}
private static bool IsOptionalNumber(
JsonElement parent, string name, double minimum = double.NegativeInfinity,
double maximum = double.PositiveInfinity, bool inclusiveMinimum = true)
{
if (!parent.TryGetProperty(name, out var value)) return true;
if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var number)
|| double.IsNaN(number) || double.IsInfinity(number))
{
return false;
}
return (inclusiveMinimum ? number >= minimum : number > minimum) && number <= maximum;
}
private static bool IsHexColor(string value) =>
value.Length == 7 && value[0] == '#'
&& value.Skip(1).All(character =>
character is >= '0' and <= '9'
|| character is >= 'a' and <= 'f'
|| character is >= 'A' and <= 'F');
private static bool IsValidInputTransfers(JsonElement transfers, Guid jobId) private static bool IsValidInputTransfers(JsonElement transfers, Guid jobId)
{ {
if (transfers.ValueKind != JsonValueKind.Array if (transfers.ValueKind != JsonValueKind.Array

View File

@ -18,7 +18,7 @@ internal interface INodeAdapter
internal sealed class OriginPlotNodeAdapter(JobInboxStore inbox) : INodeAdapter internal sealed class OriginPlotNodeAdapter(JobInboxStore inbox) : INodeAdapter
{ {
internal const string CurrentAdapterVersion = "0.4.0"; internal const string CurrentAdapterVersion = "0.5.0";
internal const string ContractFilename = "origin.plot.v2.json"; internal const string ContractFilename = "origin.plot.v2.json";
private static readonly NodeAdapterContract Contract = private static readonly NodeAdapterContract Contract =
NodeAdapterContract.Load(ContractFilename, CurrentAdapterVersion); NodeAdapterContract.Load(ContractFilename, CurrentAdapterVersion);

View File

@ -31,6 +31,27 @@ PLOT_CONFIG = {
} }
XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"} XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"}
FORMATS = {"opju", "png", "svg", "pdf"} FORMATS = {"opju", "png", "svg", "pdf"}
LINE_STYLES = {
"solid": 1,
"dash": 2,
"dot": 3,
"dash_dot": 4,
"dash_dot_dot": 5,
}
SYMBOLS = {
"square": 0,
"circle": 1,
"triangle_up": 2,
"diamond": 3,
"cross": 9,
"plus": 10,
}
LEGEND_POSITIONS = {
"top_left": (700, 500),
"top_right": (6800, 500),
"bottom_left": (700, 7200),
"bottom_right": (6800, 7200),
}
def _atomic_json(path: Path, value: Any) -> None: def _atomic_json(path: Path, value: Any) -> None:
@ -117,6 +138,71 @@ def _axis_title(axis: Any, fallback: str) -> str:
return f"{title} ({unit})" if unit else title return f"{title} ({unit})" if unit else title
def _hex_color(value: str) -> tuple[int, int, int]:
return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5))
def _apply_canvas(graph: Any, canvas: Any) -> None:
if not isinstance(canvas, dict):
return
width = float(canvas["width_mm"])
height = float(canvas["height_mm"])
graph.set_float("width", width / 25.4 * graph.get_float("resx"))
graph.set_float("height", height / 25.4 * graph.get_float("resy"))
def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
settings = spec if isinstance(spec, dict) else {}
axis = layer.axis(name)
axis.title = _axis_title(settings, fallback)
if "scale" in settings:
axis.scale = settings["scale"]
if any(key in settings for key in ("minimum", "maximum", "major_step")):
axis.set_limits(
settings.get("minimum"), settings.get("maximum"), settings.get("major_step")
)
if "tick_label_angle" in settings:
layer.set_float(f"{name}.label.rotate", float(settings["tick_label_angle"]))
if "tick_label_font_size" in settings:
layer.set_float(f"{name}.label.pt", float(settings["tick_label_font_size"]))
if "title_font_size" in settings:
title_object = {"x": "xb", "y": "yl", "z": "zf"}[name]
layer.label(title_object).set_int("fsize", round(settings["title_font_size"]))
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 _apply_series_style(origin_plot: Any, style: Any) -> None:
if not isinstance(style, dict):
return
if "color" in style:
origin_plot.color = _hex_color(style["color"])
if "line_width" in style:
origin_plot.set_float("line.width", float(style["line_width"]))
if "line_style" in style:
origin_plot.set_int("line.type", LINE_STYLES[style["line_style"]])
if "symbol" in style:
origin_plot.symbol_kind = SYMBOLS[style["symbol"]]
if "symbol_size" in style:
origin_plot.symbol_size = float(style["symbol_size"])
if "transparency" in style:
origin_plot.transparency = int(style["transparency"])
def _apply_legend(layer: Any, legend: Any) -> None:
if not isinstance(legend, dict):
return
label = layer.label("Legend")
label.show = legend.get("enabled", True)
if "font_size" in legend:
label.set_int("fsize", round(legend["font_size"]))
if "position" in legend:
left, top = LEGEND_POSITIONS[legend["position"]]
label.set_int("left", left)
label.set_int("top", top)
def _input_file(job_dir: Path, key: str) -> Path: def _input_file(job_dir: Path, key: str) -> Path:
directory = job_dir / "input" / key directory = job_dir / "input" / key
files = [path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")] files = [path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")]
@ -266,11 +352,12 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
) )
graph = op.new_graph(template="heatmap") graph = op.new_graph(template="heatmap")
layer = graph[0] layer = graph[0]
layer.add_mplot(matrix_sheet, 0, type=105) origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)]
else: else:
template, origin_plot_type = PLOT_CONFIG[plot_type] template, origin_plot_type = PLOT_CONFIG[plot_type]
graph = op.new_graph(template=template) graph = op.new_graph(template=template)
layer = graph[0] layer = graph[0]
origin_plots = []
for resolved in resolved_series: for resolved in resolved_series:
arguments = { arguments = {
"coly": resolved["y"], "coly": resolved["y"],
@ -281,21 +368,26 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
arguments["colz"] = resolved["z"] arguments["colz"] = resolved["z"]
if plot_type == "y_error": if plot_type == "y_error":
arguments["colyerr"] = resolved["y_error"] arguments["colyerr"] = resolved["y_error"]
layer.add_plot(worksheets[resolved["input"]], **arguments) origin_plots.append(layer.add_plot(worksheets[resolved["input"]], **arguments))
if plot_type == "grouped_column": if plot_type == "grouped_column":
layer.group() layer.group()
layer.rescale() layer.rescale()
layer.axis("x").title = _axis_title( _apply_canvas(graph, plot_spec.get("canvas"))
plot_spec.get("x_axis"), str(series_specs[0].get("x") or "X") _apply_axis(
layer, "x", plot_spec.get("x_axis"), str(series_specs[0].get("x") or "X")
) )
layer.axis("y").title = _axis_title(plot_spec.get("y_axis"), "Y") _apply_axis(layer, "y", plot_spec.get("y_axis"), "Y")
if plot_type == "surface_3d": if plot_type == "surface_3d":
layer.axis("z").title = _axis_title( _apply_axis(
plot_spec.get("z_axis"), str(series_specs[0].get("z") or "Z") layer, "z", plot_spec.get("z_axis"), str(series_specs[0].get("z") or "Z")
) )
for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
_apply_series_style(origin_plot, series_spec.get("style"))
_apply_legend(layer, plot_spec.get("legend"))
if plot_spec.get("title"): if plot_spec.get("title"):
title = layer.add_label(str(plot_spec["title"])) title = layer.add_label(str(plot_spec["title"]))
title.set_int("fsize", 18) title_font_size = (plot_spec.get("title_style") or {}).get("font_size", 18)
title.set_int("fsize", round(title_font_size))
title.set_int("left", 2200) title.set_int("left", 2200)
title.set_int("top", 120) title.set_int("top", 120)
requested_outputs = request["outputs"] requested_outputs = request["outputs"]
@ -313,7 +405,8 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
dpi = (png_output.get("options") or {}).get("dpi", 300) if png_output else 300 dpi = (png_output.get("options") or {}).get("dpi", 300) if png_output else 300
if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200: if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200:
raise ValueError("OUTPUT_DPI_INVALID") raise ValueError("OUTPUT_DPI_INVALID")
pixel_width = round(dpi * 160 / 25.4) canvas_width_mm = (plot_spec.get("canvas") or {}).get("width_mm", 160)
pixel_width = round(dpi * canvas_width_mm / 25.4)
for extension in ("png", "svg", "pdf"): for extension in ("png", "svg", "pdf"):
if extension in formats: if extension in formats:
target = output / f"figure.{extension}" target = output / f"figure.{extension}"
@ -332,7 +425,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
except PackageNotFoundError: except PackageNotFoundError:
originpro_version = "embedded" originpro_version = "embedded"
provenance = { provenance = {
"adapter_version": "0.4.0", "adapter_version": "0.5.0",
"originpro_version": originpro_version, "originpro_version": originpro_version,
"request_digest": request_record["request_digest"], "request_digest": request_record["request_digest"],
"inputs": [ "inputs": [
@ -346,6 +439,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
"outputs": requested_outputs, "outputs": requested_outputs,
"requested_dpi": dpi if png_output else None, "requested_dpi": dpi if png_output else None,
"png_pixel_width": pixel_width if png_output else None, "png_pixel_width": pixel_width if png_output else None,
"canvas_mm": plot_spec.get("canvas"),
} }
plot_spec_path = output / "plot-spec.json" plot_spec_path = output / "plot-spec.json"
provenance_path = output / "provenance.json" provenance_path = output / "provenance.json"