From d62b5e0766f1689e2b166a377867e3daebc682bd Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 14 Aug 2026 16:21:32 +0800 Subject: [PATCH] feat(origin): expand scientific chart types --- CHANGELOG.md | 6 + DESIGN.md | 4 +- PROGRESS.md | 6 + RUN.md | 6 + software-contracts/origin.plot.v2.json | 209 ++++++++- tests/test_origin_worker.py | 322 ++++++++++++- tests/test_software_contracts.py | 48 +- tests/test_software_nodes.py | 83 ++++ windows-node/README.md | 2 +- .../adapters/origin.plot@v2/adapter.json | 2 +- windows-node/origin-worker/worker.py | 426 ++++++++++++++++-- 11 files changed, 1058 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 673dae7..bfed73b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,14 @@ ## Unreleased +- Origin 绘图新增面积图、堆叠面积图、极坐标图、饼图和堆叠条形图;堆叠类图型改用 Origin 原生多系列分组与累计机制,跨输入数据也会先校验并对齐横坐标。 + - Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口、zcbot 管理页和用户专业软件任务列表会显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。 +- Origin 绘图新增箱线图、直方图、堆叠柱形图、数据驱动尺寸的气泡图和带中心线的置信带图,覆盖重复试验分布、相组成占比、多变量关系及拟合不确定性展示。 + +- Origin 绘图支持 1–4 个二维面板组成复合图,可在同一面板混合折线、散点、线点、柱形和条形系列,分别使用左右 Y 轴,并为任意系列添加对称 X/Y 误差棒;原有单图请求继续兼容。 + - Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node,无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。 - Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。 diff --git a/DESIGN.md b/DESIGN.md index 138c7f3..ae619a9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -472,7 +472,9 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c 第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 1–16 个 artifact,并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input//`,同时校验大小与 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 Worker:Node 仅从管理员安装的固定 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 Worker:Node 仅从管理员安装的固定 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[]` 显式描述 1–4 个二维 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/`,再将完整目录原子移动到 `/origin//`。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。 diff --git a/PROGRESS.md b/PROGRESS.md index c9781f6..a6ad920 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 受管 runtime,Origin 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,以 1–4 个显式 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。 diff --git a/RUN.md b/RUN.md index 2bcaf62..c025e29 100644 --- a/RUN.md +++ b/RUN.md @@ -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`,支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`,Agent可在该目录内搜索并分析;正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\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` 数量为 1–4;两个 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 Schema;Worker 可以是受管 Python 脚本或独立 EXE。 两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动(实际版本、runtime、入口和契约文件名),`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。 diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index 715e4d8..f939058 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -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} + } + } + } } } } diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index 4fc9dd8..b159391 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -218,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]], diff --git a/tests/test_software_contracts.py b/tests/test_software_contracts.py index b6725b7..e5d04e8 100644 --- a/tests/test_software_contracts.py +++ b/tests/test_software_contracts.py @@ -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")) diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index f71b50d..9ed85ab 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -404,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 = { @@ -417,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, @@ -425,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): diff --git a/windows-node/README.md b/windows-node/README.md index 92bf77e..667a420 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -12,7 +12,7 @@ windows-node\package-windows-node.bat 产物位于 `windows-node\dist\zcbot-windows-node--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`:可组成 1–4 个二维 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\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要重新运行统一安装器。 diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index 707829e..3eed0cb 100644 --- a/windows-node/adapters/origin.plot@v2/adapter.json +++ b/windows-node/adapters/origin.plot@v2/adapter.json @@ -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", diff --git a/windows-node/origin-worker/worker.py b/windows-node/origin-worker/worker.py index c2b1d5d..ac5ed1d 100644 --- a/windows-node/origin-worker/worker.py +++ b/windows-node/origin-worker/worker.py @@ -15,6 +15,7 @@ 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 @@ -29,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, @@ -53,7 +71,7 @@ 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: @@ -183,6 +201,7 @@ 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"): @@ -208,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"] - series = plot["series"] + 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 {} - 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") - 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") + _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"{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"{name.upper()}_LOG_LIMIT_INVALID") def _atomic_json(path: Path, value: Any) -> None: @@ -348,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"]] @@ -372,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") @@ -381,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: @@ -404,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") @@ -429,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 @@ -436,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:]) ) @@ -480,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")) @@ -496,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 @@ -521,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] @@ -536,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")) - _apply_axis( - 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") - ) - for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True): - _apply_series_style(origin_plot, series_spec.get("style")) - _apply_legend(layer, plot_spec.get("legend")) - if plot_spec.get("title"): - 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 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") + ) + _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") + ) + for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True): + 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 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):