feat(software): add declarative recipe revisions

This commit is contained in:
caoqianming 2026-08-14 17:50:12 +08:00
parent 27f7a2fe30
commit 4426ef9214
13 changed files with 696 additions and 69 deletions

View File

@ -8,6 +8,10 @@
## Unreleased ## Unreleased
- Origin 绘图新增声明式 Recipe可组合 14 个二维面板、左右坐标轴、误差棒和逐系列样式对已有专业软件任务不满意时Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
- 修复 Origin 二维复合图中多条系列被错误套用同一种红色三角样式、标题和图例缺失或重叠的问题;颜色、线型、点型、误差棒、坐标轴和图例现在会按请求分别呈现。
- Windows Node 接收专业软件任务时不再因适配器契约数据被提前释放而离线,已注册节点可持续接收并校验新任务。 - Windows Node 接收专业软件任务时不再因适配器契约数据被提前释放而离线,已注册节点可持续接收并校验新任务。
- Origin 绘图新增面积图、堆叠面积图、极坐标图、饼图和堆叠条形图;堆叠类图型改用 Origin 原生多系列分组与累计机制,跨输入数据也会先校验并对齐横坐标。 - Origin 绘图新增面积图、堆叠面积图、极坐标图、饼图和堆叠条形图;堆叠类图型改用 Origin 原生多系列分组与累计机制,跨输入数据也会先校验并对齐横坐标。

View File

@ -472,7 +472,11 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c
第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 116 个 artifact并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input/<key>/<filename>`,同时校验大小与 SHA-256不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease重连按 job、lease、digest 恢复下载或幂等补报终态不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。 第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 116 个 artifact并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input/<key>/<filename>`,同时校验大小与 SHA-256不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease重连按 job、lease、digest 恢复下载或幂等补报终态不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。
第四阶段落地固定 Origin WorkerNode 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,旧单图 `series[]``x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,并以可选的 `canvas`、轴排版、图例、标题和 series style 统一表达出版级尺寸与样式。新增复合图不继续枚举 `dual_axis_line` 等组合类型,而使用 `multi_panel` feature`panels[]` 显式描述 14 个二维 panel每条系列用 `kind` 选择基础二维图型、用 `y_axis` 绑定左右轴,并可提供对称 `x_error/y_error``layout` 只允许 1×1、1×2、2×1、2×2 四种受控布局。Worker 使用 Origin 内置 panel 模板及 right-Y 图层不接受模板名Origin 不支持同一 3D 图窗多图层,因此 contour、surface、ternary 和 heatmap 不混入本阶段 panel。旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin避免无法证明的双执行。 第四阶段落地固定 Origin WorkerNode 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,旧单图 `series[]``x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,并以可选的 `canvas`、轴排版、图例、标题和 series style 统一表达出版级尺寸与样式。新增复合图不继续枚举 `dual_axis_line` 等组合类型,而使用 `multi_panel` feature`panels[]` 显式描述 14 个二维 panel每条系列用 `kind` 选择基础二维图型、用 `y_axis` 绑定左右轴,并可提供对称 `x_error/y_error``layout` 只允许 1×1、1×2、2×1、2×2 四种受控布局。Worker 从固定基础二维图创建受控原生图层网格及 right-Y 图层,不依赖带隐式主题分组的 panel 模板也不接受请求传入模板名Origin 不支持同一 3D 图窗多图层,因此 contour、surface、ternary 和 heatmap 不混入本阶段 panel。旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin避免无法证明的双执行。
Recipe 是专业软件的声明式目标状态,不是任意脚本或逐次鼠标命令。`origin.plot@v2` 以新增 `plot.type=recipe + recipe_version=1` 复用已验证的 `layout/panels/series/axis/legend` 组合结构;旧 `multi_panel` 与 Recipe 进入同一个受控执行器,单图和特殊统计图继续保留兼容入口。模型可以组合契约允许的原子图形能力,但不能提供 Python、LabTalk、解释器、模板名或文件路径。其他软件可采用同一通用 Job 外壳和各自的声明式 Recipe不建设跨软件万能 DSLAPI/SDK executor 优先,未来的 UI Automation 或 Computer Use 仅作为 adapter 内部执行后端,不改变云端 Recipe。
产物修改第一阶段采用不可变重新生成:`software_job_status(job_id)` 对当前 user/task 返回规范化 `editable_request``software_job_revise(source_job_id, operation, outputs)` 在服务端复用源 Job 已登记的 inputs并重新经过当前 capability 契约、artifact 权限和调度校验创建新 Job。它不打开旧 OPJU、不覆盖旧产物、不接受模型重新指定输入也暂不增加版本树 migration若以后出现必须保留用户在 GUI 内手工编辑的真实需求,再单独设计工程副本、稳定对象 ID 和增量执行。
科研统计图继续以独立 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避免暴露看似可配但不能稳定复现的参数。 科研统计图继续以独立 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避免暴露看似可配但不能稳定复现的参数。

View File

@ -22,6 +22,10 @@
### 2026-08-14 ### 2026-08-14
- **08-14 / Unreleased / Origin 声明式 Recipe 与产物重做**adapter 0.9.0 在 `origin.plot@v2` 中新增 `type=recipe + recipe_version=1`,复用已真机验证的 14 panel、左右轴、误差棒和系列样式声明结构及同一受控执行器不开放 Python、LabTalk、模板或路径。新增通用 `software_job_revise`,按当前 user/task 复用源 Job 的 artifact inputs以完整新 operation/outputs 重新校验并创建新 Job单 Job 状态返回 `editable_request`,旧 Job 与产物保持不变,无 migration、无 OPJU 增量编辑。专项 94 项 unittest、Python 编译、Ruff 致命规则、diff 检查、工具 schema、Windows Node build 与独立 adapter 打包通过;全量 634 项仍仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过(另跳过 4 项。Recipe 执行路径复用本日已通过 Origin 2024 真机核对的组合执行器,未连接或写入生产数据库。
- **08-14 / Unreleased / Origin 二维复合图真机修复**adapter 提升至 0.8.1`multi_panel` 改用固定基础二维图加受控原生图层网格,避开 Origin panel 模板的隐式分组主题;同步修正系列符号 ID、误差棒样式、顶层轴/图例默认值继承、实际 plot 索引图例及页像素标题定位。Origin 2024 的 2×2 四系列、Y 误差棒、中文标题与图例 OPJU/PNG/SVG 真机冒烟及视觉核对通过,相关 50 项 unittest、Python 编译、Ruff 致命规则和 diff 检查通过,未连接或写入数据库。
- **08-14 / Unreleased / Origin 常用二维图第三批**adapter 0.8.0 新增 area、stacked_area、polar、pie、stacked_bar所有堆叠图将跨输入系列复制到受控连续 XYY 工作表,先校验横坐标完全一致,再建立 plot group、激活目标图层并应用固定累计命令。已安装项目锁定的 Python 3.12 受管 runtimeOrigin 2024 / originpro 1.1.15 probe 为 ready堆叠柱、堆叠面积、极坐标、饼图和堆叠条形图的 OPJU/PNG 真机冒烟及视觉核对通过,其中堆叠柱总高为 15/18/17/22。相关 80 项 unittest 通过,未连接或写入数据库。 - **08-14 / Unreleased / Origin 常用二维图第三批**adapter 0.8.0 新增 area、stacked_area、polar、pie、stacked_bar所有堆叠图将跨输入系列复制到受控连续 XYY 工作表,先校验横坐标完全一致,再建立 plot group、激活目标图层并应用固定累计命令。已安装项目锁定的 Python 3.12 受管 runtimeOrigin 2024 / originpro 1.1.15 probe 为 ready堆叠柱、堆叠面积、极坐标、饼图和堆叠条形图的 OPJU/PNG 真机冒烟及视觉核对通过,其中堆叠柱总高为 15/18/17/22。相关 80 项 unittest 通过,未连接或写入数据库。
- **08-14 / Unreleased / Origin 科研统计图第二批**adapter 0.7.0 新增 box、histogram、stacked_column、bubble、band 五个 feature箱线/直方图直接消费原始 Y 列,气泡以正数 size 列驱动符号尺寸,置信带以显式 center/lower/upper 绘制填充边界并拒绝倒置区间。堆叠柱经 Origin 2024 真机检查发现单独设置属性会退化为并排/覆盖,已在 0.8.0 改为原生 XYY 分组累计链路。高级箱线百分位和直方图分箱参数暂沿用 Origin 默认值,待真机验证稳定属性后再开放。 - **08-14 / Unreleased / Origin 科研统计图第二批**adapter 0.7.0 新增 box、histogram、stacked_column、bubble、band 五个 feature箱线/直方图直接消费原始 Y 列,气泡以正数 size 列驱动符号尺寸,置信带以显式 center/lower/upper 绘制填充边界并拒绝倒置区间。堆叠柱经 Origin 2024 真机检查发现单独设置属性会退化为并排/覆盖,已在 0.8.0 改为原生 XYY 分组累计链路。高级箱线百分位和直方图分箱参数暂沿用 Origin 默认值,待真机验证稳定属性后再开放。

6
RUN.md
View File

@ -1100,9 +1100,11 @@ install-windows-node.bat
若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,优先设置机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `adapters/origin.plot@v2/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin/<job_id>/`plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 若 Python 未加入 PATH可把绝对路径作为第一个参数例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,优先设置机器级 `ZCBOT_ADAPTER_ORIGIN_PYTHON`;旧名 `ZCBOT_ORIGIN_PYTHON` 暂时兼容。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `adapters/origin.plot@v2/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入及 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin/<job_id>/`plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。
Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒停止已派发任务是协作取消状态先显示“正在停止”Node 在线时立即接收断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status` 和 `software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 116 个输入、跨输入系列和多个显式输出;`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_revise``software_job_cancel`。Origin 输入必须是 artifact已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 116 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`Agent可在该目录内搜索并分析正式输出的 artifact 带 `software_job_id`供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\<job-id>\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。
二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2`panels` 数量为 14两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind``line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列panel 可独立设置 `x_axis/y_axis/right_y_axis`、标题、标签和图例。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名也不支持把等高线、3D 曲面、三元图或热图混入 panel。 二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2`panels` 数量为 14两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind``line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列;顶层 `x_axis/y_axis/legend` 作为全部 panel 的默认值panel 内同名设置可单独覆盖,`right_y_axis` 仍按 panel 设置。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名也不支持把等高线、3D 曲面、三元图或热图混入 panel。
声明式绘图使用 `plot.type=recipe`、`recipe_version=1`,第一版有意复用上述 `layout/panels` 结构和限制,作为新的组合入口而不是开放脚本。对已有结果继续调整时,先用 `software_job_status(job_id)` 取得 `editable_request`,在其中形成完整的新 `operation``outputs`,再调用 `software_job_revise`;服务端自动复用源 Job 的 inputs重新校验并生成新 Job。旧 Job、OPJU 和图片不会被覆盖,当前也不会保留用户下载后在 Origin GUI 中做的手工修改。
科研统计图使用独立单图 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。`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 版本完成真机验证后再增量开放。

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import json import json
import re import re
from copy import deepcopy
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID, uuid4 from uuid import UUID, uuid4
@ -344,6 +345,61 @@ def get_job(user_id: UUID, job_id: UUID) -> dict | None:
return _job_dict(row) if row else None return _job_dict(row) if row else None
def get_job_request(user_id: UUID, task_id: UUID, job_id: UUID) -> dict | None:
"""返回当前用户和任务内可供 Agent 修改的规范化请求。"""
with session_scope() as session:
request = session.execute(
select(SoftwareJob.request).where(
SoftwareJob.job_id == job_id,
SoftwareJob.user_id == user_id,
SoftwareJob.task_id == task_id,
)
).scalar_one_or_none()
if request is None:
return None
return deepcopy(request)
def revise_job(
user_id: UUID,
task_id: UUID,
source_job_id: UUID,
*,
idempotency_key: str,
operation: dict,
outputs: list[dict],
) -> tuple[dict, bool]:
"""复用源任务已登记输入,创建不可变的新任务。"""
with session_scope() as session:
source = session.execute(
select(SoftwareJob).where(
SoftwareJob.job_id == source_job_id,
SoftwareJob.user_id == user_id,
SoftwareJob.task_id == task_id,
)
).scalar_one_or_none()
if source is None:
raise SoftwareJobError("source software job not found")
capability = source.capability
source_request = source.request
inputs = deepcopy(source_request.get("inputs") or [])
schema_version = get_contract(capability).request_schema[
"properties"
]["schema_version"]["const"]
return create_job(
user_id,
task_id,
idempotency_key=idempotency_key,
capability=capability,
request={
"schema_version": schema_version,
"inputs": inputs,
"operation": operation,
"outputs": outputs,
},
)
def offer_next_job(node_ids: set[UUID]) -> dict | None: def offer_next_job(node_ids: set[UUID]) -> dict | None:
"""选择最早可执行的 JobNode 组合,避免跨 capability 队首阻塞。""" """选择最早可执行的 JobNode 组合,避免跨 capability 队首阻塞。"""
if not node_ids: if not node_ids:

View File

@ -51,6 +51,7 @@ from tools.schedule import (
from tools.software_jobs import ( from tools.software_jobs import (
SoftwareCapabilityListTool, SoftwareCapabilityListTool,
SoftwareJobCancelTool, SoftwareJobCancelTool,
SoftwareJobReviseTool,
SoftwareJobStatusTool, SoftwareJobStatusTool,
SoftwareJobSubmitTool, SoftwareJobSubmitTool,
) )
@ -224,6 +225,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]:
return [ return [
SoftwareCapabilityListTool(ctx.uid, ctx.task_id, **base), SoftwareCapabilityListTool(ctx.uid, ctx.task_id, **base),
SoftwareJobSubmitTool(ctx.uid, ctx.task_id, **base), SoftwareJobSubmitTool(ctx.uid, ctx.task_id, **base),
SoftwareJobReviseTool(ctx.uid, ctx.task_id, **base),
SoftwareJobStatusTool(ctx.uid, ctx.task_id, **base), SoftwareJobStatusTool(ctx.uid, ctx.task_id, **base),
SoftwareJobCancelTool(ctx.uid, ctx.task_id, **base), SoftwareJobCancelTool(ctx.uid, ctx.task_id, **base),
] ]

View File

@ -66,7 +66,8 @@
"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", "multi_panel": "0.8.1",
"recipe": "0.9.0",
"box": "0.7.0", "box": "0.7.0",
"histogram": "0.7.0", "histogram": "0.7.0",
"stacked_column": "0.7.0", "stacked_column": "0.7.0",
@ -130,7 +131,7 @@
"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", "multi_panel", "box", "histogram", "ternary", "heatmap", "multi_panel", "recipe", "box", "histogram",
"stacked_column", "bubble", "band", "area", "stacked_column", "bubble", "band", "area",
"stacked_area", "polar", "pie", "stacked_bar" "stacked_area", "polar", "pie", "stacked_bar"
] ]
@ -175,6 +176,7 @@
"maxItems": 4, "maxItems": 4,
"items": {"$ref": "#/$defs/panel"} "items": {"$ref": "#/$defs/panel"}
}, },
"recipe_version": {"const": 1},
"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"},
@ -205,7 +207,7 @@
}, },
"allOf": [ "allOf": [
{ {
"if": {"properties": {"type": {"const": "multi_panel"}}}, "if": {"properties": {"type": {"enum": ["multi_panel", "recipe"]}}},
"then": { "then": {
"required": ["layout", "panels"], "required": ["layout", "panels"],
"not": {"required": ["series"]} "not": {"required": ["series"]}
@ -220,6 +222,11 @@
} }
} }
}, },
{
"if": {"properties": {"type": {"const": "recipe"}}},
"then": {"required": ["recipe_version"]},
"else": {"not": {"required": ["recipe_version"]}}
},
{ {
"if": {"properties": {"type": {"enum": ["contour", "surface_3d", "ternary", "heatmap"]}}}, "if": {"properties": {"type": {"enum": ["contour", "surface_3d", "ternary", "heatmap"]}}},
"then": { "then": {

View File

@ -163,10 +163,23 @@ class OriginWorkerUnitTests(unittest.TestCase):
}) })
self.assertEqual(plot.color, (51, 102, 204)) self.assertEqual(plot.color, (51, 102, 204))
self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4}) self.assertEqual(plot.values, {"line.width": 1.5, "line.type": 4})
self.assertEqual(plot.symbol_kind, 3) self.assertEqual(plot.symbol_kind, 5)
self.assertEqual(plot.symbol_size, 8) self.assertEqual(plot.symbol_size, 8)
self.assertEqual(plot.transparency, 20) self.assertEqual(plot.transparency, 20)
def test_origin_symbol_ids_match_labtalk_values(self) -> None:
self.assertEqual(
worker.SYMBOLS,
{
"square": 1,
"circle": 2,
"triangle_up": 3,
"diamond": 5,
"plus": 6,
"cross": 7,
},
)
def test_keyed_input_directory_requires_exactly_one_file(self) -> None: def test_keyed_input_directory_requires_exactly_one_file(self) -> None:
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
root = Path(directory) root = Path(directory)
@ -269,6 +282,59 @@ class OriginWorkerUnitTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "SHARED_X_AXIS_CONFIG_MISMATCH"): with self.assertRaisesRegex(ValueError, "SHARED_X_AXIS_CONFIG_MISMATCH"):
worker._validate_semantics(request) worker._validate_semantics(request)
request["operation"]["plot"]["panels"][0].pop("x_axis")
request["operation"]["plot"]["x_axis"] = {
"scale": "log10", "minimum": 0,
}
with self.assertRaisesRegex(ValueError, "X_AXIS_LOG_LIMIT_INVALID"):
worker._validate_semantics(request)
def test_recipe_uses_the_composition_semantics(self) -> None:
request = {
"inputs": [{"key": "sample"}],
"operation": {"plot": {
"type": "recipe",
"recipe_version": 1,
"layout": {"rows": 1, "columns": 1},
"panels": [{
"key": "main",
"series": [{
"input": "sample",
"x": "age",
"y": "strength",
"kind": "line_scatter",
}],
}],
}},
"outputs": [{"key": "figure_png", "format": "png"}],
}
worker._validate_semantics(request)
self.assertIn("recipe", worker.COMPOSITION_PLOT_TYPES)
def test_panel_settings_inherit_plot_defaults_and_allow_overrides(self) -> None:
plot = {
"x_axis": {"title": "Age", "unit": "d"},
"y_axis": {"title": "Value"},
"legend": {"enabled": False, "font_size": 8},
}
panel = {
"y_axis": {"title": "Strength", "unit": "MPa"},
"legend": {"enabled": True},
}
self.assertEqual(
worker._panel_setting(plot, panel, "x_axis"),
{"title": "Age", "unit": "d"},
)
self.assertEqual(
worker._panel_setting(plot, panel, "y_axis"),
{"title": "Strength", "unit": "MPa"},
)
self.assertEqual(worker._panel_legend(plot, panel), {
"enabled": True,
"position": "top_right",
"font_size": 8,
})
def test_mixed_xy_series_pass_kind_axis_and_error_columns_to_origin(self) -> None: def test_mixed_xy_series_pass_kind_axis_and_error_columns_to_origin(self) -> None:
class FakePlot: class FakePlot:
pass pass
@ -277,13 +343,23 @@ class OriginWorkerUnitTests(unittest.TestCase):
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
self.grouped = False self.grouped = False
self.plots = []
def add_plot(self, worksheet, **arguments): def add_plot(self, worksheet, **arguments):
self.calls.append((worksheet, arguments)) self.calls.append((worksheet, arguments))
return FakePlot() plot = FakePlot()
self.plots.append(plot)
if "colxerr" in arguments:
self.plots.append(FakePlot())
if "colyerr" in arguments:
self.plots.append(FakePlot())
return plot
def group(self): def plot_list(self):
self.grouped = True return self.plots
def group(self, enabled=True):
self.grouped = enabled
layer = FakeLayer() layer = FakeLayer()
series = [ series = [
@ -546,6 +622,106 @@ class OriginWorkerUnitTests(unittest.TestCase):
self.assertEqual(layer.name, "Legend") self.assertEqual(layer.name, "Legend")
self.assertEqual(layer.legend.text, r"\l(1) Phase A" "\n" r"\l(2) phase_b") self.assertEqual(layer.legend.text, r"\l(1) Phase A" "\n" r"\l(2) phase_b")
def test_multi_panel_legend_uses_actual_data_plot_indices(self) -> None:
class Label:
text = ""
class Layer:
legend = Label()
def label(self, _name):
return self.legend
class Plot:
def __init__(self, value):
self.value = value
def index(self):
return self.value
layer = Layer()
worker._replace_series_legend(
layer,
[
{"y": "a", "label": "5 C"},
{"y": "b", "label": "20 C"},
],
[Plot(0), Plot(2)],
)
self.assertEqual(
layer.legend.text,
r"\l(1) 5 C" "\n" r"\l(3) 20 C",
)
def test_panel_title_and_legend_use_page_pixel_coordinates(self) -> None:
class Label:
def __init__(self):
self.values = {}
self.show = None
def set_int(self, name, value):
self.values[name] = value
def set_float(self, name, value):
self.values[name] = value
class Layer:
def __init__(self):
self.legend = Label()
self.title = None
def label(self, _name):
return self.legend
def add_label(self, _text):
self.title = Label()
return self.title
@staticmethod
def get_float(name):
return {
"left": 10,
"top": 20,
"width": 40,
"height": 30,
}[name]
class Graph:
@staticmethod
def get_float(name):
return {
"width": 1800,
"height": 1450,
"resx": 254,
"resy": 254,
}[name]
layer = Layer()
graph = Graph()
worker._apply_legend(
layer,
{"enabled": True, "position": "top_right"},
attach_to_layer=True,
graph=graph,
geometry=(10, 20, 40, 30),
)
worker._apply_panel_title(
graph,
layer,
(10, 20, 40, 30),
"Strength",
{"font_size": 12},
)
self.assertEqual(layer.legend.values["attach"], 1)
self.assertEqual(layer.legend.values["background"], 0)
self.assertEqual(layer.legend.values["left"], 655)
self.assertEqual(layer.legend.values["top"], 307)
self.assertEqual(layer.title.values["attach"], 1)
self.assertEqual(layer.title.values["background"], 0)
self.assertEqual(layer.title.values["fsize"], 12)
self.assertEqual(layer.title.values["left"], 382)
self.assertEqual(layer.title.values["top"], 229)
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]],

View File

@ -83,8 +83,33 @@ class SoftwareContractTests(unittest.TestCase):
"adapter_version": "0.6.0", "adapter_version": "0.6.0",
"features": ["multi_panel"], "features": ["multi_panel"],
}) })
self.assertFalse(node_supports_request(contract, multi_panel, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.8.1",
})
self.assertTrue(node_supports_request(contract, multi_panel, current_runtime)) self.assertTrue(node_supports_request(contract, multi_panel, current_runtime))
recipe = json.loads(json.dumps(multi_panel))
recipe["operation"]["plot"].update({
"type": "recipe",
"recipe_version": 1,
})
normalized, _ = contract.normalize_request(recipe)
self.assertEqual(normalized, recipe)
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.8.1",
"features": ["recipe"],
})
self.assertFalse(node_supports_request(contract, recipe, current_runtime))
current_runtime["capability_runtime"]["origin.plot@v2"].update({
"adapter_version": "0.9.0",
})
self.assertTrue(node_supports_request(contract, recipe, current_runtime))
recipe["operation"]["plot"].pop("recipe_version")
with self.assertRaises(ValueError):
contract.normalize_request(recipe)
bubble = _request("line") bubble = _request("line")
bubble["operation"]["plot"] = { bubble["operation"]["plot"] = {
"type": "bubble", "type": "bubble",

View File

@ -2,12 +2,16 @@ from __future__ import annotations
import json import json
import unittest import unittest
from contextlib import contextmanager
from types import SimpleNamespace
from unittest.mock import patch from unittest.mock import patch
from uuid import uuid4 from uuid import uuid4
from core.software_jobs import revise_job
from tools.software_jobs import ( from tools.software_jobs import (
SoftwareCapabilityListTool, SoftwareCapabilityListTool,
SoftwareJobCancelTool, SoftwareJobCancelTool,
SoftwareJobReviseTool,
SoftwareJobStatusTool, SoftwareJobStatusTool,
SoftwareJobSubmitTool, SoftwareJobSubmitTool,
) )
@ -105,6 +109,105 @@ class SoftwareJobToolTests(unittest.TestCase):
self.assertIn("not found", status) self.assertIn("not found", status)
self.assertIn("not found", cancel) self.assertIn("not found", cancel)
def test_status_includes_editable_request_for_revision(self):
job_id = uuid4()
current = {"job_id": str(job_id), "task_id": str(self.task_id)}
editable = {
"schema_version": 2,
"inputs": [{"key": "sample", "artifact_id": str(uuid4())}],
"operation": {"plot": {"type": "line"}},
"outputs": [{"key": "figure_png", "format": "png"}],
}
with (
patch("tools.software_jobs.get_job", return_value=current),
patch("tools.software_jobs.get_job_request", return_value=editable),
):
result = json.loads(
SoftwareJobStatusTool(self.user_id, self.task_id).execute(str(job_id))
)
self.assertEqual(result["editable_request"], editable)
def test_revise_reuses_source_job_through_user_scoped_service(self):
source_job_id = uuid4()
revised = {"job_id": str(uuid4()), "status": "queued"}
operation = {"plot": {
"type": "recipe",
"recipe_version": 1,
"layout": {"rows": 1, "columns": 1},
"panels": [],
}}
outputs = [{"key": "figure_png", "type": "figure", "format": "png"}]
with patch(
"tools.software_jobs.revise_job", return_value=(revised, True)
) as revise:
result = json.loads(SoftwareJobReviseTool(
self.user_id, self.task_id
).execute(
str(source_job_id),
operation=operation,
outputs=outputs,
idempotency_key="revision-1",
))
self.assertTrue(result["created"])
revise.assert_called_once_with(
self.user_id,
self.task_id,
source_job_id,
idempotency_key="revision-1",
operation=operation,
outputs=outputs,
)
def test_revise_service_copies_inputs_and_revalidates_as_new_job(self):
source_job_id = uuid4()
source = SimpleNamespace(
capability="origin.plot@v2",
request={
"schema_version": 2,
"inputs": [{"key": "sample", "artifact_id": str(uuid4())}],
"operation": {"plot": {"type": "line"}},
"outputs": [{"key": "figure_png", "format": "png"}],
},
)
class Result:
@staticmethod
def scalar_one_or_none():
return source
class Session:
@staticmethod
def execute(_statement):
return Result()
@contextmanager
def fake_session_scope():
yield Session()
operation = {"plot": {"type": "scatter"}}
outputs = [{"key": "figure_png", "format": "png"}]
created = {"job_id": str(uuid4())}
with (
patch("core.software_jobs.session_scope", fake_session_scope),
patch("core.software_jobs.create_job", return_value=(created, True)) as create,
):
result = revise_job(
self.user_id,
self.task_id,
source_job_id,
idempotency_key="revision-service-1",
operation=operation,
outputs=outputs,
)
self.assertEqual(result, (created, True))
self.assertEqual(create.call_args.kwargs["capability"], "origin.plot@v2")
self.assertEqual(create.call_args.kwargs["request"], {
"schema_version": 2,
"inputs": source.request["inputs"],
"operation": operation,
"outputs": outputs,
})
def test_cancel_uses_user_scoped_service(self): def test_cancel_uses_user_scoped_service(self):
job_id = uuid4() job_id = uuid4()
current = {"job_id": str(job_id), "task_id": str(self.task_id)} current = {"job_id": str(job_id), "task_id": str(self.task_id)}

View File

@ -14,8 +14,10 @@ from core.software_jobs import (
SoftwareJobError, SoftwareJobError,
create_job, create_job,
get_job, get_job,
get_job_request,
list_jobs, list_jobs,
request_job_cancel, request_job_cancel,
revise_job,
) )
from core.software_nodes import list_nodes from core.software_nodes import list_nodes
@ -158,6 +160,9 @@ class SoftwareJobStatusTool(_SoftwareJobTool):
item = get_job(self.user_id, UUID(job_id.strip())) item = get_job(self.user_id, UUID(job_id.strip()))
if item is None or item["task_id"] != str(self.task_id): if item is None or item["task_id"] != str(self.task_id):
return "[Error] software job not found" return "[Error] software job not found"
item["editable_request"] = get_job_request(
self.user_id, self.task_id, UUID(job_id.strip())
)
return json.dumps(item, ensure_ascii=False) return json.dumps(item, ensure_ascii=False)
return json.dumps( return json.dumps(
{"results": list_jobs(self.user_id, task_id=self.task_id, limit=20)}, {"results": list_jobs(self.user_id, task_id=self.task_id, limit=20)},
@ -167,6 +172,65 @@ class SoftwareJobStatusTool(_SoftwareJobTool):
return "[Error] invalid job_id" return "[Error] invalid job_id"
class SoftwareJobReviseTool(_SoftwareJobTool):
name = "software_job_revise"
description = (
"Create a new professional-software job from a prior job in the current task. "
"Reuse the prior registered inputs, provide a complete replacement operation and "
"outputs, and leave the prior job and artifacts unchanged. Call software_job_status "
"first when the prior editable_request is not already in context."
)
@staticmethod
def _parameters() -> dict:
return {
"type": "object",
"properties": {
"source_job_id": {"type": "string", "format": "uuid"},
"operation": _contract_property_schema("operation"),
"outputs": _contract_property_schema("outputs"),
"idempotency_key": {
"type": "string",
"description": (
"Stable unique key for this exact revision; omit to generate one."
),
},
},
"required": ["source_job_id", "operation", "outputs"],
"additionalProperties": False,
}
parameters = _parameters()
@property
def schema(self) -> dict:
value = super().schema
value["function"]["parameters"] = self._parameters()
return value
def execute(
self,
source_job_id: str,
operation: dict | None = None,
outputs: list[dict] | None = None,
idempotency_key: str = "",
) -> str:
try:
if operation is None or not outputs:
return "[Error] operation and outputs are required for a software revision"
job, created = revise_job(
self.user_id,
self.task_id,
UUID(source_job_id.strip()),
idempotency_key=idempotency_key.strip() or str(uuid4()),
operation=operation,
outputs=outputs,
)
return json.dumps({**job, "created": created}, ensure_ascii=False)
except (SoftwareJobError, ValueError) as exc:
return f"[Error] {exc}"
class SoftwareJobCancelTool(_SoftwareJobTool): class SoftwareJobCancelTool(_SoftwareJobTool):
name = "software_job_cancel" name = "software_job_cancel"
description = "Request cancellation of a software job in the current task after the user asks to stop it." description = "Request cancellation of a software job in the current task after the user asks to stop it."

View File

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

View File

@ -42,13 +42,9 @@ PLOT_CONFIG = {
"stacked_bar": ("bar", 216), "stacked_bar": ("bar", 216),
} }
STACKED_PLOT_TYPES = {"stacked_column", "stacked_area", "stacked_bar"} STACKED_PLOT_TYPES = {"stacked_column", "stacked_area", "stacked_bar"}
COMPOSITION_PLOT_TYPES = {"multi_panel", "recipe"}
_CUMULATIVE_STACK_COMMAND = "layer -b s 1" _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,
@ -58,12 +54,12 @@ LINE_STYLES = {
"dash_dot_dot": 5, "dash_dot_dot": 5,
} }
SYMBOLS = { SYMBOLS = {
"square": 0, "square": 1,
"circle": 1, "circle": 2,
"triangle_up": 2, "triangle_up": 3,
"diamond": 3, "diamond": 5,
"cross": 9, "cross": 7,
"plus": 10, "plus": 6,
} }
LEGEND_POSITIONS = { LEGEND_POSITIONS = {
"top_left": (700, 500), "top_left": (700, 500),
@ -71,7 +67,18 @@ LEGEND_POSITIONS = {
"bottom_left": (700, 7200), "bottom_left": (700, 7200),
"bottom_right": (6800, 7200), "bottom_right": (6800, 7200),
} }
ADAPTER_VERSION = "0.8.0" PANEL_GEOMETRY = {
(1, 1): ((13, 14, 74, 72),),
(1, 2): ((9, 14, 38, 72), (58, 14, 38, 72)),
(2, 1): ((13, 14, 74, 31), (13, 57, 74, 31)),
(2, 2): (
(9, 14, 38, 31),
(58, 14, 38, 31),
(9, 57, 38, 31),
(58, 57, 38, 31),
),
}
ADAPTER_VERSION = "0.9.0"
def _server_executable(command: str) -> Path: def _server_executable(command: str) -> Path:
@ -227,7 +234,7 @@ 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"]
if plot_type == "multi_panel": if plot_type in COMPOSITION_PLOT_TYPES:
panels = plot["panels"] panels = plot["panels"]
layout = plot["layout"] layout = plot["layout"]
grid = (layout["rows"], layout["columns"]) grid = (layout["rows"], layout["columns"])
@ -250,9 +257,14 @@ def _validate_semantics(request: dict[str, Any]) -> None:
raise ValueError("PANEL_LEFT_AXIS_REQUIRES_SERIES") raise ValueError("PANEL_LEFT_AXIS_REQUIRES_SERIES")
for axis_name in ("x_axis", "y_axis", "right_y_axis"): for axis_name in ("x_axis", "y_axis", "right_y_axis"):
_validate_axis(panel.get(axis_name), axis_name) _validate_axis(panel.get(axis_name), axis_name)
for axis_name in ("x_axis", "y_axis"):
_validate_axis(plot.get(axis_name), axis_name)
for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")): for axis_name, share_name in (("x_axis", "share_x"), ("y_axis", "share_y")):
if layout.get(share_name): if layout.get(share_name):
settings = [panel.get(axis_name) or {} for panel in panels] settings = [
_panel_setting(plot, panel, axis_name) or {}
for panel in panels
]
if any(value != settings[0] for value in settings[1:]): if any(value != settings[0] for value in settings[1:]):
raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH") raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH")
else: else:
@ -260,7 +272,7 @@ def _validate_semantics(request: dict[str, Any]) -> None:
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": if plot_type in COMPOSITION_PLOT_TYPES:
return 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")
@ -431,37 +443,157 @@ 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, vertical_offset: int = 0) -> None: def _apply_error_style(origin_plot: Any, style: Any) -> None:
if not isinstance(style, dict):
return
if "color" in style:
origin_plot.color = _hex_color(style["color"])
if "line_width" in style:
origin_plot.set_float("line.width", float(style["line_width"]))
if "transparency" in style:
origin_plot.transparency = int(style["transparency"])
def _apply_legend(
layer: Any,
legend: Any,
vertical_offset: int = 0,
*,
attach_to_layer: bool = False,
graph: Any = None,
geometry: tuple[int, int, int, int] | None = None,
) -> None:
if not isinstance(legend, dict): if not isinstance(legend, dict):
return return
label = layer.label("Legend") label = _legend_label(layer)
label.set_int("background", 0)
if attach_to_layer:
label.set_int("attach", 0)
label.show = legend.get("enabled", True) label.show = legend.get("enabled", True)
if "font_size" in legend: if "font_size" in legend:
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"]] if attach_to_layer:
if vertical_offset and legend["position"].startswith("bottom"): x_fraction, y_fraction = {
vertical_offset = -vertical_offset "top_left": (0.03, 0.04),
label.set_int("left", left) "top_right": (0.66, 0.04),
label.set_int("top", top + vertical_offset) "bottom_left": (0.03, 0.68),
"bottom_right": (0.66, 0.68),
}[legend["position"]]
if vertical_offset:
y_fraction += 0.22 if legend["position"].startswith("top") else -0.22
left, top = _panel_page_pixel(
graph, geometry, x_fraction, y_fraction
)
label.set_int("attach", 1)
label.set_int("left", round(left))
label.set_int("top", round(top))
else:
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 + vertical_offset)
def _replace_series_legend(layer: Any, series_specs: list[dict[str, Any]]) -> None: def _replace_series_legend(
layer.label("Legend").text = "\n".join( layer: Any,
f"\\l({index}) {item.get('label') or item['y']}" series_specs: list[dict[str, Any]],
for index, item in enumerate(series_specs, start=1) origin_plots: list[Any] | None = None,
) -> None:
plot_numbers = (
[plot.index() + 1 for plot in origin_plots]
if origin_plots is not None
else list(range(1, len(series_specs) + 1))
) )
_legend_label(layer).text = "\n".join(
f"\\l({plot_number}) {item.get('label') or item['y']}"
for plot_number, item in zip(plot_numbers, series_specs, strict=True)
)
def _legend_label(layer: Any) -> Any:
label = layer.label("Legend")
if label is None:
label = layer.add_label("")
label.name = "Legend"
return label
def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None: def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None:
if not value: if not value:
return return
title = layer.add_label(str(value)) title = layer.add_label(str(value))
title.set_int("background", 0)
title.set_int("fsize", round((style or {}).get("font_size", 18))) title.set_int("fsize", round((style or {}).get("font_size", 18)))
title.set_int("left", 2200) title.set_int("left", 2200)
title.set_int("top", top) title.set_int("top", top)
def _panel_page_pixel(
graph: Any,
geometry: tuple[int, int, int, int] | None,
x_fraction: float,
y_fraction: float,
) -> tuple[float, float]:
if geometry is None:
raise ValueError("PANEL_GEOMETRY_REQUIRED")
page_width = graph.get_float("width")
page_height = graph.get_float("height")
left, top, width, height = geometry
return (
page_width * (left + width * x_fraction) / 100,
page_height * (top + height * y_fraction) / 100,
)
def _apply_panel_title(
graph: Any,
layer: Any,
geometry: tuple[int, int, int, int],
value: Any,
style: Any,
) -> None:
if not value:
return
title = layer.add_label(str(value))
title.set_int("background", 0)
title.set_int("attach", 1)
title.set_int("fsize", round((style or {}).get("font_size", 12)))
left, top = _panel_page_pixel(graph, geometry, 0.28, -0.14)
title.set_int("left", round(left))
title.set_int("top", round(top))
def _apply_page_title(graph: Any, layer: Any, value: Any, style: Any) -> None:
if not value:
return
title = layer.add_label(str(value))
title.set_int("background", 0)
title.set_int("attach", 1)
title.set_int("fsize", round((style or {}).get("font_size", 18)))
title.set_int("left", round(graph.get_float("width") * 0.32))
title.set_int("top", round(graph.get_float("height") * 0.01))
def _panel_setting(
plot_spec: dict[str, Any], panel: dict[str, Any], name: str
) -> Any:
value = panel.get(name)
return value if value is not None else plot_spec.get(name)
def _panel_legend(
plot_spec: dict[str, Any], panel: dict[str, Any]
) -> dict[str, Any]:
return {
"enabled": True,
"position": "top_right",
**(plot_spec.get("legend") or {}),
**(panel.get("legend") or {}),
}
def _input_file(job_dir: Path, key: str) -> Path: def _input_file(job_dir: Path, key: str) -> Path:
directory = job_dir / "input" / key directory = job_dir / "input" / key
files = [path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")] files = [path for path in directory.iterdir() if path.is_file() and not path.name.startswith(".")]
@ -597,6 +729,7 @@ def _add_xy_plots(
resolved_series: list[dict[str, Any]], resolved_series: list[dict[str, Any]],
) -> list[Any]: ) -> list[Any]:
plots = [] plots = []
error_plots: list[list[Any]] = []
for series_spec, resolved in zip(series_specs, resolved_series, strict=True): for series_spec, resolved in zip(series_specs, resolved_series, strict=True):
kind = series_spec["kind"] kind = series_spec["kind"]
arguments = { arguments = {
@ -608,11 +741,22 @@ def _add_xy_plots(
arguments["colxerr"] = resolved["x_error"] arguments["colxerr"] = resolved["x_error"]
if "y_error" in resolved: if "y_error" in resolved:
arguments["colyerr"] = resolved["y_error"] arguments["colyerr"] = resolved["y_error"]
plot_count = len(layer.plot_list())
plot = layer.add_plot(worksheets[resolved["input"]], **arguments) plot = layer.add_plot(worksheets[resolved["input"]], **arguments)
_apply_series_style(plot, series_spec.get("style"))
plots.append(plot) plots.append(plot)
error_plots.append(list(layer.plot_list())[plot_count + 1:])
if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs): if len(plots) > 1 and all(item["kind"] == "column" for item in series_specs):
layer.group() layer.group()
else:
# Explicit series styles require independent plots.
layer.group(False)
for plot, errors, series_spec in zip(
plots, error_plots, series_specs, strict=True
):
style = series_spec.get("style")
_apply_series_style(plot, style)
for error_plot in errors:
_apply_error_style(error_plot, style)
return plots return plots
@ -676,7 +820,7 @@ def _share_axis_limits(layers: list[Any], name: str) -> None:
getattr(layer, f"set_{name}lim")(begin, end) getattr(layer, f"set_{name}lim")(begin, end)
def _build_multi_panel_graph( def _build_recipe_graph(
op: Any, op: Any,
plot_spec: dict[str, Any], plot_spec: dict[str, Any],
worksheets: dict[str, Any], worksheets: dict[str, Any],
@ -685,17 +829,24 @@ def _build_multi_panel_graph(
panels = plot_spec["panels"] panels = plot_spec["panels"]
layout = plot_spec["layout"] layout = plot_spec["layout"]
grid = (layout["rows"], layout["columns"]) grid = (layout["rows"], layout["columns"])
if len(panels) == 1: graph = op.new_graph(template="line")
template = PLOT_CONFIG[panels[0]["series"][0]["kind"]][0] while len(graph) < len(panels):
else: graph.add_layer(0)
template = PANEL_TEMPLATES[grid] _apply_canvas(graph, plot_spec.get("canvas"))
graph = op.new_graph(template=template)
primary_layers = list(graph)[:len(panels)] primary_layers = list(graph)[:len(panels)]
for unused_layer in list(graph)[len(panels):]: for layer, (left, top, width, height) in zip(
unused_layer.show = False primary_layers, PANEL_GEOMETRY[grid], strict=True
):
layer.set_int("unit", 1)
layer.set_float("left", left)
layer.set_float("top", top)
layer.set_float("width", width)
layer.set_float("height", height)
resolved_offset = 0 resolved_offset = 0
for index, (panel, layer) in enumerate(zip(panels, primary_layers, strict=True)): for panel, layer, geometry in zip(
panels, primary_layers, PANEL_GEOMETRY[grid], strict=True
):
panel_series = panel["series"] panel_series = panel["series"]
resolved_panel = resolved_series[ resolved_panel = resolved_series[
resolved_offset:resolved_offset + len(panel_series) resolved_offset:resolved_offset + len(panel_series)
@ -713,41 +864,70 @@ def _build_multi_panel_graph(
] ]
left_specs = [item[0] for item in left_pairs] left_specs = [item[0] for item in left_pairs]
left_resolved = [item[1] for item in left_pairs] left_resolved = [item[1] for item in left_pairs]
_add_xy_plots(layer, worksheets, left_specs, left_resolved) left_plots = _add_xy_plots(layer, worksheets, left_specs, left_resolved)
layer.rescale() layer.rescale()
_apply_axis(layer, "x", panel.get("x_axis"), str(left_specs[0]["x"])) _apply_axis(
_apply_axis(layer, "y", panel.get("y_axis"), "Y") layer, "x", _panel_setting(plot_spec, panel, "x_axis"),
legend = {"enabled": True, "position": "top_right", **(panel.get("legend") or {})} str(left_specs[0]["x"]),
_apply_legend(layer, legend) )
_apply_title(layer, panel.get("title"), panel.get("title_style")) _apply_axis(
layer, "y", _panel_setting(plot_spec, panel, "y_axis"), "Y"
)
legend = _panel_legend(plot_spec, panel)
_replace_series_legend(layer, left_specs, left_plots)
_apply_legend(
layer,
legend,
attach_to_layer=True,
graph=graph,
geometry=geometry,
)
_apply_panel_title(
graph,
layer,
geometry,
panel.get("title"),
panel.get("title_style"),
)
if panel.get("panel_label"): if panel.get("panel_label"):
panel_label = layer.add_label(str(panel["panel_label"])) panel_label = layer.add_label(str(panel["panel_label"]))
panel_label.set_int("background", 0)
panel_label.set_int("attach", 1)
panel_label.set_int("fsize", round((panel.get("title_style") or {}).get("font_size", 12))) panel_label.set_int("fsize", round((panel.get("title_style") or {}).get("font_size", 12)))
panel_label.set_int("left", 120) panel_left, panel_top = _panel_page_pixel(graph, geometry, 0.02, -0.14)
panel_label.set_int("top", 120) panel_label.set_float("left", round(panel_left))
panel_label.set_float("top", round(panel_top))
if right_pairs: if right_pairs:
layer.activate() layer.activate()
right_layer = graph.add_layer(2) right_layer = graph.add_layer(2)
right_specs = [item[0] for item in right_pairs] right_specs = [item[0] for item in right_pairs]
right_resolved = [item[1] 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_plots = _add_xy_plots(
right_layer, worksheets, right_specs, right_resolved
)
right_layer.rescale() right_layer.rescale()
_apply_axis(right_layer, "y2", panel.get("right_y_axis"), "Right Y") _apply_axis(right_layer, "y2", panel.get("right_y_axis"), "Right Y")
_apply_legend(right_layer, legend, vertical_offset=700) _replace_series_legend(right_layer, right_specs, right_plots)
_apply_legend(
if index == 0: right_layer,
_apply_title( legend,
layer, vertical_offset=700,
plot_spec.get("title"), attach_to_layer=True,
plot_spec.get("title_style"), graph=graph,
top=-360, geometry=geometry,
) )
if layout.get("share_x") and len(primary_layers) > 1: if layout.get("share_x") and len(primary_layers) > 1:
_share_axis_limits(primary_layers, "x") _share_axis_limits(primary_layers, "x")
if layout.get("share_y") and len(primary_layers) > 1: if layout.get("share_y") and len(primary_layers) > 1:
_share_axis_limits(primary_layers, "y") _share_axis_limits(primary_layers, "y")
_apply_page_title(
graph,
primary_layers[0],
plot_spec.get("title"),
plot_spec.get("title_style"),
)
return graph return graph
@ -799,11 +979,11 @@ 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 not in {"heatmap", "multi_panel"}: if plot_type not in PLOT_CONFIG and plot_type not in {"heatmap", *COMPOSITION_PLOT_TYPES}:
raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED") raise ValueError("PLOT_TYPE_NOT_IMPLEMENTED")
series_specs = ( series_specs = (
[item for panel in plot_spec["panels"] for item in panel["series"]] [item for panel in plot_spec["panels"] for item in panel["series"]]
if plot_type == "multi_panel" if plot_type in COMPOSITION_PLOT_TYPES
else plot_spec["series"] else plot_spec["series"]
) )
resolved_series, labels = _resolve_series(input_data, series_specs) resolved_series, labels = _resolve_series(input_data, series_specs)
@ -833,8 +1013,8 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
lname=column_label, lname=column_label,
) )
band_legend = None band_legend = None
if plot_type == "multi_panel": if plot_type in COMPOSITION_PLOT_TYPES:
graph = _build_multi_panel_graph( graph = _build_recipe_graph(
op, plot_spec, worksheets, resolved_series op, plot_spec, worksheets, resolved_series
) )
layer = graph[0] layer = graph[0]
@ -895,7 +1075,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]:
if plot_type == "grouped_column": if plot_type == "grouped_column":
layer.group() layer.group()
_apply_canvas(graph, plot_spec.get("canvas")) _apply_canvas(graph, plot_spec.get("canvas"))
if plot_type != "multi_panel": if plot_type not in COMPOSITION_PLOT_TYPES:
layer.rescale() layer.rescale()
if plot_type not in {"polar", "pie"}: if plot_type not in {"polar", "pie"}:
_apply_axis( _apply_axis(