feat(origin): expand supported plot types

This commit is contained in:
caoqianming 2026-08-14 12:16:37 +08:00
parent ef5428c3b7
commit b77db25440
16 changed files with 290 additions and 49 deletions

View File

@ -15,6 +15,7 @@
- Windows Node 窗口新增本机任务列表可查看任务标题、输入文件、执行阶段、进度、时间、Job ID 和失败详情;任务状态会在本机持久保存,断线或重启后仍可查看。 - Windows Node 窗口新增本机任务列表可查看任务标题、输入文件、执行阶段、进度、时间、Job ID 和失败详情;任务状态会在本机持久保存,断线或重启后仍可查看。
- Windows Node 会自动重试被 Origin 或系统程序短暂占用的输出文件;云端已确认的结果可在重启后直接恢复本机完成状态,并为每个任务保留不含凭据的上传诊断日志。 - Windows Node 会自动重试被 Origin 或系统程序短暂占用的输出文件;云端已确认的结果可在重启后直接恢复本机完成状态,并为每个任务保留不含凭据的上传诊断日志。
- Origin 绘图任务支持同时绑定多个 CSV、XLSX 或 JSON 输入、从不同输入中选择数据系列,并显式选择需要生成的工程文件和多种图形格式。 - Origin 绘图任务支持同时绑定多个 CSV、XLSX 或 JSON 输入、从不同输入中选择数据系列,并显式选择需要生成的工程文件和多种图形格式。
- Origin 绘图新增柱形图、条形图、分组柱形图、Y 误差棒、等高线、三维曲面、三元图和规则网格热图;这些图形继续使用同一个任务入口,可统一生成工程文件和多种图片格式。
## 0.65.2 — 2026-08-13 ## 0.65.2 — 2026-08-13

View File

@ -468,7 +468,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校验产物签名并原子写入终态;当前受控图形仅含 line、scatter、line_scatter 和双栏出版布局。进程内 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 误差棒、等高线、三维曲面、三元图和规则网格热图,旧 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` 保持单一入口,`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 / Windows Node 输出上传收敛与诊断**:恢复中的成功任务优先向云端重放完成确认,已发布结果不再重新打开可能被 Origin 占用的 OPJU文件共享冲突按 0.5/1/2/5 秒有界退避,本地完成标记使用可跨心跳复用的固定 pending 文件,只重试重命名以避开系统程序对每个新文件的重复扫描,并按 Job 写入带阶段、产物、重试次数和 HRESULT 的 1 MiB 轮转诊断日志。同步修正本机更新时间早于接收时间的展示边界;相关 45 项专项 unittest、.NET build 与 diff 检查通过,未写入生产 DB。 - **08-14 / Unreleased / Windows Node 输出上传收敛与诊断**:恢复中的成功任务优先向云端重放完成确认,已发布结果不再重新打开可能被 Origin 占用的 OPJU文件共享冲突按 0.5/1/2/5 秒有界退避,本地完成标记使用可跨心跳复用的固定 pending 文件,只重试重命名以避开系统程序对每个新文件的重复扫描,并按 Job 写入带阶段、产物、重试次数和 HRESULT 的 1 MiB 轮转诊断日志。同步修正本机更新时间早于接收时间的展示边界;相关 45 项专项 unittest、.NET build 与 diff 检查通过,未写入生产 DB。
- **08-14 / Unreleased / Windows Node 本机任务监控 + Origin 多输入多输出 v2**:配置窗口新增只读本机任务列表与详情,每秒从 `%ProgramData%/Zcbot/WindowsNode/jobs` 合并 request/state/terminal/upload-complete展示标题、全部输入、阶段、执行耗时、时间、Job ID 与错误,重启后可恢复且不查询云端未派发 Job。按用户明确授权直接切换到 `origin.plot@v2`:通用 `inputs[] + operation + outputs[]` 支持 116 个输入、跨输入系列和多个显式产物Node 下载到 keyed 目录Origin Worker 建立多工作表后合并绘图;本地阶段统一为 `downloading_inputs/software_running`,不兼容 v1 请求或旧本地任务。相关专项 71 项 unittest、Python/JavaScript 语法、Ruff 致命规则、.NET build 与 diff 检查通过;完整测试 593 项中非数据库测试通过3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。 - **08-14 / Unreleased / Windows Node 本机任务监控 + Origin 多输入多输出 v2**:配置窗口新增只读本机任务列表与详情,每秒从 `%ProgramData%/Zcbot/WindowsNode/jobs` 合并 request/state/terminal/upload-complete展示标题、全部输入、阶段、执行耗时、时间、Job ID 与错误,重启后可恢复且不查询云端未派发 Job。按用户明确授权直接切换到 `origin.plot@v2`:通用 `inputs[] + operation + outputs[]` 支持 116 个输入、跨输入系列和多个显式产物Node 下载到 keyed 目录Origin Worker 建立多工作表后合并绘图;本地阶段统一为 `downloading_inputs/software_running`,不兼容 v1 请求或旧本地任务。相关专项 71 项 unittest、Python/JavaScript 语法、Ruff 致命规则、.NET build 与 diff 检查通过;完整测试 593 项中非数据库测试通过3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。

View File

@ -17,8 +17,12 @@ from core.storage.models import Artifact, SoftwareJob, SoftwareNode, Task
OFFER_SECONDS = 60 OFFER_SECONDS = 60
ALLOWED_PLOT_TYPES = frozenset( ALLOWED_PLOT_TYPES = frozenset(
{"line", "scatter", "line_scatter"} {
"line", "scatter", "line_scatter", "column", "bar", "grouped_column",
"y_error", "contour", "surface_3d", "ternary", "heatmap",
}
) )
XYZ_PLOT_TYPES = frozenset({"contour", "surface_3d", "ternary", "heatmap"})
ALLOWED_INPUT_SUFFIXES = frozenset({".csv", ".xlsx", ".json"}) ALLOWED_INPUT_SUFFIXES = frozenset({".csv", ".xlsx", ".json"})
MAX_INPUT_BYTES = 100 * 1024 * 1024 MAX_INPUT_BYTES = 100 * 1024 * 1024
MAX_INPUTS = 16 MAX_INPUTS = 16
@ -102,7 +106,10 @@ def _canonical_origin_plot_request(request: dict) -> tuple[dict, str]:
raise SoftwareJobError("input keys must be unique") raise SoftwareJobError("input keys must be unique")
if not _has_only( if not _has_only(
plot, plot,
{"type", "series", "template", "title", "x_axis", "y_axis", "legend", "error_bars"}, {
"type", "series", "template", "title", "x_axis", "y_axis", "z_axis",
"legend", "error_bars",
},
): ):
raise SoftwareJobError("unsupported origin plot fields") raise SoftwareJobError("unsupported origin plot fields")
if plot.get("type") not in ALLOWED_PLOT_TYPES: if plot.get("type") not in ALLOWED_PLOT_TYPES:
@ -116,40 +123,54 @@ def _canonical_origin_plot_request(request: dict) -> tuple[dict, str]:
series = plot.get("series") series = plot.get("series")
if not isinstance(series, list) or not 1 <= len(series) <= 16: if not isinstance(series, list) or not 1 <= len(series) <= 16:
raise SoftwareJobError("plot.series must contain 1 to 16 series") raise SoftwareJobError("plot.series must contain 1 to 16 series")
identities: list[tuple[str, str, str]] = [] plot_type = plot["type"]
if plot_type == "grouped_column" and len(series) < 2:
raise SoftwareJobError("grouped_column requires at least two series")
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
raise SoftwareJobError(f"{plot_type} requires exactly one XYZ series")
required_roles = (
{"x", "y", "z"} if plot_type in XYZ_PLOT_TYPES
else {"x", "y", "y_error"} if plot_type == "y_error"
else {"x", "y"}
)
identities: list[tuple[str, ...]] = []
used_input_keys: set[str] = set() used_input_keys: set[str] = set()
series_labels: dict[tuple[str, str], str] = {} series_labels: dict[tuple[str, str], str] = {}
for item in series: for item in series:
if not isinstance(item, dict) or not _has_only(item, {"input", "x", "y", "label"}): if not isinstance(item, dict) or not _has_only(
item, {"input", "x", "y", "z", "y_error", "label"}
):
raise SoftwareJobError("invalid plot series fields") raise SoftwareJobError("invalid plot series fields")
if not {"input", "x", "y"}.issubset(item): if not {"input", *required_roles}.issubset(item):
raise SoftwareJobError("plot series requires input, x, and y") roles = ", ".join(sorted(required_roles))
raise SoftwareJobError(f"{plot_type} series requires input, {roles}")
if {"x", "y", "z", "y_error"}.intersection(item) - required_roles:
raise SoftwareJobError(f"{plot_type} series contains unsupported data roles")
input_key = item.get("input") input_key = item.get("input")
x_column = item.get("x")
y_column = item.get("y")
if input_key not in input_keys: if input_key not in input_keys:
raise SoftwareJobError("plot series references an unknown input") raise SoftwareJobError("plot series references an unknown input")
if any( if any(
not isinstance(value, str) or not 1 <= len(value) <= 128 not isinstance(value, str) or not 1 <= len(value) <= 128
for value in (x_column, y_column) for value in (item.get(role) for role in required_roles)
): ):
raise SoftwareJobError("plot series x and y must be column names") raise SoftwareJobError("plot series data roles must be column names")
if "label" in item and ( if "label" in item and (
not isinstance(item["label"], str) or not 1 <= len(item["label"]) <= 200 not isinstance(item["label"], str) or not 1 <= len(item["label"]) <= 200
): ):
raise SoftwareJobError("plot series label must be a string") raise SoftwareJobError("plot series label must be a string")
y_column = item["y"]
label_key = (input_key, y_column) label_key = (input_key, y_column)
effective_label = item.get("label", y_column) effective_label = item.get("label", y_column)
if label_key in series_labels and series_labels[label_key] != effective_label: if label_key in series_labels and series_labels[label_key] != effective_label:
raise SoftwareJobError("series sharing an input Y column must use one label") raise SoftwareJobError("series sharing an input Y column must use one label")
series_labels[label_key] = effective_label series_labels[label_key] = effective_label
used_input_keys.add(input_key) used_input_keys.add(input_key)
identities.append((input_key, x_column, y_column)) identities.append((input_key, *(item[role] for role in sorted(required_roles))))
if len(identities) != len(set(identities)): if len(identities) != len(set(identities)):
raise SoftwareJobError("plot series must be unique") raise SoftwareJobError("plot series must be unique")
if used_input_keys != set(input_keys): if used_input_keys != set(input_keys):
raise SoftwareJobError("every input must be referenced by a plot series") raise SoftwareJobError("every input must be referenced by a plot series")
for axis_name in ("x_axis", "y_axis"): for axis_name in ("x_axis", "y_axis", "z_axis"):
axis = plot.get(axis_name) axis = plot.get(axis_name)
if axis is not None and ( if axis is not None and (
not isinstance(axis, dict) not isinstance(axis, dict)

View File

@ -468,7 +468,7 @@ C# COM 只作未覆盖能力或旧版本备用,并与实际 Origin 版本匹
} }
``` ```
当前图形类型line、scatter、line_scatter。后续增加 grouped_bar、box、histogram、heatmap、误差棒组合、三元图、等高线、三维曲面、XRD 堆叠图、热分析联图和多面板布局。 当前仍只开放一个 `origin.plot@v2` 契约。`series[]` 统一使用数据角色字段:常规二维图为 `x/y`,误差棒为 `x/y/y_error`XYZ 图为 `x/y/z`;服务端和 Node 均按 `type` 做判别校验。图形类型包括 line、scatter、line_scatter、column、bar、grouped_column、y_error、contour、surface_3d、ternary 和 heatmap其中 heatmap 要求完整、等间距且无重复坐标的规则 XYZ 网格。后续可在同一角色模型内增加 box、histogram、XRD 堆叠图、热分析联图和多面板布局,不拆 capability只有新增不同执行语义如拟合、统计分析时才增加 operation
上述 JSON 同时是提交工具与云端账本、Windows Node 之间的 artifact 输入契约。 上述 JSON 同时是提交工具与云端账本、Windows Node 之间的 artifact 输入契约。
Agent 优先复用已有 artifact UUID普通 task 文件先逐个调用 Agent 优先复用已有 artifact UUID普通 task 文件先逐个调用
@ -589,7 +589,7 @@ Windows 基线:固定版本并受控更新;禁止休眠和自动锁屏;固
- enrollment、节点表、WSS Connection Manager - enrollment、节点表、WSS Connection Manager
- Node 注册、证书、心跳、能力上报; - Node 注册、证书、心跳、能力上报;
- job 表、幂等提交、offer/accept 和基本租约; - job 表、幂等提交、offer/accept 和基本租约;
- Origin line/scatter/line_scatter - Origin 二维 XY、柱/条形、Y 误差棒、XYZ 等高线/三维曲面/三元图和规则网格热图
- 输入下载、分块上传、SHA-256 - 输入下载、分块上传、SHA-256
- zcbot 查询、取消、导入和发布; - zcbot 查询、取消、导入和发布;
- 最终截图与稳定错误码。 - 最终截图与稳定错误码。

View File

@ -200,7 +200,7 @@ Node 断线且本地任务可能仍在执行时标记 `disconnected`,不得自
## 7. Origin 任务闭环 ## 7. Origin 任务闭环
当前实现进度:云端任务账本、幂等提交、短期 offer、Node 本地原子保存与 accept/reject 已落地。请求以 `inputs[]` 绑定多个 artifact、以 `outputs[]` 声明多个交付物Node 逐项流式校验大小和 SHA-256 后将输入保存到 `input/<key>/<filename>`。固定 Worker 使用管理员安装的隔离 Python 运行时与随程序发布的 `worker.py` 驱动 Origin,按 `operation.plot.series[]` 从不同输入选择系列,并按输出声明生成 OPJU、PNG、SVG、PDFplot spec、provenance 和原子 `terminal.json` 是系统强制元数据。运行不绑定单次 WebSocket断线后继续执行。同一进程按 job 去重Node 重启后不重复启动已留启动标记但无可信终态的任务。成功产物逐项流式上传到云端隐藏暂存区,云端复核任务身份、固定文件名、大小和 SHA-256 后,一次性发布到 `<working_dir>/origin/<job_id>/` 并登记平台 artifact UUIDNode 以 `upload-complete.json` 恢复中断上传。 当前实现进度:云端任务账本、幂等提交、短期 offer、Node 本地原子保存与 accept/reject 已落地。请求以 `inputs[]` 绑定多个 artifact、以 `outputs[]` 声明多个交付物Node 逐项流式校验大小和 SHA-256 后将输入保存到 `input/<key>/<filename>`。固定 Worker 使用管理员安装的隔离 Python 运行时与随程序发布的 `worker.py` 驱动 Origin`operation.plot.series[]` 以 `x/y/z/y_error` 统一描述二维、误差棒和 XYZ 数据角色,按 `plot.type` 判别校验并生成 OPJU、PNG、SVG、PDF。当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图plot spec、provenance 和原子 `terminal.json` 是系统强制元数据。运行不绑定单次 WebSocket断线后继续执行。同一进程按 job 去重Node 重启后不重复启动已留启动标记但无可信终态的任务。成功产物逐项流式上传到云端隐藏暂存区,云端复核任务身份、固定文件名、大小和 SHA-256 后,一次性发布到 `<working_dir>/origin/<job_id>/` 并登记平台 artifact UUIDNode 以 `upload-complete.json` 恢复中断上传。
```text ```text
用户上传一个或多个 CSV/XLSX/JSON 用户上传一个或多个 CSV/XLSX/JSON

View File

@ -62,7 +62,10 @@ class OriginWorkerUnitTests(unittest.TestCase):
{"input": "second", "x": "temperature", "y": "value", "label": "28 d"}, {"input": "second", "x": "temperature", "y": "value", "label": "28 d"},
] ]
resolved, labels = worker._resolve_series(input_data, series) resolved, labels = worker._resolve_series(input_data, series)
self.assertEqual(resolved, [("first", 0, 1, "7 d"), ("second", 0, 1, "28 d")]) self.assertEqual(resolved, [
{"input": "first", "label": "7 d", "x": 0, "y": 1},
{"input": "second", "label": "28 d", "x": 0, "y": 1},
])
self.assertEqual(labels, {("first", 1): "7 d", ("second", 1): "28 d"}) self.assertEqual(labels, {("first", 1): "7 d", ("second", 1): "28 d"})
def test_shared_y_column_rejects_conflicting_labels(self) -> None: def test_shared_y_column_rejects_conflicting_labels(self) -> None:
@ -73,6 +76,47 @@ class OriginWorkerUnitTests(unittest.TestCase):
{"input": "sample", "x": "x2", "y": "y", "label": "Second"}, {"input": "sample", "x": "x2", "y": "y", "label": "Second"},
]) ])
def test_series_support_xyz_and_y_error_roles(self) -> None:
resolved, labels = worker._resolve_series(
{"sample": (["x", "y", "z", "sd"], [[0, 1, 2, 0.1]])},
[{
"input": "sample", "x": "x", "y": "y", "z": "z",
"y_error": "sd", "label": "测量值",
}],
)
self.assertEqual(resolved, [{
"input": "sample", "label": "测量值", "x": 0, "y": 1,
"z": 2, "y_error": 3,
}])
self.assertEqual(labels, {("sample", 1): "测量值"})
def test_heatmap_matrix_accepts_complete_unordered_grid(self) -> None:
matrix, xy_map = worker._heatmap_matrix(
[[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]],
{"x": 0, "y": 1, "z": 2},
)
self.assertEqual(matrix, [[1.0, 2.0], [3.0, 4.0]])
self.assertEqual(xy_map, (0.0, 1.0, 10.0, 20.0))
def test_heatmap_matrix_rejects_invalid_grid(self) -> None:
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_INCOMPLETE"):
worker._heatmap_matrix(
[[0, 10, 1], [1, 10, 2], [0, 20, 3]],
{"x": 0, "y": 1, "z": 2},
)
with self.assertRaisesRegex(ValueError, "HEATMAP_COORDINATES_DUPLICATED"):
worker._heatmap_matrix(
[[0, 10, 1], [0, 10, 2]], {"x": 0, "y": 1, "z": 2}
)
with self.assertRaisesRegex(ValueError, "HEATMAP_GRID_NOT_REGULAR"):
worker._heatmap_matrix(
[
[0, 10, 1], [1, 10, 2], [3, 10, 3],
[0, 20, 4], [1, 20, 5], [3, 20, 6],
],
{"x": 0, "y": 1, "z": 2},
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@ -12,6 +12,7 @@ from sqlalchemy import create_mock_engine
from sqlalchemy.dialects import postgresql from sqlalchemy.dialects import postgresql
from core.software_jobs import ( from core.software_jobs import (
SoftwareJobError,
_canonical_request, _canonical_request,
_published_output_is_valid, _published_output_is_valid,
abandon_offer, abandon_offer,
@ -339,6 +340,50 @@ class SoftwareJobProtocolTests(unittest.TestCase):
{**request, "outputs": [{"key": "project", "type": "figure", "format": "png"}]} {**request, "outputs": [{"key": "project", "type": "figure", "format": "png"}]}
) )
def test_origin_request_supports_unified_series_roles(self) -> None:
artifact_id = str(uuid4())
outputs = [{"key": "figure_png", "type": "figure", "format": "png"}]
cases = {
"column": [{"input": "sample", "x": "age", "y": "strength"}],
"bar": [{"input": "sample", "x": "age", "y": "strength"}],
"grouped_column": [
{"input": "sample", "x": "age", "y": "strength"},
{"input": "sample", "x": "age", "y": "modulus"},
],
"y_error": [{"input": "sample", "x": "age", "y": "strength", "y_error": "sd"}],
"contour": [{"input": "sample", "x": "x", "y": "y", "z": "value"}],
"surface_3d": [{"input": "sample", "x": "x", "y": "y", "z": "value"}],
"ternary": [{"input": "sample", "x": "a", "y": "b", "z": "c"}],
"heatmap": [{"input": "sample", "x": "x", "y": "y", "z": "value"}],
}
for plot_type, series in cases.items():
request = {
"schema_version": 2,
"inputs": [{"key": "sample", "artifact_id": artifact_id}],
"operation": {"plot": {"type": plot_type, "series": series}},
"outputs": outputs,
}
with self.subTest(plot_type=plot_type):
normalized, digest = _canonical_request("origin.plot@v2", request)
self.assertEqual(normalized, request)
self.assertEqual(len(digest), 64)
def test_origin_request_rejects_roles_that_do_not_match_plot_type(self) -> None:
base = {
"schema_version": 2,
"inputs": [{"key": "sample", "artifact_id": str(uuid4())}],
"outputs": [{"key": "figure_png", "type": "figure", "format": "png"}],
}
invalid_plots = (
{"type": "line", "series": [{"input": "sample", "x": "x", "y": "y", "z": "z"}]},
{"type": "y_error", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "contour", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "grouped_column", "series": [{"input": "sample", "x": "x", "y": "y"}]},
)
for plot in invalid_plots:
with self.subTest(plot_type=plot["type"]), self.assertRaises(SoftwareJobError):
_canonical_request("origin.plot@v2", {**base, "operation": {"plot": plot}})
def test_output_manifest_matches_exact_requested_outputs(self) -> None: def test_output_manifest_matches_exact_requested_outputs(self) -> None:
request = {"outputs": [ request = {"outputs": [
{"key": "project", "type": "project", "format": "opju"}, {"key": "project", "type": "project", "format": "opju"},

View File

@ -167,7 +167,7 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('AutomationProgId = @"Origin.ApplicationSI\\CLSID"', probe) self.assertIn('AutomationProgId = @"Origin.ApplicationSI\\CLSID"', probe)
self.assertIn("RegistryHive.LocalMachine", probe) self.assertIn("RegistryHive.LocalMachine", probe)
self.assertIn("RegistryHive.CurrentUser", probe) self.assertIn("RegistryHive.CurrentUser", probe)
self.assertIn('new("OriginPro", version, "0.3.0", health, detail)', probe) self.assertIn('new("OriginPro", version, "0.4.0", health, detail)', probe)
self.assertIn( self.assertIn(
"&& !jobInbox.HasPendingOriginJobs", "&& !jobInbox.HasPendingOriginJobs",
connection, connection,

View File

@ -47,7 +47,8 @@ class SoftwareCapabilityListTool(_SoftwareJobTool):
class SoftwareJobSubmitTool(_SoftwareJobTool): class SoftwareJobSubmitTool(_SoftwareJobTool):
name = "software_job_submit" name = "software_job_submit"
description = ( description = (
"Submit an Origin plot job using one or more registered CSV, XLSX, or JSON artifacts. " "Submit an Origin 2D, error-bar, contour, 3D, ternary, or heatmap plot job using "
"one or more registered CSV, XLSX, or JSON artifacts. "
"Call register_artifact first for each workspace file. " "Call register_artifact first for each workspace file. "
"Return immediately with job_id; do not poll continuously or wait for completion." "Return immediately with job_id; do not poll continuously or wait for completion."
) )
@ -63,7 +64,10 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
_plot_schema = { _plot_schema = {
"type": "object", "type": "object",
"properties": { "properties": {
"type": {"type": "string", "enum": ["line", "scatter", "line_scatter"]}, "type": {"type": "string", "enum": [
"line", "scatter", "line_scatter", "column", "bar", "grouped_column",
"y_error", "contour", "surface_3d", "ternary", "heatmap",
]},
"series": { "series": {
"type": "array", "type": "array",
"minItems": 1, "minItems": 1,
@ -74,17 +78,23 @@ class SoftwareJobSubmitTool(_SoftwareJobTool):
"input": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"}, "input": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"},
"x": {"type": "string", "minLength": 1, "maxLength": 128}, "x": {"type": "string", "minLength": 1, "maxLength": 128},
"y": {"type": "string", "minLength": 1, "maxLength": 128}, "y": {"type": "string", "minLength": 1, "maxLength": 128},
"z": {"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},
}, },
"required": ["input", "x", "y"], "required": ["input"],
"additionalProperties": False, "additionalProperties": False,
}, },
"description": "Series with explicit input key and X/Y columns.", "description": (
"Typed data roles. XY plots require x/y; y_error requires x/y/y_error; "
"contour, surface_3d, ternary, and heatmap require x/y/z."
),
}, },
"template": {"type": "string", "enum": ["publication_double_column"]}, "template": {"type": "string", "enum": ["publication_double_column"]},
"title": {"type": "string", "maxLength": 500}, "title": {"type": "string", "maxLength": 500},
"x_axis": _axis_schema, "x_axis": _axis_schema,
"y_axis": _axis_schema, "y_axis": _axis_schema,
"z_axis": _axis_schema,
"legend": { "legend": {
"type": "object", "type": "object",
"properties": { "properties": {

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 已支持 `line`、`scatter` 和 `line_scatter`,生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。运行时独立于 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

@ -7,7 +7,12 @@ internal sealed class JobInboxStore(string jobsDirectory)
{ {
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static readonly HashSet<string> PlotTypes = private static readonly HashSet<string> PlotTypes =
["line", "scatter", "line_scatter"]; [
"line", "scatter", "line_scatter", "column", "bar", "grouped_column",
"y_error", "contour", "surface_3d", "ternary", "heatmap",
];
private static readonly HashSet<string> XyzPlotTypes =
["contour", "surface_3d", "ternary", "heatmap"];
private static readonly HashSet<string> JobStages = private static readonly HashSet<string> JobStages =
[ [
"accepted", "accepted",
@ -502,9 +507,11 @@ 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", "legend", "error_bars") plot, "type", "series", "template", "title", "x_axis", "y_axis", "z_axis",
"legend", "error_bars")
|| !plot.TryGetProperty("type", out var plotType) || !plot.TryGetProperty("type", out var plotType)
|| !PlotTypes.Contains(plotType.GetString() ?? "") || plotType.GetString() is not { } plotTypeName
|| !PlotTypes.Contains(plotTypeName)
|| plot.TryGetProperty("title", out var title) || plot.TryGetProperty("title", out var title)
&& (title.ValueKind != JsonValueKind.String || title.GetString()!.Length > 500) && (title.ValueKind != JsonValueKind.String || title.GetString()!.Length > 500)
|| !plot.TryGetProperty("series", out var series) || !plot.TryGetProperty("series", out var series)
@ -514,11 +521,23 @@ internal sealed class JobInboxStore(string jobsDirectory)
&& template.GetString() != "publication_double_column" && template.GetString() != "publication_double_column"
|| !IsValidAxis(plot, "x_axis") || !IsValidAxis(plot, "x_axis")
|| !IsValidAxis(plot, "y_axis") || !IsValidAxis(plot, "y_axis")
|| !IsValidAxis(plot, "z_axis")
|| !IsValidLegend(plot) || !IsValidLegend(plot)
|| plot.TryGetProperty("error_bars", out _)) || plot.TryGetProperty("error_bars", out _))
{ {
return false; return false;
} }
if ((plotTypeName == "grouped_column" && series.GetArrayLength() < 2)
|| (XyzPlotTypes.Contains(plotTypeName) && series.GetArrayLength() != 1))
{
return false;
}
var requiredRoles = XyzPlotTypes.Contains(plotTypeName)
? new[] { "x", "y", "z" }
: plotTypeName == "y_error"
? new[] { "x", "y", "y_error" }
: new[] { "x", "y" };
var allowedRoles = requiredRoles.ToHashSet(StringComparer.Ordinal);
var inputKeys = inputs.EnumerateArray() var inputKeys = inputs.EnumerateArray()
.Select(item => item.GetProperty("key").GetString()!) .Select(item => item.GetProperty("key").GetString()!)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
@ -528,26 +547,29 @@ 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", "label") || !HasOnlyProperties(item, "input", "x", "y", "z", "y_error", "label")
|| !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)
|| !item.TryGetProperty("x", out var x) || requiredRoles.Any(role =>
|| !IsColumnName(x) !item.TryGetProperty(role, out var column) || !IsColumnName(column))
|| !item.TryGetProperty("y", out var y) || new[] { "x", "y", "z", "y_error" }.Any(role =>
|| !IsColumnName(y) !allowedRoles.Contains(role) && item.TryGetProperty(role, out _))
|| 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)
|| !identities.Add($"{inputKey}\0{x.GetString()}\0{y.GetString()}")) || !identities.Add(string.Join(
"\0", new[] { inputKey }.Concat(requiredRoles.Select(
role => item.GetProperty(role).GetString()!)))))
{ {
return false; return false;
} }
usedInputs.Add(inputKey); usedInputs.Add(inputKey);
var labelKey = $"{inputKey}\0{y.GetString()}"; var yValue = item.GetProperty("y").GetString()!;
var labelKey = $"{inputKey}\0{yValue}";
var effectiveLabel = item.TryGetProperty("label", out var seriesLabel) var effectiveLabel = item.TryGetProperty("label", out var seriesLabel)
? seriesLabel.GetString()! ? seriesLabel.GetString()!
: y.GetString()!; : yValue;
if (labels.TryGetValue(labelKey, out var existingLabel) if (labels.TryGetValue(labelKey, out var existingLabel)
&& existingLabel != effectiveLabel) && existingLabel != effectiveLabel)
{ {

View File

@ -54,7 +54,7 @@ internal static class OriginRuntimeProbe
} }
private static OriginRuntimeStatus Status(string? version, string health, string detail) => private static OriginRuntimeStatus Status(string? version, string health, string detail) =>
new("OriginPro", version, "0.3.0", health, detail); new("OriginPro", version, "0.4.0", health, detail);
private static string? FindInstalledVersion() private static string? FindInstalledVersion()
{ {

View File

@ -56,7 +56,7 @@ if errorlevel 1 (
echo [ERR] Failed to install the Origin worker dependencies. echo [ERR] Failed to install the Origin worker dependencies.
goto :failed goto :failed
) )
"%RUNTIME_PYTHON%" -c "import originpro, openpyxl; print('[OK] Origin worker Python packages are available.')" "%RUNTIME_PYTHON%" -c "import originpro, openpyxl, numpy; print('[OK] Origin worker Python packages are available.')"
if errorlevel 1 ( if errorlevel 1 (
echo [ERR] Origin runtime import verification failed. echo [ERR] Origin runtime import verification failed.
goto :failed goto :failed

View File

@ -1,2 +1,3 @@
originpro==1.1.15 originpro==1.1.15
openpyxl==3.1.5 openpyxl==3.1.5
numpy==2.2.6

View File

@ -9,6 +9,7 @@ from __future__ import annotations
import csv import csv
import hashlib import hashlib
import json import json
import math
import os import os
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
@ -16,7 +17,19 @@ from importlib.metadata import PackageNotFoundError, version
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
PLOT_TYPES = {"line": "l", "scatter": "s", "line_scatter": "y"} PLOT_CONFIG = {
"line": ("line", "l"),
"scatter": ("scatter", "s"),
"line_scatter": ("linesymb", "y"),
"column": ("column", "c"),
"bar": ("bar", 215),
"grouped_column": ("column", "c"),
"y_error": ("ERRBAR", "y"),
"contour": ("TriContour", 243),
"surface_3d": ("glCMAP", 103),
"ternary": ("ternary", 245),
}
XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"}
FORMATS = {"opju", "png", "svg", "pdf"} FORMATS = {"opju", "png", "svg", "pdf"}
@ -115,24 +128,76 @@ def _input_file(job_dir: Path, key: str) -> Path:
def _resolve_series( def _resolve_series(
input_data: dict[str, tuple[list[str], list[list[Any]]]], input_data: dict[str, tuple[list[str], list[list[Any]]]],
series_specs: list[dict[str, Any]], series_specs: list[dict[str, Any]],
) -> tuple[list[tuple[str, int, int, str | None]], dict[tuple[str, int], str]]: ) -> tuple[list[dict[str, Any]], dict[tuple[str, int], str]]:
resolved: list[tuple[str, int, int, str | None]] = [] resolved: list[dict[str, Any]] = []
labels: dict[tuple[str, int], str] = {} labels: dict[tuple[str, int], str] = {}
for series in series_specs: for series in series_specs:
input_key = series["input"] input_key = series["input"]
headers, _ = input_data[input_key] headers, _ = input_data[input_key]
x_index = _column_index(headers, series["x"], "x") role_indexes = {
y_index = _column_index(headers, series["y"], "y") role: _column_index(headers, series[role], role)
for role in ("x", "y", "z", "y_error") if role in series
}
y_index = role_indexes["y"]
label = series.get("label") label = series.get("label")
label_key = (input_key, y_index) label_key = (input_key, y_index)
effective_label = label or series["y"] effective_label = label or series["y"]
if label_key in labels and labels[label_key] != effective_label: if label_key in labels and labels[label_key] != effective_label:
raise ValueError("SERIES_LABEL_CONFLICT") raise ValueError("SERIES_LABEL_CONFLICT")
labels[label_key] = effective_label labels[label_key] = effective_label
resolved.append((input_key, x_index, y_index, label)) resolved.append({"input": input_key, "label": label, **role_indexes})
return resolved, labels return resolved, labels
def _number(value: Any, role: str) -> float:
if isinstance(value, bool):
raise ValueError(f"{role.upper()}_VALUE_NOT_NUMERIC") # noqa: TRY004
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{role.upper()}_VALUE_NOT_NUMERIC") from exc
if not math.isfinite(number):
raise ValueError(f"{role.upper()}_VALUE_NOT_FINITE")
return number
def _is_evenly_spaced(values: list[float]) -> bool:
if len(values) <= 2:
return True
step = values[1] - values[0]
tolerance = max(abs(step) * 1e-9, 1e-12)
return all(
math.isclose(current - previous, step, rel_tol=1e-9, abs_tol=tolerance)
for previous, current in zip(values[1:-1], values[2:], strict=True)
)
def _heatmap_matrix(
rows: list[list[Any]], resolved: dict[str, Any]
) -> tuple[list[list[float]], tuple[float, float, float, float]]:
points: dict[tuple[float, float], float] = {}
for row in rows:
try:
x = _number(row[resolved["x"]], "x")
y = _number(row[resolved["y"]], "y")
z = _number(row[resolved["z"]], "z")
except IndexError as exc:
raise ValueError("HEATMAP_ROW_INCOMPLETE") from exc
if (x, y) in points:
raise ValueError("HEATMAP_COORDINATES_DUPLICATED")
points[(x, y)] = z
x_values = sorted({item[0] for item in points})
y_values = sorted({item[1] for item in points})
if len(x_values) < 2 or len(y_values) < 2:
raise ValueError("HEATMAP_GRID_TOO_SMALL")
if len(points) != len(x_values) * len(y_values):
raise ValueError("HEATMAP_GRID_INCOMPLETE")
if not _is_evenly_spaced(x_values) or not _is_evenly_spaced(y_values):
raise ValueError("HEATMAP_GRID_NOT_REGULAR")
matrix = [[points[(x, y)] for x in x_values] for y in y_values]
return matrix, (x_values[0], x_values[-1], y_values[0], y_values[-1])
def _validate_artifact(path: Path, extension: str) -> None: def _validate_artifact(path: Path, extension: str) -> None:
if not path.is_file() or path.stat().st_size == 0: if not path.is_file() or path.stat().st_size == 0:
raise RuntimeError(f"{extension.upper()}_EXPORT_EMPTY") raise RuntimeError(f"{extension.upper()}_EXPORT_EMPTY")
@ -162,7 +227,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
} }
plot_spec = request["operation"]["plot"] plot_spec = request["operation"]["plot"]
plot_type = plot_spec["type"] plot_type = plot_spec["type"]
if plot_type not in PLOT_TYPES: if plot_type not in PLOT_CONFIG and plot_type != "heatmap":
raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED") raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
series_specs = plot_spec["series"] series_specs = plot_spec["series"]
resolved_series, labels = _resolve_series(input_data, series_specs) resolved_series, labels = _resolve_series(input_data, series_specs)
@ -187,17 +252,47 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
[row[index] if index < len(row) else None for row in rows], [row[index] if index < len(row) else None for row in rows],
lname=column_label, lname=column_label,
) )
graph = op.new_graph(template={"line": "line", "scatter": "scatter", "line_scatter": "linesymb"}[plot_type]) if plot_type == "heatmap":
layer = graph[0] import numpy as np
for input_key, x_index, y_index, _ in resolved_series:
layer.add_plot( resolved = resolved_series[0]
worksheets[input_key], coly=y_index, colx=x_index, type=PLOT_TYPES[plot_type] _, rows = input_data[resolved["input"]]
matrix, xy_map = _heatmap_matrix(rows, resolved)
matrix_sheet = op.new_sheet("m")
matrix_sheet.from_np(np.array(matrix, dtype=float))
matrix_sheet.xymap = xy_map
matrix_sheet.set_label(
0, _axis_title(plot_spec.get("z_axis"), str(series_specs[0]["z"]))
) )
graph = op.new_graph(template="heatmap")
layer = graph[0]
layer.add_mplot(matrix_sheet, 0, type=105)
else:
template, origin_plot_type = PLOT_CONFIG[plot_type]
graph = op.new_graph(template=template)
layer = graph[0]
for resolved in resolved_series:
arguments = {
"coly": resolved["y"],
"colx": resolved["x"],
"type": origin_plot_type,
}
if plot_type in XYZ_PLOT_TYPES:
arguments["colz"] = resolved["z"]
if plot_type == "y_error":
arguments["colyerr"] = resolved["y_error"]
layer.add_plot(worksheets[resolved["input"]], **arguments)
if plot_type == "grouped_column":
layer.group()
layer.rescale() layer.rescale()
layer.axis("x").title = _axis_title( layer.axis("x").title = _axis_title(
plot_spec.get("x_axis"), str(series_specs[0].get("x") or "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") layer.axis("y").title = _axis_title(plot_spec.get("y_axis"), "Y")
if plot_type == "surface_3d":
layer.axis("z").title = _axis_title(
plot_spec.get("z_axis"), str(series_specs[0].get("z") or "Z")
)
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.set_int("fsize", 18)
@ -237,7 +332,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.3.0", "adapter_version": "0.4.0",
"originpro_version": originpro_version, "originpro_version": originpro_version,
"request_digest": request_record["request_digest"], "request_digest": request_record["request_digest"],
"inputs": [ "inputs": [