feat(origin): expand scientific chart types
This commit is contained in:
parent
6de88c7b0c
commit
d62b5e0766
|
|
@ -8,8 +8,14 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
- Origin 绘图新增面积图、堆叠面积图、极坐标图、饼图和堆叠条形图;堆叠类图型改用 Origin 原生多系列分组与累计机制,跨输入数据也会先校验并对齐横坐标。
|
||||||
|
|
||||||
- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口、zcbot 管理页和用户专业软件任务列表会显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。
|
- Windows Node 不再错误显示 Origin 运行状态中的中文;本机窗口、zcbot 管理页和用户专业软件任务列表会显示 Node、Adapter 及从本机程序读取的 Origin 软件版本。
|
||||||
|
|
||||||
|
- Origin 绘图新增箱线图、直方图、堆叠柱形图、数据驱动尺寸的气泡图和带中心线的置信带图,覆盖重复试验分布、相组成占比、多变量关系及拟合不确定性展示。
|
||||||
|
|
||||||
|
- Origin 绘图支持 1–4 个二维面板组成复合图,可在同一面板混合折线、散点、线点、柱形和条形系列,分别使用左右 Y 轴,并为任意系列添加对称 X/Y 误差棒;原有单图请求继续兼容。
|
||||||
|
|
||||||
- Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node,无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。
|
- Windows Node 的专业软件适配器现在可作为独立目录更新;后续扩展 Origin 绘图参数时,可只替换适配器并重启本机 Node,无需重装或替换 Node 程序,服务端契约更新也无需重启 zcbot。
|
||||||
|
|
||||||
- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。
|
- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。
|
||||||
|
|
|
||||||
|
|
@ -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/<key>/<filename>`,同时校验大小与 SHA-256;不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease,重连按 job、lease、digest 恢复下载或幂等补报终态,不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job;每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。
|
第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 1–16 个 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 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/`,再将完整目录原子移动到 `<working_dir>/origin/<job_id>/`。PNG/SVG/PDF/OPJU 等正式输出登记平台 artifact UUID 和 `software_job_id`,`.meta/` 只落真实文件;成功状态返回 task-relative `output_dir`,Agent 以该目录为起点按需搜索。重复 PUT、complete 和重连均按摘要幂等;部分上传不可见,只有完整集合才能发布。Origin 执行槽与上传确认是两个正交状态:本地已有终态且固定 Worker 已退出时即释放软件执行槽,成功但尚无 `upload-complete.json` 的任务继续后台补传;若云端已经是 succeeded,重复 PUT/complete 必须按数据库持久化 manifest 校验并直接确认,不得按新版本目录规则重新发布旧 Job。
|
第五阶段完成输出上传与发布:Node 只按固定 manifest ID 逐项流式 PUT,并携带 Node、lease、request digest 与内容摘要;云端重新绑定任务身份,不信 Node 提供的路径或媒体类型。文件先进入用户根下隐藏暂存区,固定文件名、单文件/总大小和 SHA-256 全部验证后,把 plot spec、provenance 整理进 `.meta/`,再将完整目录原子移动到 `<working_dir>/origin/<job_id>/`。PNG/SVG/PDF/OPJU 等正式输出登记平台 artifact UUID 和 `software_job_id`,`.meta/` 只落真实文件;成功状态返回 task-relative `output_dir`,Agent 以该目录为起点按需搜索。重复 PUT、complete 和重连均按摘要幂等;部分上传不可见,只有完整集合才能发布。Origin 执行槽与上传确认是两个正交状态:本地已有终态且固定 Worker 已退出时即释放软件执行槽,成功但尚无 `upload-complete.json` 的任务继续后台补传;若云端已经是 succeeded,重复 PUT/complete 必须按数据库持久化 manifest 校验并直接确认,不得按新版本目录规则重新发布旧 Job。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@
|
||||||
|
|
||||||
### 2026-08-14
|
### 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 / 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。
|
- **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
6
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\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
|
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒;停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 1–16 个输入、跨输入系列和多个显式输出;`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` 数量为 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。
|
专业软件契约位于 `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 交付目录,不维护第二份源码。
|
两个 JSON 的职责不同:`adapter.json` 只描述本机如何启动(实际版本、runtime、入口和契约文件名),`origin.plot.v2.json` 描述云端与本机共同遵守的业务请求/输出契约。前者不能替代后者;独立打包脚本只是把根目录的同一份业务契约复制进 adapter 交付目录,不维护第二份源码。
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,18 @@
|
||||||
"contour": "0.5.0",
|
"contour": "0.5.0",
|
||||||
"surface_3d": "0.5.0",
|
"surface_3d": "0.5.0",
|
||||||
"ternary": "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": {
|
"summary": {
|
||||||
"title_path": ["operation", "plot", "title"]
|
"title_path": ["operation", "plot", "title"]
|
||||||
|
|
@ -112,14 +123,16 @@
|
||||||
"properties": {
|
"properties": {
|
||||||
"plot": {
|
"plot": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": ["type", "series"],
|
"required": ["type"],
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
"type": {
|
"type": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"line", "scatter", "line_scatter", "column", "bar",
|
"line", "scatter", "line_scatter", "column", "bar",
|
||||||
"grouped_column", "y_error", "contour", "surface_3d",
|
"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": {
|
"series": {
|
||||||
|
|
@ -136,12 +149,32 @@
|
||||||
"x": {"type": "string", "minLength": 1, "maxLength": 128},
|
"x": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"y": {"type": "string", "minLength": 1, "maxLength": 128},
|
"y": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"z": {"type": "string", "minLength": 1, "maxLength": 128},
|
"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},
|
"y_error": {"type": "string", "minLength": 1, "maxLength": 128},
|
||||||
"label": {"type": "string", "minLength": 1, "maxLength": 200},
|
"label": {"type": "string", "minLength": 1, "maxLength": 200},
|
||||||
"style": {"$ref": "#/$defs/series_style"}
|
"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"},
|
"template": {"const": "publication_double_column"},
|
||||||
"title": {"type": "string", "maxLength": 500},
|
"title": {"type": "string", "maxLength": 500},
|
||||||
"title_style": {"$ref": "#/$defs/text_style"},
|
"title_style": {"$ref": "#/$defs/text_style"},
|
||||||
|
|
@ -171,6 +204,22 @@
|
||||||
"error_bars": {"type": "null"}
|
"error_bars": {"type": "null"}
|
||||||
},
|
},
|
||||||
"allOf": [
|
"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"]}}},
|
"if": {"properties": {"type": {"enum": ["contour", "surface_3d", "ternary", "heatmap"]}}},
|
||||||
"then": {
|
"then": {
|
||||||
|
|
@ -179,7 +228,10 @@
|
||||||
"maxItems": 1,
|
"maxItems": 1,
|
||||||
"items": {
|
"items": {
|
||||||
"required": ["input", "x", "y", "z"],
|
"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": {
|
"series": {
|
||||||
"items": {
|
"items": {
|
||||||
"required": ["input", "x", "y", "y_error"],
|
"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,
|
"minItems": 2,
|
||||||
"items": {
|
"items": {
|
||||||
"required": ["input", "x", "y"],
|
"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": {
|
"then": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"series": {
|
"series": {
|
||||||
"items": {
|
"items": {
|
||||||
"required": ["input", "x", "y"],
|
"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},
|
"symbol_size": {"type": "number", "minimum": 1, "maximum": 100},
|
||||||
"transparency": {"type": "integer", "minimum": 0, "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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -218,20 +218,334 @@ class OriginWorkerUnitTests(unittest.TestCase):
|
||||||
with self.assertRaisesRegex(ValueError, "INPUT_BINDINGS_MUST_BE_USED_EXACTLY"):
|
with self.assertRaisesRegex(ValueError, "INPUT_BINDINGS_MUST_BE_USED_EXACTLY"):
|
||||||
worker._validate_semantics(request)
|
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(
|
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",
|
"input": "sample", "x": "x", "y": "y", "z": "z",
|
||||||
"y_error": "sd", "label": "测量值",
|
"x_error": "x_sd", "y_error": "y_sd", "label": "测量值",
|
||||||
}],
|
}],
|
||||||
)
|
)
|
||||||
self.assertEqual(resolved, [{
|
self.assertEqual(resolved, [{
|
||||||
"input": "sample", "label": "测量值", "x": 0, "y": 1,
|
"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): "测量值"})
|
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:
|
def test_heatmap_matrix_accepts_complete_unordered_grid(self) -> None:
|
||||||
matrix, xy_map = worker._heatmap_matrix(
|
matrix, xy_map = worker._heatmap_matrix(
|
||||||
[[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]],
|
[[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]],
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,8 @@ from pathlib import Path
|
||||||
from shutil import copy2
|
from shutil import copy2
|
||||||
|
|
||||||
from core.software_contracts import (
|
from core.software_contracts import (
|
||||||
DEFAULT_CAPABILITIES,
|
|
||||||
CONTRACT_ROOT,
|
CONTRACT_ROOT,
|
||||||
|
DEFAULT_CAPABILITIES,
|
||||||
_ContractRegistry,
|
_ContractRegistry,
|
||||||
get_contract,
|
get_contract,
|
||||||
node_supports_request,
|
node_supports_request,
|
||||||
|
|
@ -66,6 +66,52 @@ class SoftwareContractTests(unittest.TestCase):
|
||||||
self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime))
|
self.assertTrue(node_supports_request(contract, _request("heatmap"), current_runtime))
|
||||||
self.assertFalse(node_supports_request(contract, _request("bar"), 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:
|
def test_semantic_version_comparison_is_numeric(self) -> None:
|
||||||
self.assertTrue(version_at_least("0.10.0", "0.4.0"))
|
self.assertTrue(version_at_least("0.10.0", "0.4.0"))
|
||||||
self.assertFalse(version_at_least("0.3.9", "0.4.0"))
|
self.assertFalse(version_at_least("0.3.9", "0.4.0"))
|
||||||
|
|
|
||||||
|
|
@ -404,6 +404,31 @@ class SoftwareJobProtocolTests(unittest.TestCase):
|
||||||
"surface_3d": [{"input": "sample", "x": "x", "y": "y", "z": "value"}],
|
"surface_3d": [{"input": "sample", "x": "x", "y": "y", "z": "value"}],
|
||||||
"ternary": [{"input": "sample", "x": "a", "y": "b", "z": "c"}],
|
"ternary": [{"input": "sample", "x": "a", "y": "b", "z": "c"}],
|
||||||
"heatmap": [{"input": "sample", "x": "x", "y": "y", "z": "value"}],
|
"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():
|
for plot_type, series in cases.items():
|
||||||
request = {
|
request = {
|
||||||
|
|
@ -417,6 +442,55 @@ class SoftwareJobProtocolTests(unittest.TestCase):
|
||||||
self.assertEqual(normalized, request)
|
self.assertEqual(normalized, request)
|
||||||
self.assertEqual(len(digest), 64)
|
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:
|
def test_origin_request_rejects_roles_that_do_not_match_plot_type(self) -> None:
|
||||||
base = {
|
base = {
|
||||||
"schema_version": 2,
|
"schema_version": 2,
|
||||||
|
|
@ -425,9 +499,18 @@ class SoftwareJobProtocolTests(unittest.TestCase):
|
||||||
}
|
}
|
||||||
invalid_plots = (
|
invalid_plots = (
|
||||||
{"type": "line", "series": [{"input": "sample", "x": "x", "y": "y", "z": "z"}]},
|
{"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": "y_error", "series": [{"input": "sample", "x": "x", "y": "y"}]},
|
||||||
{"type": "contour", "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": "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:
|
for plot in invalid_plots:
|
||||||
with self.subTest(plot_type=plot["type"]), self.assertRaises(SoftwareJobError):
|
with self.subTest(plot_type=plot["type"]), self.assertRaises(SoftwareJobError):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ windows-node\package-windows-node.bat
|
||||||
|
|
||||||
产物位于 `windows-node\dist\zcbot-windows-node-<version>-win-x64.zip`,旁边同时生成 SHA-256 文件。默认不包含 .NET Runtime,目标机器需预装 .NET 10 Desktop Runtime;需要免安装 .NET 的离线包时执行 `package-windows-node.bat --self-contained`。打包脚本是纯 BAT,使用 .NET SDK 以及 Windows 自带的 `tar.exe`、`certutil.exe`。
|
产物位于 `windows-node\dist\zcbot-windows-node-<version>-win-x64.zip`,旁边同时生成 SHA-256 文件。默认不包含 .NET Runtime,目标机器需预装 .NET 10 Desktop Runtime;需要免安装 .NET 的离线包时执行 `package-windows-node.bat --self-contained`。打包脚本是纯 BAT,使用 .NET SDK 以及 Windows 自带的 `tar.exe`、`certutil.exe`。
|
||||||
|
|
||||||
固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式,生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口;使用实际运行 Node 的专用 Windows 账号直接双击:
|
固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式。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\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要重新运行统一安装器。
|
日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要重新运行统一安装器。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"capability": "origin.plot@v2",
|
"capability": "origin.plot@v2",
|
||||||
"adapter_version": "0.5.0",
|
"adapter_version": "0.8.0",
|
||||||
"runtime": "python",
|
"runtime": "python",
|
||||||
"runtime_id": "origin",
|
"runtime_id": "origin",
|
||||||
"entrypoint": "worker.py",
|
"entrypoint": "worker.py",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import re
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
from itertools import pairwise
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -29,8 +30,25 @@ PLOT_CONFIG = {
|
||||||
"contour": ("TriContour", 243),
|
"contour": ("TriContour", 243),
|
||||||
"surface_3d": ("glCMAP", 103),
|
"surface_3d": ("glCMAP", 103),
|
||||||
"ternary": ("ternary", 245),
|
"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"}
|
XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"}
|
||||||
|
PANEL_TEMPLATES = {
|
||||||
|
(1, 2): "PAN2HORZ",
|
||||||
|
(2, 1): "PAN2VERT",
|
||||||
|
(2, 2): "PAN4",
|
||||||
|
}
|
||||||
FORMATS = {"opju", "png", "svg", "pdf"}
|
FORMATS = {"opju", "png", "svg", "pdf"}
|
||||||
LINE_STYLES = {
|
LINE_STYLES = {
|
||||||
"solid": 1,
|
"solid": 1,
|
||||||
|
|
@ -53,7 +71,7 @@ LEGEND_POSITIONS = {
|
||||||
"bottom_left": (700, 7200),
|
"bottom_left": (700, 7200),
|
||||||
"bottom_right": (6800, 7200),
|
"bottom_right": (6800, 7200),
|
||||||
}
|
}
|
||||||
ADAPTER_VERSION = "0.5.0"
|
ADAPTER_VERSION = "0.8.0"
|
||||||
|
|
||||||
|
|
||||||
def _server_executable(command: str) -> Path:
|
def _server_executable(command: str) -> Path:
|
||||||
|
|
@ -183,6 +201,7 @@ def _probe() -> int:
|
||||||
if sys.platform != "win32":
|
if sys.platform != "win32":
|
||||||
raise RuntimeError("Origin adapter requires Windows")
|
raise RuntimeError("Origin adapter requires Windows")
|
||||||
import winreg
|
import winreg
|
||||||
|
|
||||||
import originpro
|
import originpro
|
||||||
|
|
||||||
with winreg.OpenKey(winreg.HKEY_CLASSES_ROOT, r"Origin.ApplicationSI\CLSID"):
|
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"]}
|
input_keys = {item["key"] for item in request["inputs"]}
|
||||||
plot = request["operation"]["plot"]
|
plot = request["operation"]["plot"]
|
||||||
plot_type = plot["type"]
|
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}
|
used_inputs = {item["input"] for item in series}
|
||||||
if used_inputs != input_keys:
|
if used_inputs != input_keys:
|
||||||
raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY")
|
raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY")
|
||||||
|
if plot_type == "multi_panel":
|
||||||
|
return
|
||||||
if plot_type == "grouped_column" and len(series) < 2:
|
if plot_type == "grouped_column" and len(series) < 2:
|
||||||
raise ValueError("GROUPED_COLUMN_REQUIRES_MULTIPLE_SERIES")
|
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:
|
if plot_type in XYZ_PLOT_TYPES and len(series) != 1:
|
||||||
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
|
raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES")
|
||||||
required_roles = (
|
required_roles = (
|
||||||
("x", "y", "z") if plot_type in XYZ_PLOT_TYPES
|
("x", "y", "z") if plot_type in XYZ_PLOT_TYPES
|
||||||
else ("x", "y", "y_error") if plot_type == "y_error"
|
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")
|
else ("x", "y")
|
||||||
)
|
)
|
||||||
for item in series:
|
for item in series:
|
||||||
if any(role not in item for role in required_roles):
|
if any(role not in item for role in required_roles):
|
||||||
raise ValueError("SERIES_REQUIRED_ROLE_MISSING")
|
raise ValueError("SERIES_REQUIRED_ROLE_MISSING")
|
||||||
for axis_name in ("x_axis", "y_axis", "z_axis"):
|
for axis_name in ("x_axis", "y_axis", "z_axis"):
|
||||||
axis = plot.get(axis_name) or {}
|
_validate_axis(plot.get(axis_name), axis_name)
|
||||||
minimum = axis.get("minimum")
|
|
||||||
maximum = axis.get("maximum")
|
|
||||||
if minimum is not None and maximum is not None and minimum >= maximum:
|
def _validate_axis(value: Any, name: str) -> None:
|
||||||
raise ValueError(f"{axis_name.upper()}_LIMITS_INVALID")
|
axis = value if isinstance(value, dict) else {}
|
||||||
if axis.get("scale") in {"log10", "ln", "log2"} and (
|
minimum = axis.get("minimum")
|
||||||
minimum is not None and minimum <= 0 or maximum is not None and maximum <= 0
|
maximum = axis.get("maximum")
|
||||||
):
|
if minimum is not None and maximum is not None and minimum >= maximum:
|
||||||
raise ValueError(f"{axis_name.upper()}_LOG_LIMIT_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"{name.upper()}_LOG_LIMIT_INVALID")
|
||||||
|
|
||||||
|
|
||||||
def _atomic_json(path: Path, value: Any) -> None:
|
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:
|
if "tick_label_font_size" in settings:
|
||||||
layer.set_float(f"{name}.label.pt", float(settings["tick_label_font_size"]))
|
layer.set_float(f"{name}.label.pt", float(settings["tick_label_font_size"]))
|
||||||
if "title_font_size" in settings:
|
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"]))
|
layer.label(title_object).set_int("fsize", round(settings["title_font_size"]))
|
||||||
if "grid" in settings:
|
if "grid" in settings:
|
||||||
grid_value = {"none": 0, "major": 1, "major_minor": 3}[settings["grid"]]
|
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"])
|
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):
|
if not isinstance(legend, dict):
|
||||||
return
|
return
|
||||||
label = layer.label("Legend")
|
label = layer.label("Legend")
|
||||||
|
|
@ -381,8 +440,26 @@ def _apply_legend(layer: Any, legend: Any) -> None:
|
||||||
label.set_int("fsize", round(legend["font_size"]))
|
label.set_int("fsize", round(legend["font_size"]))
|
||||||
if "position" in legend:
|
if "position" in legend:
|
||||||
left, top = LEGEND_POSITIONS[legend["position"]]
|
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("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:
|
def _input_file(job_dir: Path, key: str) -> Path:
|
||||||
|
|
@ -404,7 +481,9 @@ def _resolve_series(
|
||||||
headers, _ = input_data[input_key]
|
headers, _ = input_data[input_key]
|
||||||
role_indexes = {
|
role_indexes = {
|
||||||
role: _column_index(headers, series[role], role)
|
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"]
|
y_index = role_indexes["y"]
|
||||||
label = series.get("label")
|
label = series.get("label")
|
||||||
|
|
@ -429,6 +508,37 @@ def _number(value: Any, role: str) -> float:
|
||||||
return number
|
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:
|
def _is_evenly_spaced(values: list[float]) -> bool:
|
||||||
if len(values) <= 2:
|
if len(values) <= 2:
|
||||||
return True
|
return True
|
||||||
|
|
@ -436,7 +546,7 @@ def _is_evenly_spaced(values: list[float]) -> bool:
|
||||||
tolerance = max(abs(step) * 1e-9, 1e-12)
|
tolerance = max(abs(step) * 1e-9, 1e-12)
|
||||||
return all(
|
return all(
|
||||||
math.isclose(current - previous, step, rel_tol=1e-9, abs_tol=tolerance)
|
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")
|
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]]:
|
def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
job_dir = job_dir.resolve(strict=True)
|
job_dir = job_dir.resolve(strict=True)
|
||||||
request_record = json.loads((job_dir / "request" / "request.json").read_text(encoding="utf-8"))
|
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_spec = request["operation"]["plot"]
|
||||||
plot_type = plot_spec["type"]
|
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")
|
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)
|
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
|
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],
|
[row[index] if index < len(row) else None for row in rows],
|
||||||
lname=column_label,
|
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
|
import numpy as np
|
||||||
|
|
||||||
resolved = resolved_series[0]
|
resolved = resolved_series[0]
|
||||||
|
|
@ -536,43 +858,67 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
|
||||||
graph = op.new_graph(template="heatmap")
|
graph = op.new_graph(template="heatmap")
|
||||||
layer = graph[0]
|
layer = graph[0]
|
||||||
origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)]
|
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:
|
else:
|
||||||
template, origin_plot_type = PLOT_CONFIG[plot_type]
|
template, origin_plot_type = PLOT_CONFIG[plot_type]
|
||||||
graph = op.new_graph(template=template)
|
graph = op.new_graph(template=template)
|
||||||
layer = graph[0]
|
layer = graph[0]
|
||||||
origin_plots = []
|
origin_plots = []
|
||||||
for resolved in resolved_series:
|
for series_spec, resolved in zip(
|
||||||
|
series_specs, resolved_series, strict=True
|
||||||
|
):
|
||||||
arguments = {
|
arguments = {
|
||||||
"coly": resolved["y"],
|
"coly": resolved["y"],
|
||||||
"colx": resolved["x"],
|
|
||||||
"type": origin_plot_type,
|
"type": origin_plot_type,
|
||||||
}
|
}
|
||||||
|
if "x" in resolved:
|
||||||
|
arguments["colx"] = resolved["x"]
|
||||||
if plot_type in XYZ_PLOT_TYPES:
|
if plot_type in XYZ_PLOT_TYPES:
|
||||||
arguments["colz"] = resolved["z"]
|
arguments["colz"] = resolved["z"]
|
||||||
if plot_type == "y_error":
|
if plot_type == "y_error":
|
||||||
arguments["colyerr"] = resolved["y_error"]
|
arguments["colyerr"] = resolved["y_error"]
|
||||||
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":
|
if plot_type == "grouped_column":
|
||||||
layer.group()
|
layer.group()
|
||||||
layer.rescale()
|
|
||||||
_apply_canvas(graph, plot_spec.get("canvas"))
|
_apply_canvas(graph, plot_spec.get("canvas"))
|
||||||
_apply_axis(
|
if plot_type != "multi_panel":
|
||||||
layer, "x", plot_spec.get("x_axis"), str(series_specs[0].get("x") or "X")
|
layer.rescale()
|
||||||
)
|
if plot_type not in {"polar", "pie"}:
|
||||||
_apply_axis(layer, "y", plot_spec.get("y_axis"), "Y")
|
_apply_axis(
|
||||||
if plot_type == "surface_3d":
|
layer, "x", plot_spec.get("x_axis"),
|
||||||
_apply_axis(
|
str(series_specs[0].get("x") or "X")
|
||||||
layer, "z", plot_spec.get("z_axis"), str(series_specs[0].get("z") or "Z")
|
)
|
||||||
)
|
_apply_axis(layer, "y", plot_spec.get("y_axis"), "Y")
|
||||||
for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
|
if plot_type == "surface_3d":
|
||||||
_apply_series_style(origin_plot, series_spec.get("style"))
|
_apply_axis(
|
||||||
_apply_legend(layer, plot_spec.get("legend"))
|
layer, "z", plot_spec.get("z_axis"),
|
||||||
if plot_spec.get("title"):
|
str(series_specs[0].get("z") or "Z")
|
||||||
title = layer.add_label(str(plot_spec["title"]))
|
)
|
||||||
title_font_size = (plot_spec.get("title_style") or {}).get("font_size", 18)
|
for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True):
|
||||||
title.set_int("fsize", round(title_font_size))
|
style = series_spec.get("style")
|
||||||
title.set_int("left", 2200)
|
if plot_type == "bubble" and style:
|
||||||
title.set_int("top", 120)
|
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"]
|
requested_outputs = request["outputs"]
|
||||||
formats = [item["format"] for item in requested_outputs]
|
formats = [item["format"] for item in requested_outputs]
|
||||||
if any(item not in FORMATS for item in formats):
|
if any(item not in FORMATS for item in formats):
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue