Compare commits

...

3 Commits

19 changed files with 1386 additions and 69 deletions

View File

@ -8,6 +8,14 @@
## Unreleased
- Origin 绘图新增面积图、堆叠面积图、极坐标图、饼图和堆叠条形图;堆叠类图型改用 Origin 原生多系列分组与累计机制,跨输入数据也会先校验并对齐横坐标。
- Windows Node 不再错误显示 Origin 运行状态中的中文本机窗口、zcbot 管理页和用户专业软件任务列表会显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。
- Origin 绘图新增箱线图、直方图、堆叠柱形图、数据驱动尺寸的气泡图和带中心线的置信带图,覆盖重复试验分布、相组成占比、多变量关系及拟合不确定性展示。
- Origin 绘图支持 14 个二维面板组成复合图,可在同一面板混合折线、散点、线点、柱形和条形系列,分别使用左右 Y 轴,并为任意系列添加对称 X/Y 误差棒;原有单图请求继续兼容。
- Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。
- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。

View File

@ -472,7 +472,9 @@ 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 合并成可恢复视图。
第四阶段落地固定 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避免无法证明的双执行。
第四阶段落地固定 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 统一表达出版级尺寸与样式。新增复合图不继续枚举 `dual_axis_line` 等组合类型,而使用 `multi_panel` feature`panels[]` 显式描述 14 个二维 panel每条系列用 `kind` 选择基础二维图型、用 `y_axis` 绑定左右轴,并可提供对称 `x_error/y_error``layout` 只允许 1×1、1×2、2×1、2×2 四种受控布局。Worker 使用 Origin 内置 panel 模板及 right-Y 图层不接受模板名Origin 不支持同一 3D 图窗多图层,因此 contour、surface、ternary 和 heatmap 不混入本阶段 panel。旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin避免无法证明的双执行。
科研统计图继续以独立 feature 增量扩展同一契约:`box/histogram` 的系列只绑定原始 Y 列,统计规则由 Origin 固定模板决定;`bubble` 增加正数 `size` 数据角色,由受控 modifier column 驱动符号尺寸;`band` 要求同一输入的 `x/y/lower/upper`先画上下界并填充到下一曲线再叠加中心线Worker 在打开 Origin 前拒绝非有限尺寸、非正尺寸和倒置边界。`stacked_column/stacked_area/stacked_bar` 统一把两条以上 XY 系列复制进内部连续 XYY 工作表,严格校验横坐标相同后建立 plot group并只执行 Worker 内置的固定累计图层命令;请求不能提供命令、模板或工作表范围。`area/polar/pie` 继续使用固定 Origin 类型 ID。未经过目标 Origin 版本真机验证的统计属性不进入公共 schema避免暴露看似可配但不能稳定复现的参数。
第五阶段完成输出上传与发布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,12 @@
### 2026-08-14
- **08-14 / Unreleased / Origin 常用二维图第三批**adapter 0.8.0 新增 area、stacked_area、polar、pie、stacked_bar所有堆叠图将跨输入系列复制到受控连续 XYY 工作表,先校验横坐标完全一致,再建立 plot group、激活目标图层并应用固定累计命令。已安装项目锁定的 Python 3.12 受管 runtimeOrigin 2024 / originpro 1.1.15 probe 为 ready堆叠柱、堆叠面积、极坐标、饼图和堆叠条形图的 OPJU/PNG 真机冒烟及视觉核对通过,其中堆叠柱总高为 15/18/17/22。相关 80 项 unittest 通过,未连接或写入数据库。
- **08-14 / Unreleased / Origin 科研统计图第二批**adapter 0.7.0 新增 box、histogram、stacked_column、bubble、band 五个 feature箱线/直方图直接消费原始 Y 列,气泡以正数 size 列驱动符号尺寸,置信带以显式 center/lower/upper 绘制填充边界并拒绝倒置区间。堆叠柱经 Origin 2024 真机检查发现单独设置属性会退化为并排/覆盖,已在 0.8.0 改为原生 XYY 分组累计链路。高级箱线百分位和直方图分箱参数暂沿用 Origin 默认值,待真机验证稳定属性后再开放。
- **08-14 / Unreleased / Origin 二维复合图第一批**`origin.plot@v2` 新增 adapter 0.6.0 的 `multi_panel` feature以 14 个显式 panel 表达横排、竖排或 2×2 复合图panel 内每条系列可独立选择 line/scatter/line_scatter/column/bar、左右 Y 轴和对称 X/Y 误差列,并保留标题、图例、轴与系列样式。旧 11 类请求继续走原分支;多面板仅覆盖 Origin 原生支持的二维图层,不伪装支持 3D 多图层。专项 52 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 win-x64 .NET build 通过,未启动 Origin、连接或写入数据库。
- **08-14 / Unreleased / Adapter 目录化更新与契约热加载**Windows Node 改为从 EXE 同级 `adapters/*/adapter.json` 发现能力实际版本、Python/EXE 运行类型、入口和契约路径均由 manifest 声明Host 使用通用 JSON Schema 校验并删除 Origin 专用 C# 校验,软件探测与轴范围、系列角色等语义规则下沉 Worker。Core 契约注册表按文件变化原子热加载,非法中间版本保留上一有效快照,工具 schema、注册默认值和调度查询均动态读取。新增无需编译 Node 的 Origin adapter 独立打包入口;专项 40 项 unittest、Python 编译、Ruff 致命规则、diff 检查、.NET build 与两类实际打包通过,全量 611 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项),未连接或写入生产 DB。
- **08-14 / Unreleased / Origin 出版级排版参数**`origin.plot@v2` 新增可选画布、轴范围/尺度/步长/刻度排版/网格、标题与图例排版以及逐系列颜色、线型、点型和透明度PNG 宽度按物理画布与 DPI 计算旧请求默认行为不变。共享契约、Node 二次校验、固定 Worker 与运行文档已同步,专项 68 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 .NET build 通过;全量 608 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过,未连接或写入生产 DB。

6
RUN.md
View File

@ -1102,6 +1102,12 @@ install-windows-node.bat
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 或认证请求头。
二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2`panels` 数量为 14两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind``line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列panel 可独立设置 `x_axis/y_axis/right_y_axis`、标题、标签和图例。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名也不支持把等高线、3D 曲面、三元图或热图混入 panel。
科研统计图使用独立单图 type。`box` 和 `histogram` 的系列填写 `input/y`,直接使用原始观测列并采用 Origin 默认箱线统计与自动分箱;`stacked_column` 至少提供两条同类 `input/x/y` 系列;`bubble` 使用 `input/x/y/size`,每行 size 必须是有限正数;`band` 使用 `input/x/y/lower/upper`,其中 y 是中心线且每行 lower 不得大于 upper。当前 schema 不开放自定义箱线百分位、直方图分箱和分布拟合参数,也不允许把这五类统计图作为 `multi_panel.series[].kind`;在目标 Origin 版本完成真机验证后再增量开放。
常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度、Y 是半径pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind`
专业软件契约位于 `software-contracts/*.json`。zcbot 会在文件变化后校验并热加载完整契约快照正常修改契约不需要重启服务非法或尚未写完的文件不会替换上一份有效快照。Node Host 从 EXE 同级的 `adapters/*/adapter.json` 发现本机能力,通用校验只读取 manifest 指向的 JSON SchemaWorker 可以是受管 Python 脚本或独立 EXE。
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动实际版本、runtime、入口和契约文件名`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。

View File

@ -27,6 +27,40 @@ class SoftwareJobError(Exception):
pass
def _execution_runtime_snapshot(node: SoftwareNode | None, capability: str) -> dict:
if node is None:
return {}
runtime = node.runtime if isinstance(node.runtime, dict) else {}
capability_runtime = runtime.get("capability_runtime")
current = (
capability_runtime.get(capability, {})
if isinstance(capability_runtime, dict)
else {}
)
if not isinstance(current, dict):
current = {}
if capability == "origin.plot@v2" and isinstance(runtime.get("origin"), dict):
current = {**runtime["origin"], **current}
values = {
"node_version": runtime.get("node_version") or getattr(node, "node_version", ""),
"adapter_version": current.get("adapter_version"),
"software": current.get("software"),
"software_version": current.get("software_version"),
}
return {
key: value.strip()
for key, value in values.items()
if isinstance(value, str) and value.strip()
}
def _job_execution_runtime(row: SoftwareJob) -> dict:
stored_metrics = getattr(row, "metrics", None)
metrics = stored_metrics if isinstance(stored_metrics, dict) else {}
runtime = metrics.get("execution_runtime")
return runtime if isinstance(runtime, dict) else {}
def software_job_output_path(capability: str, output_id: str) -> str:
"""返回 capability Job 输出目录内的契约路径。"""
try:
@ -54,6 +88,7 @@ def _job_dict(row: SoftwareJob) -> dict:
"stage": row.stage,
"progress": row.progress,
"metrics": row.metrics,
"execution_runtime": _job_execution_runtime(row),
"error": row.error,
"artifact_manifest": row.artifact_manifest,
"output_dir": f"{contract.output_namespace}/{row.job_id}",
@ -634,6 +669,12 @@ def respond_to_offer(node_id: UUID, *, accepted: bool, payload: dict) -> None:
job.status = "dispatched"
job.stage = "accepted"
job.error = {}
snapshot = _execution_runtime_snapshot(
session.get(SoftwareNode, node_id), job.capability
)
if snapshot:
existing_metrics = job.metrics if isinstance(job.metrics, dict) else {}
job.metrics = {**existing_metrics, "execution_runtime": snapshot}
else:
job.status = "queued"
job.node_id = None
@ -670,7 +711,12 @@ def update_job_state(node_id: UUID, payload: dict) -> None:
)
job.stage = stage
job.progress = progress
job.metrics = metrics
execution_runtime = _job_execution_runtime(job)
job.metrics = {
key: value for key, value in metrics.items() if key != "execution_runtime"
}
if execution_runtime:
job.metrics["execution_runtime"] = execution_runtime
if job.status == "running" and job.started_at is None:
job.started_at = now

View File

@ -65,7 +65,18 @@
"contour": "0.5.0",
"surface_3d": "0.5.0",
"ternary": "0.5.0",
"heatmap": "0.5.0"
"heatmap": "0.5.0",
"multi_panel": "0.6.0",
"box": "0.7.0",
"histogram": "0.7.0",
"stacked_column": "0.7.0",
"bubble": "0.7.0",
"band": "0.7.0",
"area": "0.8.0",
"stacked_area": "0.8.0",
"polar": "0.8.0",
"pie": "0.8.0",
"stacked_bar": "0.8.0"
},
"summary": {
"title_path": ["operation", "plot", "title"]
@ -112,14 +123,16 @@
"properties": {
"plot": {
"type": "object",
"required": ["type", "series"],
"required": ["type"],
"additionalProperties": false,
"properties": {
"type": {
"enum": [
"line", "scatter", "line_scatter", "column", "bar",
"grouped_column", "y_error", "contour", "surface_3d",
"ternary", "heatmap"
"ternary", "heatmap", "multi_panel", "box", "histogram",
"stacked_column", "bubble", "band", "area",
"stacked_area", "polar", "pie", "stacked_bar"
]
},
"series": {
@ -136,12 +149,32 @@
"x": {"type": "string", "minLength": 1, "maxLength": 128},
"y": {"type": "string", "minLength": 1, "maxLength": 128},
"z": {"type": "string", "minLength": 1, "maxLength": 128},
"size": {"type": "string", "minLength": 1, "maxLength": 128},
"lower": {"type": "string", "minLength": 1, "maxLength": 128},
"upper": {"type": "string", "minLength": 1, "maxLength": 128},
"y_error": {"type": "string", "minLength": 1, "maxLength": 128},
"label": {"type": "string", "minLength": 1, "maxLength": 200},
"style": {"$ref": "#/$defs/series_style"}
}
}
},
"layout": {
"type": "object",
"required": ["rows", "columns"],
"additionalProperties": false,
"properties": {
"rows": {"type": "integer", "minimum": 1, "maximum": 2},
"columns": {"type": "integer", "minimum": 1, "maximum": 2},
"share_x": {"type": "boolean"},
"share_y": {"type": "boolean"}
}
},
"panels": {
"type": "array",
"minItems": 1,
"maxItems": 4,
"items": {"$ref": "#/$defs/panel"}
},
"template": {"const": "publication_double_column"},
"title": {"type": "string", "maxLength": 500},
"title_style": {"$ref": "#/$defs/text_style"},
@ -171,6 +204,22 @@
"error_bars": {"type": "null"}
},
"allOf": [
{
"if": {"properties": {"type": {"const": "multi_panel"}}},
"then": {
"required": ["layout", "panels"],
"not": {"required": ["series"]}
},
"else": {
"required": ["series"],
"not": {
"anyOf": [
{"required": ["layout"]},
{"required": ["panels"]}
]
}
}
},
{
"if": {"properties": {"type": {"enum": ["contour", "surface_3d", "ternary", "heatmap"]}}},
"then": {
@ -179,7 +228,10 @@
"maxItems": 1,
"items": {
"required": ["input", "x", "y", "z"],
"not": {"required": ["y_error"]}
"not": {"anyOf": [
{"required": ["y_error"]}, {"required": ["size"]},
{"required": ["lower"]}, {"required": ["upper"]}
]}
}
}
}
@ -192,7 +244,10 @@
"series": {
"items": {
"required": ["input", "x", "y", "y_error"],
"not": {"required": ["z"]}
"not": {"anyOf": [
{"required": ["z"]}, {"required": ["size"]},
{"required": ["lower"]}, {"required": ["upper"]}
]}
}
}
}
@ -206,24 +261,114 @@
"minItems": 2,
"items": {
"required": ["input", "x", "y"],
"not": {"anyOf": [{"required": ["z"]}, {"required": ["y_error"]}]}
"not": {"anyOf": [
{"required": ["z"]}, {"required": ["y_error"]},
{"required": ["size"]}, {"required": ["lower"]},
{"required": ["upper"]}
]}
}
}
}
}
},
{
"if": {"properties": {"type": {"enum": ["line", "scatter", "line_scatter", "column", "bar"]}}},
"if": {"properties": {"type": {"enum": [
"line", "scatter", "line_scatter", "column", "bar",
"area", "polar", "pie"
]}}},
"then": {
"properties": {
"series": {
"items": {
"required": ["input", "x", "y"],
"not": {"anyOf": [{"required": ["z"]}, {"required": ["y_error"]}]}
"not": {"anyOf": [
{"required": ["z"]}, {"required": ["y_error"]},
{"required": ["size"]}, {"required": ["lower"]},
{"required": ["upper"]}
]}
}
}
}
}
},
{
"if": {"properties": {"type": {"enum": ["box", "histogram"]}}},
"then": {
"properties": {
"series": {
"items": {
"required": ["input", "y"],
"not": {"anyOf": [
{"required": ["x"]}, {"required": ["z"]},
{"required": ["y_error"]}, {"required": ["size"]},
{"required": ["lower"]}, {"required": ["upper"]}
]}
}
}
}
}
},
{
"if": {"properties": {"type": {"enum": [
"stacked_column", "stacked_area", "stacked_bar"
]}}},
"then": {
"properties": {
"series": {
"minItems": 2,
"items": {
"required": ["input", "x", "y"],
"not": {"anyOf": [
{"required": ["z"]}, {"required": ["y_error"]},
{"required": ["size"]}, {"required": ["lower"]},
{"required": ["upper"]}
]}
}
}
}
}
},
{
"if": {"properties": {"type": {"const": "bubble"}}},
"then": {
"properties": {
"series": {
"items": {
"required": ["input", "x", "y", "size"],
"not": {"anyOf": [
{"required": ["z"]}, {"required": ["y_error"]},
{"required": ["lower"]}, {"required": ["upper"]}
]}
}
}
}
}
},
{
"if": {"properties": {"type": {"const": "band"}}},
"then": {
"properties": {
"series": {
"items": {
"required": ["input", "x", "y", "lower", "upper"],
"not": {"anyOf": [
{"required": ["z"]}, {"required": ["y_error"]},
{"required": ["size"]}
]}
}
}
}
}
},
{
"if": {"properties": {"type": {"enum": ["polar", "pie"]}}},
"then": {
"not": {"anyOf": [
{"required": ["x_axis"]},
{"required": ["y_axis"]},
{"required": ["z_axis"]}
]}
}
}
]
}
@ -321,6 +466,54 @@
"symbol_size": {"type": "number", "minimum": 1, "maximum": 100},
"transparency": {"type": "integer", "minimum": 0, "maximum": 100}
}
},
"panel_series": {
"type": "object",
"required": ["input", "x", "y", "kind"],
"additionalProperties": false,
"properties": {
"input": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"},
"x": {"type": "string", "minLength": 1, "maxLength": 128},
"y": {"type": "string", "minLength": 1, "maxLength": 128},
"x_error": {"type": "string", "minLength": 1, "maxLength": 128},
"y_error": {"type": "string", "minLength": 1, "maxLength": 128},
"kind": {"enum": ["line", "scatter", "line_scatter", "column", "bar"]},
"y_axis": {"enum": ["left", "right"]},
"label": {"type": "string", "minLength": 1, "maxLength": 200},
"style": {"$ref": "#/$defs/series_style"}
}
},
"panel": {
"type": "object",
"required": ["key", "series"],
"additionalProperties": false,
"properties": {
"key": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,31}$"},
"series": {
"type": "array",
"minItems": 1,
"maxItems": 16,
"uniqueItems": true,
"items": {"$ref": "#/$defs/panel_series"}
},
"panel_label": {"type": "string", "minLength": 1, "maxLength": 20},
"title": {"type": "string", "maxLength": 500},
"title_style": {"$ref": "#/$defs/text_style"},
"x_axis": {"$ref": "#/$defs/axis"},
"y_axis": {"$ref": "#/$defs/axis"},
"right_y_axis": {"$ref": "#/$defs/axis"},
"legend": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean"},
"position": {
"enum": ["top_left", "top_right", "bottom_left", "bottom_right"]
},
"font_size": {"type": "number", "minimum": 6, "maximum": 72}
}
}
}
}
}
}

View File

@ -5,6 +5,7 @@ import json
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
WORKER_PATH = (
Path(__file__).resolve().parents[1] / "windows-node" / "origin-worker" / "worker.py"
@ -35,6 +36,42 @@ class OriginWorkerUnitTests(unittest.TestCase):
manifest = json.loads(ADAPTER_MANIFEST_PATH.read_text(encoding="utf-8"))
self.assertEqual(worker.ADAPTER_VERSION, manifest["adapter_version"])
def test_registered_origin_version_comes_from_com_server_executable(self) -> None:
class FakeKey:
def __init__(self, path: str):
self.path = path
def __enter__(self):
return self
def __exit__(self, *_):
return None
class FakeRegistry:
HKEY_CLASSES_ROOT = object()
values = {
r"Origin.ApplicationSI\CLSID": "{origin-clsid}",
r"CLSID\{origin-clsid}\LocalServer32": (
r'"C:\Program Files\OriginLab\Origin2025\Origin64.exe" /Automation'
),
}
@classmethod
def OpenKey(cls, _root, path):
return FakeKey(path)
@classmethod
def QueryValueEx(cls, key, _name):
return cls.values[key.path], 1
with patch.object(worker, "_windows_file_version", return_value="2025") as read:
self.assertEqual(worker._registered_origin_version(FakeRegistry), "2025")
read.assert_called_once_with(
Path(r"C:\Program Files\OriginLab\Origin2025\Origin64.exe")
)
FakeRegistry.values[r"CLSID\{origin-clsid}\LocalServer32"] = "invalid"
self.assertIsNone(worker._registered_origin_version(FakeRegistry))
def test_csv_and_json_inputs_are_read_without_origin(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
@ -181,20 +218,334 @@ class OriginWorkerUnitTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "INPUT_BINDINGS_MUST_BE_USED_EXACTLY"):
worker._validate_semantics(request)
def test_series_support_xyz_and_y_error_roles(self) -> None:
def test_multi_panel_semantics_cover_layout_axes_and_total_series(self) -> None:
request = {
"inputs": [{"key": "sample"}],
"operation": {"plot": {
"type": "multi_panel",
"layout": {"rows": 1, "columns": 2, "share_x": True},
"panels": [
{
"key": "strength",
"series": [{
"input": "sample", "x": "age", "y": "strength",
"kind": "scatter", "y_error": "sd",
}],
},
{
"key": "thermal",
"series": [
{
"input": "sample", "x": "temperature", "y": "tg",
"kind": "line",
},
{
"input": "sample", "x": "temperature", "y": "dtg",
"kind": "line", "y_axis": "right",
},
],
"right_y_axis": {"title": "DTG"},
},
],
}},
"outputs": [{"key": "figure_png", "format": "png"}],
}
worker._validate_semantics(request)
request["operation"]["plot"]["layout"] = {"rows": 1, "columns": 1}
with self.assertRaisesRegex(ValueError, "PANEL_LAYOUT_TOO_SMALL"):
worker._validate_semantics(request)
request["operation"]["plot"]["layout"] = {
"rows": 1, "columns": 2, "share_x": True,
}
request["operation"]["plot"]["panels"][0]["x_axis"] = {
"scale": "log10", "minimum": 0,
}
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
worker._validate_semantics(request)
request["operation"]["plot"]["panels"][0]["x_axis"] = {"minimum": 1}
with self.assertRaisesRegex(ValueError, "SHARED_X_AXIS_CONFIG_MISMATCH"):
worker._validate_semantics(request)
def test_mixed_xy_series_pass_kind_axis_and_error_columns_to_origin(self) -> None:
class FakePlot:
pass
class FakeLayer:
def __init__(self):
self.calls = []
self.grouped = False
def add_plot(self, worksheet, **arguments):
self.calls.append((worksheet, arguments))
return FakePlot()
def group(self):
self.grouped = True
layer = FakeLayer()
series = [
{
"input": "sample", "x": "age", "y": "strength",
"kind": "scatter", "x_error": "age_sd", "y_error": "strength_sd",
},
{"input": "sample", "x": "age", "y": "fit", "kind": "line"},
]
resolved = [
{"input": "sample", "x": 0, "y": 1, "x_error": 2, "y_error": 3},
{"input": "sample", "x": 0, "y": 4},
]
worker._add_xy_plots(layer, {"sample": "worksheet"}, series, resolved)
self.assertEqual(layer.calls, [
("worksheet", {"coly": 1, "colx": 0, "type": "s", "colxerr": 2, "colyerr": 3}),
("worksheet", {"coly": 4, "colx": 0, "type": "l"}),
])
self.assertFalse(layer.grouped)
def test_series_support_xyz_and_error_roles(self) -> None:
resolved, labels = worker._resolve_series(
{"sample": (["x", "y", "z", "sd"], [[0, 1, 2, 0.1]])},
{"sample": (["x", "y", "z", "x_sd", "y_sd"], [[0, 1, 2, 0.1, 0.2]])},
[{
"input": "sample", "x": "x", "y": "y", "z": "z",
"y_error": "sd", "label": "测量值",
"x_error": "x_sd", "y_error": "y_sd", "label": "测量值",
}],
)
self.assertEqual(resolved, [{
"input": "sample", "label": "测量值", "x": 0, "y": 1,
"z": 2, "y_error": 3,
"z": 2, "x_error": 3, "y_error": 4,
}])
self.assertEqual(labels, {("sample", 1): "测量值"})
def test_series_support_second_batch_roles(self) -> None:
resolved, _ = worker._resolve_series(
{
"sample": (
["x", "mean", "size", "lower", "upper"],
[[0, 1, 2, 0.5, 1.5]],
)
},
[{
"input": "sample", "x": "x", "y": "mean", "size": "size",
"lower": "lower", "upper": "upper",
}],
)
self.assertEqual(resolved, [{
"input": "sample", "label": None, "x": 0, "y": 1,
"size": 2, "lower": 3, "upper": 4,
}])
def test_second_batch_value_validation_rejects_invalid_data(self) -> None:
input_data = {
"sample": (["x", "y", "size", "lower", "upper"], [[0, 1, 0, 2, 1]])
}
resolved = [{
"input": "sample", "x": 0, "y": 1, "size": 2, "lower": 3, "upper": 4,
}]
with self.assertRaisesRegex(ValueError, "SIZE_VALUE_NOT_POSITIVE"):
worker._validate_bubble_values(input_data, resolved)
with self.assertRaisesRegex(ValueError, "BAND_BOUNDS_INVERTED"):
worker._validate_band_values(input_data, resolved)
def test_band_graph_adds_bounds_fill_and_center_line(self) -> None:
class FakePlot:
def __init__(self):
self.fill = None
def set_fill_area(self, **arguments):
self.fill = arguments
class FakeLayer:
def __init__(self):
self.calls = []
def add_plot(self, worksheet, **arguments):
plot = FakePlot()
self.calls.append((worksheet, arguments, plot))
return plot
class FakeGraph:
def __init__(self):
self.layer = FakeLayer()
def __getitem__(self, index):
self.assert_index = index
return self.layer
class FakeOp:
def __init__(self):
self.graph = FakeGraph()
def new_graph(self, *, template):
self.template = template
return self.graph
op = FakeOp()
graph, layer, centers, legend = worker._build_band_graph(
op,
{"sample": "worksheet"},
[{"input": "sample", "x": "x", "y": "mean", "label": "95% CI"}],
[{"input": "sample", "x": 0, "y": 1, "lower": 2, "upper": 3}],
)
self.assertIs(graph, op.graph)
self.assertIs(layer, op.graph.layer)
self.assertEqual(op.template, "line")
self.assertEqual([item[1]["coly"] for item in layer.calls], [3, 2, 1])
self.assertEqual(layer.calls[0][2].fill, {"type": 9})
self.assertEqual(centers, [layer.calls[2][2]])
self.assertEqual(legend, r"\l(3) 95% CI")
def test_second_batch_plot_ids_and_templates_are_fixed(self) -> None:
self.assertEqual(worker.PLOT_CONFIG["box"], ("box", 206))
self.assertEqual(worker.PLOT_CONFIG["histogram"], ("hist", 219))
self.assertEqual(worker.PLOT_CONFIG["stacked_column"], ("StackColumn", 213))
self.assertEqual(worker.PLOT_CONFIG["bubble"], ("scatter", "s"))
def test_bubble_execution_parameters(self) -> None:
class FakeOp:
@staticmethod
def modi_col(offset):
return ("modifier", offset)
class FakePlot:
symbol_size = None
symbol_sizefactor = None
plot = FakePlot()
worker._configure_bubble_plot(
FakeOp(), plot, {"style": {"symbol_size": 12}}, {"y": 1, "size": 4}
)
self.assertEqual(plot.symbol_size, ("modifier", 3))
self.assertEqual(plot.symbol_sizefactor, 12)
def test_stacked_graph_uses_one_native_xyy_range(self) -> None:
class FakeSheet:
def __init__(self):
self.columns = []
def from_list(self, index, values, *, lname):
self.columns.append((index, values, lname))
@staticmethod
def lt_range(_use_name):
return "[Book1]1"
class FakeLayer:
def __init__(self):
self.calls = []
self.obj = self
def add_plot(self, data_range, *, type):
self.calls.append((data_range, type))
def activate(self):
self.activated = True
def group(self, enabled, begin, end):
self.group_call = (enabled, begin, end)
def LT_execute(self, command):
self.command = command
@staticmethod
def plot_list():
return ["a", "b"]
class FakeGraph:
def __init__(self):
self.layer = FakeLayer()
def __getitem__(self, index):
self.index = index
return self.layer
class FakeOp:
def __init__(self):
self.sheet = FakeSheet()
self.graph = FakeGraph()
def new_sheet(self, kind, *, lname):
self.sheet_call = (kind, lname)
return self.sheet
def new_graph(self, *, template):
self.template = template
return self.graph
op = FakeOp()
graph, layer, plots = worker._build_stacked_graph(
op,
{"sample": (["x", "a", "b"], [[1, 10, 5], [2, 12, 6]])},
[
{"input": "sample", "x": "x", "y": "a", "label": "A"},
{"input": "sample", "x": "x", "y": "b", "label": "B"},
],
[
{"input": "sample", "x": 0, "y": 1},
{"input": "sample", "x": 0, "y": 2},
],
"StackColumn",
213,
)
self.assertIs(graph, op.graph)
self.assertIs(layer, op.graph.layer)
self.assertEqual(plots, ["a", "b"])
self.assertEqual(op.template, "StackColumn")
self.assertEqual(layer.calls, [("[Book1]1!(1,2:3)", 213)])
self.assertEqual(layer.group_call, (True, 0, 1))
self.assertTrue(layer.activated)
self.assertEqual(layer.command, "layer -b s 1")
self.assertEqual(op.sheet.columns[1:], [
(1, [10, 12], "A"), (2, [5, 6], "B"),
])
def test_stacked_graph_rejects_mismatched_x_values(self) -> None:
with self.assertRaisesRegex(ValueError, "STACKED_PLOT_X_VALUES_MISMATCH"):
worker._build_stacked_graph(
object(),
{
"a": (["x", "y"], [[1, 10], [2, 12]]),
"b": (["x", "y"], [[1, 5], [3, 6]]),
},
[
{"input": "a", "x": "x", "y": "y"},
{"input": "b", "x": "x", "y": "y"},
],
[
{"input": "a", "x": 0, "y": 1},
{"input": "b", "x": 0, "y": 1},
],
"StackColumn",
213,
)
def test_third_batch_plot_ids_and_templates_are_fixed(self) -> None:
self.assertEqual(worker.PLOT_CONFIG["area"], ("area", 204))
self.assertEqual(worker.PLOT_CONFIG["stacked_area"], ("stackarea", 214))
self.assertEqual(worker.PLOT_CONFIG["polar"], ("polar", 192))
self.assertEqual(worker.PLOT_CONFIG["pie"], ("pie", 225))
self.assertEqual(worker.PLOT_CONFIG["stacked_bar"], ("bar", 216))
def test_stacked_legend_only_contains_requested_series(self) -> None:
class Label:
text = ""
class Layer:
legend = Label()
def label(self, name):
self.name = name
return self.legend
layer = Layer()
worker._replace_series_legend(layer, [
{"y": "a", "label": "Phase A"},
{"y": "phase_b"},
])
self.assertEqual(layer.name, "Legend")
self.assertEqual(layer.legend.text, r"\l(1) Phase A" "\n" r"\l(2) phase_b")
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]],

View File

@ -7,8 +7,8 @@ from pathlib import Path
from shutil import copy2
from core.software_contracts import (
DEFAULT_CAPABILITIES,
CONTRACT_ROOT,
DEFAULT_CAPABILITIES,
_ContractRegistry,
get_contract,
node_supports_request,
@ -66,6 +66,52 @@ class SoftwareContractTests(unittest.TestCase):
self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime))
self.assertFalse(node_supports_request(contract, _request("bar"), current_runtime))
multi_panel = _request("line")
multi_panel["operation"]["plot"] = {
"type": "multi_panel",
"layout": {"rows": 1, "columns": 1},
"panels": [{
"key": "main",
"series": [{
"input": "sample", "x": "x", "y": "y", "kind": "line",
}],
}],
}
normalized, _ = contract.normalize_request(multi_panel)
self.assertEqual(normalized, multi_panel)
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.6.0",
"features": ["multi_panel"],
})
self.assertTrue(node_supports_request(contract, multi_panel, current_runtime))
bubble = _request("line")
bubble["operation"]["plot"] = {
"type": "bubble",
"series": [{
"input": "sample", "x": "x", "y": "y", "size": "particle_size",
}],
}
normalized, _ = contract.normalize_request(bubble)
self.assertEqual(normalized, bubble)
self.assertFalse(node_supports_request(contract, bubble, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.7.0",
"features": ["bubble"],
})
self.assertTrue(node_supports_request(contract, bubble, current_runtime))
area = _request("line")
area["operation"]["plot"]["type"] = "area"
normalized, _ = contract.normalize_request(area)
self.assertEqual(normalized, area)
self.assertFalse(node_supports_request(contract, area, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.8.0",
"features": ["area"],
})
self.assertTrue(node_supports_request(contract, area, current_runtime))
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"))

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import importlib
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
@ -266,7 +267,12 @@ class SoftwareJobProtocolTests(unittest.TestCase):
job = type("Job", (), {})()
job.job_id = uuid4(); job.task_id = uuid4(); job.capability = "origin.plot@v2"
job.request_digest = "c" * 64; job.node_id = uuid4(); job.status = "running"
job.stage = "software_running"; job.progress = 20; job.metrics = {}; job.error = {}
job.stage = "software_running"; job.progress = 20; job.metrics = {
"execution_runtime": {
"node_version": "0.2.0", "adapter_version": "0.8.0",
"software": "OriginPro", "software_version": "2024",
}
}; job.error = {}
job.artifact_manifest = []; job.input_manifest = [{"key": "sample", "filename": "input.xlsx"}]
job.request = {"operation": {"plot": {"title": "Test"}}, "outputs": [
{"key": "figure_png", "type": "figure", "format": "png"}
@ -279,6 +285,7 @@ class SoftwareJobProtocolTests(unittest.TestCase):
self.assertEqual(results[0]["request_summary"]["display_name"], "Origin 科研绘图")
self.assertEqual(results[0]["request_summary"]["formats"], ["png"])
self.assertEqual(results[0]["output_dir"], f"origin/{job.job_id}")
self.assertEqual(results[0]["execution_runtime"]["software_version"], "2024")
def test_origin_request_is_canonical_and_rejects_extra_fields(self) -> None:
request = {
@ -397,6 +404,31 @@ class SoftwareJobProtocolTests(unittest.TestCase):
"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"}],
"box": [{"input": "sample", "y": "strength"}],
"histogram": [{"input": "sample", "y": "particle_size"}],
"stacked_column": [
{"input": "sample", "x": "sample_name", "y": "phase_a"},
{"input": "sample", "x": "sample_name", "y": "phase_b"},
],
"bubble": [{
"input": "sample", "x": "temperature", "y": "strength",
"size": "particle_size",
}],
"band": [{
"input": "sample", "x": "age", "y": "mean",
"lower": "ci_lower", "upper": "ci_upper",
}],
"area": [{"input": "sample", "x": "age", "y": "strength"}],
"stacked_area": [
{"input": "sample", "x": "age", "y": "phase_a"},
{"input": "sample", "x": "age", "y": "phase_b"},
],
"polar": [{"input": "sample", "x": "angle", "y": "intensity"}],
"pie": [{"input": "sample", "x": "phase", "y": "fraction"}],
"stacked_bar": [
{"input": "sample", "x": "sample_name", "y": "phase_a"},
{"input": "sample", "x": "sample_name", "y": "phase_b"},
],
}
for plot_type, series in cases.items():
request = {
@ -410,6 +442,55 @@ class SoftwareJobProtocolTests(unittest.TestCase):
self.assertEqual(normalized, request)
self.assertEqual(len(digest), 64)
def test_origin_request_accepts_multi_panel_mixed_series_and_dual_y(self) -> None:
request = {
"schema_version": 2,
"inputs": [{"key": "sample", "artifact_id": str(uuid4())}],
"operation": {"plot": {
"type": "multi_panel",
"layout": {"rows": 1, "columns": 2, "share_x": False},
"canvas": {"width_mm": 180, "height_mm": 90},
"panels": [
{
"key": "mechanical",
"panel_label": "(a)",
"title": "Mechanical properties",
"series": [
{
"input": "sample", "x": "age", "y": "strength",
"kind": "column", "y_error": "strength_sd",
},
{
"input": "sample", "x": "age", "y": "modulus",
"kind": "line_scatter", "y_axis": "right",
},
],
"x_axis": {"title": "Age", "unit": "d"},
"y_axis": {"title": "Strength", "unit": "MPa"},
"right_y_axis": {"title": "Modulus", "unit": "GPa"},
},
{
"key": "fit",
"panel_label": "(b)",
"series": [
{
"input": "sample", "x": "measured", "y": "predicted",
"kind": "scatter", "x_error": "measured_sd",
},
{
"input": "sample", "x": "measured", "y": "reference",
"kind": "line",
},
],
},
],
}},
"outputs": [{"key": "figure_svg", "type": "figure", "format": "svg"}],
}
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,
@ -418,9 +499,18 @@ class SoftwareJobProtocolTests(unittest.TestCase):
}
invalid_plots = (
{"type": "line", "series": [{"input": "sample", "x": "x", "y": "y", "z": "z"}]},
{"type": "line", "series": [{
"input": "sample", "x": "x", "y": "y", "size": "size",
}]},
{"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"}]},
{"type": "box", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "stacked_column", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "bubble", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "band", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "stacked_area", "series": [{"input": "sample", "x": "x", "y": "y"}]},
{"type": "polar", "series": [{"input": "sample", "x": "x", "y": "y"}], "x_axis": {"title": "Angle"}},
)
for plot in invalid_plots:
with self.subTest(plot_type=plot["type"]), self.assertRaises(SoftwareJobError):
@ -463,6 +553,50 @@ class SoftwareJobProtocolTests(unittest.TestCase):
payload={"job_id": str(uuid4()), "lease_id": str(job.lease_id)},
)
@patch("core.software_jobs.session_scope")
def test_accepted_job_snapshots_execution_versions(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value
node_id = uuid4()
lease_id = uuid4()
digest = "d" * 64
job = type("Job", (), {})()
job.node_id = node_id; job.lease_id = lease_id; job.status = "offered"
job.lease_expires_at = datetime.now(timezone.utc) + timedelta(minutes=1)
job.request_digest = digest; job.capability = "origin.plot@v2"; job.metrics = {}
node = type("Node", (), {})()
node.node_version = "0.1.0"
node.runtime = {
"node_version": "0.2.0",
"capability_runtime": {"origin.plot@v2": {
"adapter_version": "0.8.0", "health": "ready",
}},
"origin": {
"software": "OriginPro", "software_version": "2024",
"adapter_version": "0.8.0", "health": "ready",
},
}
session.execute.return_value.scalar_one_or_none.return_value = job
session.get.return_value = node
respond_to_offer(node_id, accepted=True, payload={
"job_id": str(uuid4()), "lease_id": str(lease_id),
"request_digest": digest,
})
self.assertEqual(job.metrics["execution_runtime"], {
"node_version": "0.2.0", "adapter_version": "0.8.0",
"software": "OriginPro", "software_version": "2024",
})
job.started_at = None
update_job_state(node_id, {
"job_id": str(uuid4()), "lease_id": str(lease_id),
"request_digest": digest, "stage": "software_running",
"progress": 10, "metrics": {"elapsed_seconds": 1},
})
self.assertEqual(job.metrics["elapsed_seconds"], 1)
self.assertEqual(job.metrics["execution_runtime"]["software_version"], "2024")
@patch("core.software_jobs.session_scope")
def test_failed_delivery_only_abandons_matching_offer(self, session_scope) -> None:
session = session_scope.return_value.__enter__.return_value

View File

@ -45,6 +45,9 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("/cancel`", source)
self.assertIn("分析结果", source)
self.assertIn("refreshCurrentTaskFiles(job.task_id)", source)
self.assertIn("job.execution_runtime", source)
self.assertIn("执行版本未记录", source)
self.assertIn("版本:${escapeHtml(jobRuntimeText(job))}", source)
self.assertIn(
'document.addEventListener("software-job-submitted", refreshSoftwareJobs)',
source,
@ -63,7 +66,10 @@ class StaticVendorTests(unittest.TestCase):
self.assertIn("生成 Windows Node 注册码", html)
self.assertIn('"/v1/admin/software-node-enrollments"', admin_js)
self.assertIn('capabilities: ["origin.plot@v2"]', admin_js)
self.assertIn('origin.health === "ready"', admin_js)
self.assertIn("runtime.capability_runtime", admin_js)
self.assertIn("item.adapter_version", admin_js)
self.assertIn("item.software_version", admin_js)
self.assertIn('runtime.origin ? [["origin.plot@v2", runtime.origin]]', admin_js)
self.assertIn("ttl_seconds: 600", admin_js)
self.assertIn("navigator.clipboard.writeText(value)", admin_js)
self.assertIn('apiGet("/v1/admin/software-nodes")', admin_js)

View File

@ -172,6 +172,11 @@ class WindowsNodeSourceTests(unittest.TestCase):
self.assertIn('r"Origin.ApplicationSI\\CLSID"', worker)
self.assertIn('["--probe"]', runner)
self.assertIn('root.GetProperty("adapter_version")', runner)
self.assertIn('startInfo.Environment["PYTHONUTF8"] = "1"', runner)
self.assertIn('startInfo.Environment["PYTHONIOENCODING"] = "utf-8"', runner)
form = (PROJECT / "ConfigurationForm.cs").read_text(encoding="utf-8")
self.assertIn('Adapter {adapter.AdapterVersion}', form)
self.assertIn('Origin 版本未知', form)
self.assertIn(
"&& !jobInbox.HasPendingJobs",
connection,

View File

@ -166,17 +166,40 @@ function nodeStatusHTML(status) {
return `<span class="node-status ${value}">${labels[value]}</span>`;
}
function nodeCapabilityRuntimes(runtime) {
const entries = Object.entries(runtime.capability_runtime || {})
.filter(([, value]) => value && typeof value === "object");
if (entries.length) return entries;
return runtime.origin ? [["origin.plot@v2", runtime.origin]] : [];
}
function nodeSoftwareName(capability, item) {
if (item.software === "OriginPro" || capability.startsWith("origin.")) return "Origin";
return item.software || capability;
}
function renderWindowsNodes() {
const rows = softwareNodes.map(node => {
const runtime = node.runtime || {};
const origin = runtime.origin || {};
const originState = origin.health === "ready" ? "Origin 可用" : "Origin 不可用";
const originVersion = origin.software_version ? ` ${origin.software_version}` : "";
const capabilityRuntimes = nodeCapabilityRuntimes(runtime);
const runtimeParts = [
node.os_version || "",
runtime.desktop_session === true ? "桌面会话" : "",
runtime.available_slots != null ? `可用槽位 ${runtime.available_slots}` : "",
runtime.origin ? `${originState}${originVersion}` : "",
...capabilityRuntimes.map(([capability, item]) => {
const name = nodeSoftwareName(capability, item);
return `${name}${item.health === "ready" ? "可用" : "不可用"}`;
}),
].filter(Boolean);
const versionParts = [
node.node_version ? `Node ${node.node_version}` : "",
...capabilityRuntimes.flatMap(([capability, item]) => {
const name = nodeSoftwareName(capability, item);
return [
item.adapter_version ? `${name} Adapter ${item.adapter_version}` : "",
item.software_version ? `${name} ${item.software_version}` : "",
];
}),
].filter(Boolean);
const lastSeen = node.last_seen_at
? `<span title="${escapeHtml(fmtTime(node.last_seen_at))}">${escapeHtml(fmtTimeAgo(node.last_seen_at))}</span>`
@ -187,7 +210,7 @@ function renderWindowsNodes() {
+ `<div class="node-id">${escapeHtml(node.node_id)}</div></td>`
+ `<td>${nodeStatusHTML(node.status)}</td>`
+ `<td class="node-runtime">${escapeHtml(runtimeParts.join(" · ") || "—")}</td>`
+ `<td>${escapeHtml(node.node_version || "—")}</td>`
+ `<td class="node-runtime">${escapeHtml(versionParts.join(" · ") || "—")}</td>`
+ `<td>${(node.capabilities || []).map(x => `<span class="chip">${escapeHtml(x)}</span>`).join(" ") || "—"}</td>`
+ `<td>${lastSeen}</td>`
+ `<td class="node-actions"><button type="button" data-node-toggle="${disabled ? "enable" : "disable"}" `

View File

@ -163,6 +163,21 @@ function onListScroll(event) {
if (list.scrollHeight - list.scrollTop - list.clientHeight < 160) loadMore();
}
function jobRuntimeText(job) {
const runtime = job.execution_runtime || {};
const software = runtime.software === "OriginPro"
|| job.capability?.startsWith("origin.") ? "Origin" : (runtime.software || "软件");
const parts = [
runtime.node_version ? `Node ${runtime.node_version}` : "",
runtime.adapter_version ? `${software} Adapter ${runtime.adapter_version}` : "",
runtime.software_version ? `${software} ${runtime.software_version}` : "",
].filter(Boolean);
if (parts.length) return parts.join(" · ");
return ["queued", "offered"].includes(job.status)
? "执行版本待节点确认"
: "执行版本未记录";
}
function jobCard(job) {
const summary = job.request_summary || {};
const inputs = Array.isArray(job.input) ? job.input : [];
@ -179,6 +194,7 @@ function jobCard(job) {
<div class="sj-sub">${icons.activity}<span>${escapeHtml(detail)}${error ? ` · ${escapeHtml(error)}` : ""}</span></div>
${active ? `<div class="sj-progress"><i style="width:${progress}%"></i></div>` : ""}
<div class="sj-meta">${icons.clock}${escapeHtml(job.task_name || "未命名对话")}${inputNames ? ` · ${escapeHtml(inputNames)}` : ""}${openedText ? ` · 开启于 ${escapeHtml(openedText)}` : ""}</div>
<div class="sj-meta">版本${escapeHtml(jobRuntimeText(job))}</div>
<div class="sj-actions">
<button class="small" data-job-action="open" data-task-id="${escapeHtml(job.task_id)}">${icons.open}打开对话</button>
${job.status === "succeeded" ? `<button class="small primary" data-job-action="analyze" data-task-id="${escapeHtml(job.task_id)}">${icons.analyze}分析结果</button>` : ""}

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`。
固定 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`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式。adapter 0.6.0 提供 `multi_panel`:可组成 14 个二维 panel在每个 panel 混合基础二维系列、绑定左右 Y 轴并添加对称 X/Y 误差列adapter 0.7.0 增加 `box`、`histogram`、`stacked_column`、`bubble` 和 `band`adapter 0.8.0 再增加 `area`、`stacked_area`、`polar`、`pie` 和 `stacked_bar`。Worker 生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格;所有堆叠系列必须逐行共享相同 X 值。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口使用实际运行 Node 的专用 Windows 账号直接双击:
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要重新运行统一安装器。

View File

@ -196,7 +196,7 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
return new ProcessCommand(interpreter, [descriptor.EntrypointPath]);
}
private static Process Start(
private Process Start(
ProcessCommand command, string workingDirectory, IReadOnlyList<string> arguments)
{
var startInfo = new ProcessStartInfo
@ -214,6 +214,11 @@ internal sealed class AdapterProcessRunner(AdapterDescriptor descriptor, JobInbo
{
startInfo.ArgumentList.Add(argument);
}
if (descriptor.Manifest.Runtime == "python")
{
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
}
return Process.Start(startInfo)
?? throw new InvalidOperationException("Adapter worker did not start.");
}

View File

@ -206,11 +206,12 @@ internal sealed class ConfigurationForm : Form
private static string FormatAdapterStatus(INodeAdapter adapter)
{
var runtime = adapter.DetectRuntime();
var version = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
? "版本未知"
: $"版本 {runtime.SoftwareVersion}";
var softwareVersion = string.IsNullOrWhiteSpace(runtime.SoftwareVersion)
? "Origin 版本未知"
: $"Origin {runtime.SoftwareVersion}";
var state = runtime.Health == "ready" ? "可用" : "不可用";
return $"{adapter.DisplayName}{state} · {version}\n{runtime.Detail}";
return $"{adapter.DisplayName}{state} · Adapter {adapter.AdapterVersion} · {softwareVersion}\n"
+ runtime.Detail;
}
private void RefreshJobs()

View File

@ -495,6 +495,8 @@ internal sealed class NodeConnectionLoop(NodeConfig config, Action<NodeStatus>?
&& !item.HasActiveJobs ? 1 : 0;
return new
{
software = runtime.Software,
software_version = runtime.SoftwareVersion,
adapter_version = item.AdapterVersion,
features = item.Features,
available_slots = slots,

View File

@ -1,6 +1,6 @@
{
"capability": "origin.plot@v2",
"adapter_version": "0.5.0",
"adapter_version": "0.8.0",
"runtime": "python",
"runtime_id": "origin",
"entrypoint": "worker.py",

View File

@ -11,9 +11,11 @@ import hashlib
import json
import math
import os
import re
import sys
from datetime import datetime, timezone
from importlib.metadata import PackageNotFoundError, version
from itertools import pairwise
from pathlib import Path
from typing import Any
@ -28,8 +30,25 @@ PLOT_CONFIG = {
"contour": ("TriContour", 243),
"surface_3d": ("glCMAP", 103),
"ternary": ("ternary", 245),
"box": ("box", 206),
"histogram": ("hist", 219),
"stacked_column": ("StackColumn", 213),
"bubble": ("scatter", "s"),
"band": ("line", "l"),
"area": ("area", 204),
"stacked_area": ("stackarea", 214),
"polar": ("polar", 192),
"pie": ("pie", 225),
"stacked_bar": ("bar", 216),
}
STACKED_PLOT_TYPES = {"stacked_column", "stacked_area", "stacked_bar"}
_CUMULATIVE_STACK_COMMAND = "layer -b s 1"
XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"}
PANEL_TEMPLATES = {
(1, 2): "PAN2HORZ",
(2, 1): "PAN2VERT",
(2, 2): "PAN4",
}
FORMATS = {"opju", "png", "svg", "pdf"}
LINE_STYLES = {
"solid": 1,
@ -52,7 +71,126 @@ LEGEND_POSITIONS = {
"bottom_left": (700, 7200),
"bottom_right": (6800, 7200),
}
ADAPTER_VERSION = "0.5.0"
ADAPTER_VERSION = "0.8.0"
def _server_executable(command: str) -> Path:
value = os.path.expandvars(command.strip())
match = re.match(r'^\s*"([^"]+\.exe)"|^\s*(.+?\.exe)(?:\s|$)', value, re.IGNORECASE)
if match is None:
raise ValueError("Origin COM server executable is invalid")
return Path(match.group(1) or match.group(2))
def _windows_file_version(path: Path) -> str:
import ctypes
from ctypes import wintypes
class FixedFileInfo(ctypes.Structure):
_fields_ = [
("signature", wintypes.DWORD),
("structure_version", wintypes.DWORD),
("file_version_ms", wintypes.DWORD),
("file_version_ls", wintypes.DWORD),
("product_version_ms", wintypes.DWORD),
("product_version_ls", wintypes.DWORD),
("file_flags_mask", wintypes.DWORD),
("file_flags", wintypes.DWORD),
("file_os", wintypes.DWORD),
("file_type", wintypes.DWORD),
("file_subtype", wintypes.DWORD),
("file_date_ms", wintypes.DWORD),
("file_date_ls", wintypes.DWORD),
]
class LanguageAndCodePage(ctypes.Structure):
_fields_ = [("language", wintypes.WORD), ("code_page", wintypes.WORD)]
version_api = ctypes.WinDLL("version", use_last_error=True)
version_api.GetFileVersionInfoSizeW.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(wintypes.DWORD)
]
version_api.GetFileVersionInfoSizeW.restype = wintypes.DWORD
version_api.GetFileVersionInfoW.argtypes = [
wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, ctypes.c_void_p
]
version_api.GetFileVersionInfoW.restype = wintypes.BOOL
version_api.VerQueryValueW.argtypes = [
ctypes.c_void_p,
wintypes.LPCWSTR,
ctypes.POINTER(ctypes.c_void_p),
ctypes.POINTER(wintypes.UINT),
]
version_api.VerQueryValueW.restype = wintypes.BOOL
ignored_handle = wintypes.DWORD()
size = version_api.GetFileVersionInfoSizeW(str(path), ctypes.byref(ignored_handle))
if size == 0:
raise ctypes.WinError(ctypes.get_last_error())
buffer = ctypes.create_string_buffer(size)
if not version_api.GetFileVersionInfoW(str(path), 0, size, buffer):
raise ctypes.WinError(ctypes.get_last_error())
value = ctypes.c_void_p()
value_size = wintypes.UINT()
if not version_api.VerQueryValueW(
buffer, "\\", ctypes.byref(value), ctypes.byref(value_size)
):
raise ctypes.WinError(ctypes.get_last_error())
info = ctypes.cast(value, ctypes.POINTER(FixedFileInfo)).contents
translation = ctypes.c_void_p()
translation_size = wintypes.UINT()
if version_api.VerQueryValueW(
buffer,
r"\VarFileInfo\Translation",
ctypes.byref(translation),
ctypes.byref(translation_size),
):
count = translation_size.value // ctypes.sizeof(LanguageAndCodePage)
translations = ctypes.cast(
translation, ctypes.POINTER(LanguageAndCodePage * count)
).contents
for item in translations:
prefix = rf"\StringFileInfo\{item.language:04x}{item.code_page:04x}"
strings = {}
for name in ("FileDescription", "ProductVersion"):
string_value = ctypes.c_void_p()
string_size = wintypes.UINT()
if version_api.VerQueryValueW(
buffer,
f"{prefix}\\{name}",
ctypes.byref(string_value),
ctypes.byref(string_size),
):
strings[name] = ctypes.wstring_at(string_value).strip()
description = strings.get("FileDescription", "")
match = re.fullmatch(r"Origin(?:Pro)?\s+(.+)", description)
if match:
return match.group(1)
if strings.get("ProductVersion"):
return strings["ProductVersion"]
parts = (
info.file_version_ms >> 16,
info.file_version_ms & 0xFFFF,
info.file_version_ls >> 16,
info.file_version_ls & 0xFFFF,
)
if not any(parts):
raise ValueError("Origin executable has no file version")
return ".".join(str(part) for part in parts)
def _registered_origin_version(winreg: Any) -> str | None:
try:
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"
) as clsid_key:
clsid = winreg.QueryValueEx(clsid_key, None)[0]
with winreg.OpenKey(
winreg.HKEY_CLASSES_ROOT, rf"CLSID\{clsid}\LocalServer32"
) as server_key:
command = winreg.QueryValueEx(server_key, None)[0]
return _windows_file_version(_server_executable(str(command)))
except (OSError, ValueError):
return None
def _probe() -> int:
@ -63,10 +201,12 @@ def _probe() -> int:
if sys.platform != "win32":
raise RuntimeError("Origin adapter requires Windows")
import winreg
import originpro
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"):
pass
software_version = _registered_origin_version(winreg)
originpro_version = version("originpro")
detail = f"Origin COM 与托管 Python 运行时可用originpro {originpro_version}"
del originpro
@ -87,32 +227,72 @@ def _validate_semantics(request: dict[str, Any]) -> None:
input_keys = {item["key"] for item in request["inputs"]}
plot = request["operation"]["plot"]
plot_type = plot["type"]
if plot_type == "multi_panel":
panels = plot["panels"]
layout = plot["layout"]
grid = (layout["rows"], layout["columns"])
valid_grids = {
1: {(1, 1)},
2: {(1, 2), (2, 1)},
3: {(2, 2)},
4: {(2, 2)},
}
if grid not in valid_grids[len(panels)]:
raise ValueError("PANEL_LAYOUT_TOO_SMALL")
panel_keys = [panel["key"] for panel in panels]
if len(set(panel_keys)) != len(panel_keys):
raise ValueError("PANEL_KEYS_MUST_BE_UNIQUE")
series = [item for panel in panels for item in panel["series"]]
if len(series) > 16:
raise ValueError("SERIES_COUNT_EXCEEDED")
for panel in panels:
if not any(item.get("y_axis", "left") == "left" for item in panel["series"]):
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)
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
if layout.get(share_name):
settings = [panel.get(axis_name) or {} for panel in panels]
if any(value != settings[0] for value in settings[1:]):
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
else:
series = plot["series"]
used_inputs = {item["input"] for item in series}
if used_inputs != input_keys:
raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY")
if plot_type == "multi_panel":
return
if plot_type == "grouped_column" and len(series) < 2:
raise ValueError("GROUPED_COLUMN_REQUIRES_MULTIPLE_SERIES")
if plot_type in STACKED_PLOT_TYPES and len(series) < 2:
raise ValueError("STACKED_PLOT_REQUIRES_MULTIPLE_SERIES")
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
required_roles = (
("x", "y", "z") if plot_type in XYZ_PLOT_TYPES
else ("x", "y", "y_error") if plot_type == "y_error"
else ("y",) if plot_type in {"box", "histogram"}
else ("x", "y", "size") if plot_type == "bubble"
else ("x", "y", "lower", "upper") if plot_type == "band"
else ("x", "y")
)
for item in series:
if any(role not in item for role in required_roles):
raise ValueError("SERIES_REQUIRED_ROLE_MISSING")
for axis_name in ("x_axis", "y_axis", "z_axis"):
axis = plot.get(axis_name) or {}
_validate_axis(plot.get(axis_name), axis_name)
def _validate_axis(value: Any, name: str) -> None:
axis = value if isinstance(value, dict) else {}
minimum = axis.get("minimum")
maximum = axis.get("maximum")
if minimum is not None and maximum is not None and minimum >= maximum:
raise ValueError(f"{axis_name.upper()}_LIMITS_INVALID")
raise ValueError(f"{name.upper()}_LIMITS_INVALID")
if axis.get("scale") in {"log10", "ln", "log2"} and (
minimum is not None and minimum <= 0 or maximum is not None and maximum <= 0
):
raise ValueError(f"{axis_name.upper()}_LOG_LIMIT_INVALID")
raise ValueError(f"{name.upper()}_LOG_LIMIT_INVALID")
def _atomic_json(path: Path, value: Any) -> None:
@ -227,7 +407,7 @@ def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None:
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]
title_object = {"x": "xb", "y": "yl", "y2": "yr", "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"]]
@ -251,7 +431,7 @@ def _apply_series_style(origin_plot: Any, style: Any) -> None:
origin_plot.transparency = int(style["transparency"])
def _apply_legend(layer: Any, legend: Any) -> None:
def _apply_legend(layer: Any, legend: Any, vertical_offset: int = 0) -> None:
if not isinstance(legend, dict):
return
label = layer.label("Legend")
@ -260,8 +440,26 @@ def _apply_legend(layer: Any, legend: Any) -> None:
label.set_int("fsize", round(legend["font_size"]))
if "position" in legend:
left, top = LEGEND_POSITIONS[legend["position"]]
if vertical_offset and legend["position"].startswith("bottom"):
vertical_offset = -vertical_offset
label.set_int("left", left)
label.set_int("top", top)
label.set_int("top", top + vertical_offset)
def _replace_series_legend(layer: Any, series_specs: list[dict[str, Any]]) -> None:
layer.label("Legend").text = "\n".join(
f"\\l({index}) {item.get('label') or item['y']}"
for index, item in enumerate(series_specs, start=1)
)
def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None:
if not value:
return
title = layer.add_label(str(value))
title.set_int("fsize", round((style or {}).get("font_size", 18)))
title.set_int("left", 2200)
title.set_int("top", top)
def _input_file(job_dir: Path, key: str) -> Path:
@ -283,7 +481,9 @@ def _resolve_series(
headers, _ = input_data[input_key]
role_indexes = {
role: _column_index(headers, series[role], role)
for role in ("x", "y", "z", "y_error") if role in series
for role in (
"x", "y", "z", "size", "lower", "upper", "x_error", "y_error"
) if role in series
}
y_index = role_indexes["y"]
label = series.get("label")
@ -308,6 +508,37 @@ def _number(value: Any, role: str) -> float:
return number
def _validate_bubble_values(
input_data: dict[str, tuple[list[str], list[list[Any]]]],
resolved_series: list[dict[str, Any]],
) -> None:
for resolved in resolved_series:
_, rows = input_data[resolved["input"]]
for row in rows:
try:
size = _number(row[resolved["size"]], "size")
except IndexError as exc:
raise ValueError("BUBBLE_ROW_INCOMPLETE") from exc
if size <= 0:
raise ValueError("SIZE_VALUE_NOT_POSITIVE")
def _validate_band_values(
input_data: dict[str, tuple[list[str], list[list[Any]]]],
resolved_series: list[dict[str, Any]],
) -> None:
for resolved in resolved_series:
_, rows = input_data[resolved["input"]]
for row in rows:
try:
lower = _number(row[resolved["lower"]], "lower")
upper = _number(row[resolved["upper"]], "upper")
except IndexError as exc:
raise ValueError("BAND_ROW_INCOMPLETE") from exc
if lower > upper:
raise ValueError("BAND_BOUNDS_INVERTED")
def _is_evenly_spaced(values: list[float]) -> bool:
if len(values) <= 2:
return True
@ -315,7 +546,7 @@ def _is_evenly_spaced(values: list[float]) -> bool:
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)
for previous, current in pairwise(values[1:])
)
@ -359,6 +590,199 @@ def _validate_artifact(path: Path, extension: str) -> None:
raise RuntimeError("OPJU_EXPORT_INVALID")
def _add_xy_plots(
layer: Any,
worksheets: dict[str, Any],
series_specs: list[dict[str, Any]],
resolved_series: list[dict[str, Any]],
) -> list[Any]:
plots = []
for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
kind = series_spec["kind"]
arguments = {
"coly": resolved["y"],
"colx": resolved["x"],
"type": PLOT_CONFIG[kind][1],
}
if "x_error" in resolved:
arguments["colxerr"] = resolved["x_error"]
if "y_error" in resolved:
arguments["colyerr"] = resolved["y_error"]
plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
_apply_series_style(plot, series_spec.get("style"))
plots.append(plot)
if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs):
layer.group()
return plots
def _configure_bubble_plot(
op: Any,
origin_plot: Any,
series_spec: dict[str, Any],
resolved: dict[str, Any],
) -> None:
origin_plot.symbol_size = op.modi_col(resolved["size"] - resolved["y"])
origin_plot.symbol_sizefactor = float(
(series_spec.get("style") or {}).get("symbol_size", 10)
)
def _build_stacked_graph(
op: Any,
input_data: dict[str, tuple[list[str], list[list[Any]]]],
series_specs: list[dict[str, Any]],
resolved_series: list[dict[str, Any]],
template: str,
plot_type: int,
) -> tuple[Any, Any, list[Any]]:
first = resolved_series[0]
_, first_rows = input_data[first["input"]]
x_values = [row[first["x"]] for row in first_rows]
y_columns: list[list[Any]] = []
for resolved in resolved_series:
_, rows = input_data[resolved["input"]]
current_x = [row[resolved["x"]] for row in rows]
if current_x != x_values:
raise ValueError("STACKED_PLOT_X_VALUES_MISMATCH")
y_columns.append([row[resolved["y"]] for row in rows])
staging = op.new_sheet("w", lname="stacked_plot_data")
staging.from_list(0, x_values, lname=str(series_specs[0]["x"]))
for index, (series_spec, values) in enumerate(
zip(series_specs, y_columns, strict=True), start=1
):
staging.from_list(
index,
values,
lname=str(series_spec.get("label") or series_spec["y"]),
)
graph = op.new_graph(template=template)
layer = graph[0]
data_range = f"{staging.lt_range(False)}!(1,2:{len(series_specs) + 1})"
layer.add_plot(data_range, type=plot_type)
layer.group(True, 0, len(series_specs) - 1)
layer.activate()
layer.obj.LT_execute(_CUMULATIVE_STACK_COMMAND)
return graph, layer, list(layer.plot_list())
def _share_axis_limits(layers: list[Any], name: str) -> None:
limits = [getattr(layer, f"{name}lim") for layer in layers]
begin = min(item[0] for item in limits)
end = max(item[1] for item in limits)
for layer in layers:
getattr(layer, f"set_{name}lim")(begin, end)
def _build_multi_panel_graph(
op: Any,
plot_spec: dict[str, Any],
worksheets: dict[str, Any],
resolved_series: list[dict[str, Any]],
) -> Any:
panels = plot_spec["panels"]
layout = plot_spec["layout"]
grid = (layout["rows"], layout["columns"])
if len(panels) == 1:
template = PLOT_CONFIG[panels[0]["series"][0]["kind"]][0]
else:
template = PANEL_TEMPLATES[grid]
graph = op.new_graph(template=template)
primary_layers = list(graph)[:len(panels)]
for unused_layer in list(graph)[len(panels):]:
unused_layer.show = False
resolved_offset = 0
for index, (panel, layer) in enumerate(zip(panels, primary_layers, strict=True)):
panel_series = panel["series"]
resolved_panel = resolved_series[
resolved_offset:resolved_offset + len(panel_series)
]
resolved_offset += len(panel_series)
left_pairs = [
(spec, resolved)
for spec, resolved in zip(panel_series, resolved_panel, strict=True)
if spec.get("y_axis", "left") == "left"
]
right_pairs = [
(spec, resolved)
for spec, resolved in zip(panel_series, resolved_panel, strict=True)
if spec.get("y_axis", "left") == "right"
]
left_specs = [item[0] for item in left_pairs]
left_resolved = [item[1] for item in left_pairs]
_add_xy_plots(layer, worksheets, left_specs, left_resolved)
layer.rescale()
_apply_axis(layer, "x", panel.get("x_axis"), str(left_specs[0]["x"]))
_apply_axis(layer, "y", panel.get("y_axis"), "Y")
legend = {"enabled": True, "position": "top_right", **(panel.get("legend") or {})}
_apply_legend(layer, legend)
_apply_title(layer, panel.get("title"), panel.get("title_style"))
if panel.get("panel_label"):
panel_label = layer.add_label(str(panel["panel_label"]))
panel_label.set_int("fsize", round((panel.get("title_style") or {}).get("font_size", 12)))
panel_label.set_int("left", 120)
panel_label.set_int("top", 120)
if right_pairs:
layer.activate()
right_layer = graph.add_layer(2)
right_specs = [item[0] for item in right_pairs]
right_resolved = [item[1] for item in right_pairs]
_add_xy_plots(right_layer, worksheets, right_specs, right_resolved)
right_layer.rescale()
_apply_axis(right_layer, "y2", panel.get("right_y_axis"), "Right Y")
_apply_legend(right_layer, legend, vertical_offset=700)
if index == 0:
_apply_title(
layer,
plot_spec.get("title"),
plot_spec.get("title_style"),
top=-360,
)
if layout.get("share_x") and len(primary_layers) > 1:
_share_axis_limits(primary_layers, "x")
if layout.get("share_y") and len(primary_layers) > 1:
_share_axis_limits(primary_layers, "y")
return graph
def _build_band_graph(
op: Any,
worksheets: dict[str, Any],
series_specs: list[dict[str, Any]],
resolved_series: list[dict[str, Any]],
) -> tuple[Any, Any, list[Any], str]:
graph = op.new_graph(template="line")
layer = graph[0]
center_plots = []
legend_entries = []
for index, (series_spec, resolved) in enumerate(
zip(series_specs, resolved_series, strict=True)
):
worksheet = worksheets[resolved["input"]]
upper_plot = layer.add_plot(
worksheet, coly=resolved["upper"], colx=resolved["x"], type="l"
)
lower_plot = layer.add_plot(
worksheet, coly=resolved["lower"], colx=resolved["x"], type="l"
)
upper_plot.set_fill_area(type=9)
_apply_series_style(upper_plot, series_spec.get("style"))
_apply_series_style(lower_plot, series_spec.get("style"))
center_plot = layer.add_plot(
worksheet, coly=resolved["y"], colx=resolved["x"], type="l"
)
center_plots.append(center_plot)
label = series_spec.get("label") or series_spec["y"]
legend_entries.append(f"\\l({index * 3 + 3}) {label}")
return graph, layer, center_plots, "\n".join(legend_entries)
def run(job_dir: Path) -> list[dict[str, Any]]:
job_dir = job_dir.resolve(strict=True)
request_record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8"))
@ -375,10 +799,18 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
}
plot_spec = request["operation"]["plot"]
plot_type = plot_spec["type"]
if plot_type not in PLOT_CONFIG and plot_type != "heatmap":
if plot_type not in PLOT_CONFIG and plot_type not in {"heatmap", "multi_panel"}:
raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
series_specs = plot_spec["series"]
series_specs = (
[item for panel in plot_spec["panels"] for item in panel["series"]]
if plot_type == "multi_panel"
else plot_spec["series"]
)
resolved_series, labels = _resolve_series(input_data, series_specs)
if plot_type == "bubble":
_validate_bubble_values(input_data, resolved_series)
if plot_type == "band":
_validate_band_values(input_data, resolved_series)
import originpro as op
@ -400,7 +832,18 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
[row[index] if index < len(row) else None for row in rows],
lname=column_label,
)
if plot_type == "heatmap":
band_legend = None
if plot_type == "multi_panel":
graph = _build_multi_panel_graph(
op, plot_spec, worksheets, resolved_series
)
layer = graph[0]
origin_plots = []
elif plot_type == "band":
graph, layer, origin_plots, band_legend = _build_band_graph(
op, worksheets, series_specs, resolved_series
)
elif plot_type == "heatmap":
import numpy as np
resolved = resolved_series[0]
@ -415,43 +858,67 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
graph = op.new_graph(template="heatmap")
layer = graph[0]
origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)]
elif plot_type in STACKED_PLOT_TYPES:
template, origin_plot_type = PLOT_CONFIG[plot_type]
graph, layer, origin_plots = _build_stacked_graph(
op,
input_data,
series_specs,
resolved_series,
template,
origin_plot_type,
)
else:
template, origin_plot_type = PLOT_CONFIG[plot_type]
graph = op.new_graph(template=template)
layer = graph[0]
origin_plots = []
for resolved in resolved_series:
for series_spec, resolved in zip(
series_specs, resolved_series, strict=True
):
arguments = {
"coly": resolved["y"],
"colx": resolved["x"],
"type": origin_plot_type,
}
if "x" in resolved:
arguments["colx"] = resolved["x"]
if plot_type in XYZ_PLOT_TYPES:
arguments["colz"] = resolved["z"]
if plot_type == "y_error":
arguments["colyerr"] = resolved["y_error"]
origin_plots.append(layer.add_plot(worksheets[resolved["input"]], **arguments))
origin_plot = layer.add_plot(
worksheets[resolved["input"]], **arguments
)
if plot_type == "bubble":
_configure_bubble_plot(op, origin_plot, series_spec, resolved)
origin_plots.append(origin_plot)
if plot_type == "grouped_column":
layer.group()
layer.rescale()
_apply_canvas(graph, plot_spec.get("canvas"))
if plot_type != "multi_panel":
layer.rescale()
if plot_type not in {"polar", "pie"}:
_apply_axis(
layer, "x", plot_spec.get("x_axis"), str(series_specs[0].get("x") or "X")
layer, "x", plot_spec.get("x_axis"),
str(series_specs[0].get("x") or "X")
)
_apply_axis(layer, "y", plot_spec.get("y_axis"), "Y")
if plot_type == "surface_3d":
_apply_axis(
layer, "z", 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"))
style = series_spec.get("style")
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)
if plot_type in STACKED_PLOT_TYPES:
_replace_series_legend(layer, series_specs)
_apply_legend(layer, plot_spec.get("legend"))
if plot_spec.get("title"):
title = layer.add_label(str(plot_spec["title"]))
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("top", 120)
if band_legend is not None:
layer.label("Legend").text = band_legend
_apply_title(layer, plot_spec.get("title"), plot_spec.get("title_style"))
requested_outputs = request["outputs"]
formats = [item["format"] for item in requested_outputs]
if any(item not in FORMATS for item in formats):