From 4426ef9214601d28e2b30c2224bb723e9466e703 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Fri, 14 Aug 2026 17:50:12 +0800 Subject: [PATCH] feat(software): add declarative recipe revisions --- CHANGELOG.md | 4 + DESIGN.md | 6 +- PROGRESS.md | 4 + RUN.md | 6 +- core/software_jobs.py | 56 ++++ core/tool_registry.py | 2 + software-contracts/origin.plot.v2.json | 13 +- tests/test_origin_worker.py | 184 ++++++++++- tests/test_software_contracts.py | 25 ++ tests/test_software_job_tools.py | 103 ++++++ tools/software_jobs.py | 64 ++++ .../adapters/origin.plot@v2/adapter.json | 2 +- windows-node/origin-worker/worker.py | 296 ++++++++++++++---- 13 files changed, 696 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52e2a71..14b592a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ## Unreleased +- Origin 绘图新增声明式 Recipe,可组合 1–4 个二维面板、左右坐标轴、误差棒和逐系列样式;对已有专业软件任务不满意时,Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。 + +- 修复 Origin 二维复合图中多条系列被错误套用同一种红色三角样式、标题和图例缺失或重叠的问题;颜色、线型、点型、误差棒、坐标轴和图例现在会按请求分别呈现。 + - Windows Node 接收专业软件任务时不再因适配器契约数据被提前释放而离线,已注册节点可持续接收并校验新任务。 - Origin 绘图新增面积图、堆叠面积图、极坐标图、饼图和堆叠条形图;堆叠类图型改用 Origin 原生多系列分组与累计机制,跨输入数据也会先校验并对齐横坐标。 diff --git a/DESIGN.md b/DESIGN.md index ae619a9..42a8f89 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -472,7 +472,11 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c 第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 1–16 个 artifact,并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input//`,同时校验大小与 SHA-256;不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease,重连按 job、lease、digest 恢复下载或幂等补报终态,不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job;每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。 -第四阶段落地固定 Origin Worker:Node 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录;请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance,校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,旧单图 `series[]` 以 `x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,并以可选的 `canvas`、轴排版、图例、标题和 series style 统一表达出版级尺寸与样式。新增复合图不继续枚举 `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,避免无法证明的双执行。 +第四阶段落地固定 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 从固定基础二维图创建受控原生图层网格及 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,不建设跨软件万能 DSL;API/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,避免暴露看似可配但不能稳定复现的参数。 diff --git a/PROGRESS.md b/PROGRESS.md index a6ad920..8be0d22 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -22,6 +22,10 @@ ### 2026-08-14 +- **08-14 / Unreleased / Origin 声明式 Recipe 与产物重做**:adapter 0.9.0 在 `origin.plot@v2` 中新增 `type=recipe + recipe_version=1`,复用已真机验证的 1–4 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 受管 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 默认值,待真机验证稳定属性后再开放。 diff --git a/RUN.md b/RUN.md index c025e29..4456ddc 100644 --- a/RUN.md +++ b/RUN.md @@ -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//`,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`,支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`,Agent可在该目录内搜索并分析;正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 +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`,支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`,Agent可在该目录内搜索并分析;正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 -二维复合图使用 `plot.type=multi_panel`。`layout` 支持 1×1、1×2、2×1 和 2×2,`panels` 数量为 1–4;两个 panel 必须选择横排或竖排,三个和四个 panel 使用 2×2。每个 panel 的 `series[]` 必须显式给出 `kind`(`line/scatter/line_scatter/column/bar`),可用 `y_axis=right` 绑定右 Y 轴,并用 `x_error`、`y_error` 指定对称误差列;panel 可独立设置 `x_axis/y_axis/right_y_axis`、标题、标签和图例。一个请求最多仍为 16 条系列,且每个 panel 至少有一条左轴系列。`share_x/share_y` 会统一各主图层自动缩放后的范围。复合图不接受 Origin 模板名,也不支持把等高线、3D 曲面、三元图或热图混入 panel。 +二维复合图使用 `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` 指定对称误差列;顶层 `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 版本完成真机验证后再增量开放。 diff --git a/core/software_jobs.py b/core/software_jobs.py index 212231e..0836d27 100644 --- a/core/software_jobs.py +++ b/core/software_jobs.py @@ -4,6 +4,7 @@ from __future__ import annotations import json import re +from copy import deepcopy from datetime import datetime, timedelta, timezone 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 +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: """选择最早可执行的 Job–Node 组合,避免跨 capability 队首阻塞。""" if not node_ids: diff --git a/core/tool_registry.py b/core/tool_registry.py index 05f51c1..d68c8b8 100644 --- a/core/tool_registry.py +++ b/core/tool_registry.py @@ -51,6 +51,7 @@ from tools.schedule import ( from tools.software_jobs import ( SoftwareCapabilityListTool, SoftwareJobCancelTool, + SoftwareJobReviseTool, SoftwareJobStatusTool, SoftwareJobSubmitTool, ) @@ -224,6 +225,7 @@ def build_tools(ctx: ToolContext) -> dict[str, Any]: return [ SoftwareCapabilityListTool(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), SoftwareJobCancelTool(ctx.uid, ctx.task_id, **base), ] diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index f939058..13c6e4f 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -66,7 +66,8 @@ "surface_3d": "0.5.0", "ternary": "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", "histogram": "0.7.0", "stacked_column": "0.7.0", @@ -130,7 +131,7 @@ "enum": [ "line", "scatter", "line_scatter", "column", "bar", "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_area", "polar", "pie", "stacked_bar" ] @@ -175,6 +176,7 @@ "maxItems": 4, "items": {"$ref": "#/$defs/panel"} }, + "recipe_version": {"const": 1}, "template": {"const": "publication_double_column"}, "title": {"type": "string", "maxLength": 500}, "title_style": {"$ref": "#/$defs/text_style"}, @@ -205,7 +207,7 @@ }, "allOf": [ { - "if": {"properties": {"type": {"const": "multi_panel"}}}, + "if": {"properties": {"type": {"enum": ["multi_panel", "recipe"]}}}, "then": { "required": ["layout", "panels"], "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"]}}}, "then": { diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index b159391..b6b9dc0 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -163,10 +163,23 @@ class OriginWorkerUnitTests(unittest.TestCase): }) self.assertEqual(plot.color, (51, 102, 204)) 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.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: with tempfile.TemporaryDirectory() as directory: root = Path(directory) @@ -269,6 +282,59 @@ class OriginWorkerUnitTests(unittest.TestCase): with self.assertRaisesRegex(ValueError, "SHARED_X_AXIS_CONFIG_MISMATCH"): 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: class FakePlot: pass @@ -277,13 +343,23 @@ class OriginWorkerUnitTests(unittest.TestCase): def __init__(self): self.calls = [] self.grouped = False + self.plots = [] def add_plot(self, 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): - self.grouped = True + def plot_list(self): + return self.plots + + def group(self, enabled=True): + self.grouped = enabled layer = FakeLayer() series = [ @@ -546,6 +622,106 @@ class OriginWorkerUnitTests(unittest.TestCase): self.assertEqual(layer.name, "Legend") 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: matrix, xy_map = worker._heatmap_matrix( [[1, 20, 4], [0, 10, 1], [1, 10, 2], [0, 20, 3]], diff --git a/tests/test_software_contracts.py b/tests/test_software_contracts.py index e5d04e8..1600d9a 100644 --- a/tests/test_software_contracts.py +++ b/tests/test_software_contracts.py @@ -83,8 +83,33 @@ class SoftwareContractTests(unittest.TestCase): "adapter_version": "0.6.0", "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)) + 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["operation"]["plot"] = { "type": "bubble", diff --git a/tests/test_software_job_tools.py b/tests/test_software_job_tools.py index a42aa59..00fe752 100644 --- a/tests/test_software_job_tools.py +++ b/tests/test_software_job_tools.py @@ -2,12 +2,16 @@ from __future__ import annotations import json import unittest +from contextlib import contextmanager +from types import SimpleNamespace from unittest.mock import patch from uuid import uuid4 +from core.software_jobs import revise_job from tools.software_jobs import ( SoftwareCapabilityListTool, SoftwareJobCancelTool, + SoftwareJobReviseTool, SoftwareJobStatusTool, SoftwareJobSubmitTool, ) @@ -105,6 +109,105 @@ class SoftwareJobToolTests(unittest.TestCase): self.assertIn("not found", status) 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): job_id = uuid4() current = {"job_id": str(job_id), "task_id": str(self.task_id)} diff --git a/tools/software_jobs.py b/tools/software_jobs.py index 0f16050..5c50ce1 100644 --- a/tools/software_jobs.py +++ b/tools/software_jobs.py @@ -14,8 +14,10 @@ from core.software_jobs import ( SoftwareJobError, create_job, get_job, + get_job_request, list_jobs, request_job_cancel, + revise_job, ) from core.software_nodes import list_nodes @@ -158,6 +160,9 @@ class SoftwareJobStatusTool(_SoftwareJobTool): item = get_job(self.user_id, UUID(job_id.strip())) if item is None or item["task_id"] != str(self.task_id): 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( {"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" +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): name = "software_job_cancel" description = "Request cancellation of a software job in the current task after the user asks to stop it." diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index 3eed0cb..db532ef 100644 --- a/windows-node/adapters/origin.plot@v2/adapter.json +++ b/windows-node/adapters/origin.plot@v2/adapter.json @@ -1,6 +1,6 @@ { "capability": "origin.plot@v2", - "adapter_version": "0.8.0", + "adapter_version": "0.9.0", "runtime": "python", "runtime_id": "origin", "entrypoint": "worker.py", diff --git a/windows-node/origin-worker/worker.py b/windows-node/origin-worker/worker.py index ac5ed1d..9763768 100644 --- a/windows-node/origin-worker/worker.py +++ b/windows-node/origin-worker/worker.py @@ -42,13 +42,9 @@ PLOT_CONFIG = { "stacked_bar": ("bar", 216), } STACKED_PLOT_TYPES = {"stacked_column", "stacked_area", "stacked_bar"} +COMPOSITION_PLOT_TYPES = {"multi_panel", "recipe"} _CUMULATIVE_STACK_COMMAND = "layer -b s 1" XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"} -PANEL_TEMPLATES = { - (1, 2): "PAN2HORZ", - (2, 1): "PAN2VERT", - (2, 2): "PAN4", -} FORMATS = {"opju", "png", "svg", "pdf"} LINE_STYLES = { "solid": 1, @@ -58,12 +54,12 @@ LINE_STYLES = { "dash_dot_dot": 5, } SYMBOLS = { - "square": 0, - "circle": 1, - "triangle_up": 2, - "diamond": 3, - "cross": 9, - "plus": 10, + "square": 1, + "circle": 2, + "triangle_up": 3, + "diamond": 5, + "cross": 7, + "plus": 6, } LEGEND_POSITIONS = { "top_left": (700, 500), @@ -71,7 +67,18 @@ LEGEND_POSITIONS = { "bottom_left": (700, 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: @@ -227,7 +234,7 @@ def _validate_semantics(request: dict[str, Any]) -> None: input_keys = {item["key"] for item in request["inputs"]} plot = request["operation"]["plot"] plot_type = plot["type"] - if plot_type == "multi_panel": + if plot_type in COMPOSITION_PLOT_TYPES: panels = plot["panels"] layout = plot["layout"] grid = (layout["rows"], layout["columns"]) @@ -250,9 +257,14 @@ def _validate_semantics(request: dict[str, Any]) -> None: 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 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")): 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:]): raise ValueError(f"SHARED_{axis_name.upper()}_CONFIG_MISMATCH") else: @@ -260,7 +272,7 @@ def _validate_semantics(request: dict[str, Any]) -> None: used_inputs = {item["input"] for item in series} if used_inputs != input_keys: raise ValueError("INPUT_BINDINGS_MUST_BE_USED_EXACTLY") - if plot_type == "multi_panel": + if plot_type in COMPOSITION_PLOT_TYPES: return if plot_type == "grouped_column" and len(series) < 2: 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"]) -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): 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) if "font_size" in legend: label.set_int("fsize", round(legend["font_size"])) if "position" in legend: - left, top = LEGEND_POSITIONS[legend["position"]] - if vertical_offset and legend["position"].startswith("bottom"): - vertical_offset = -vertical_offset - label.set_int("left", left) - label.set_int("top", top + vertical_offset) + if attach_to_layer: + x_fraction, y_fraction = { + "top_left": (0.03, 0.04), + "top_right": (0.66, 0.04), + "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: - 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 _replace_series_legend( + layer: Any, + series_specs: list[dict[str, Any]], + 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: if not value: return 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("left", 2200) 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: directory = job_dir / "input" / key 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]], ) -> list[Any]: plots = [] + error_plots: list[list[Any]] = [] for series_spec, resolved in zip(series_specs, resolved_series, strict=True): kind = series_spec["kind"] arguments = { @@ -608,11 +741,22 @@ def _add_xy_plots( arguments["colxerr"] = resolved["x_error"] if "y_error" in resolved: arguments["colyerr"] = resolved["y_error"] + plot_count = len(layer.plot_list()) plot = layer.add_plot(worksheets[resolved["input"]], **arguments) - _apply_series_style(plot, series_spec.get("style")) 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): 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 @@ -676,7 +820,7 @@ def _share_axis_limits(layers: list[Any], name: str) -> None: getattr(layer, f"set_{name}lim")(begin, end) -def _build_multi_panel_graph( +def _build_recipe_graph( op: Any, plot_spec: dict[str, Any], worksheets: dict[str, Any], @@ -685,17 +829,24 @@ def _build_multi_panel_graph( 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) + graph = op.new_graph(template="line") + while len(graph) < len(panels): + graph.add_layer(0) + _apply_canvas(graph, plot_spec.get("canvas")) primary_layers = list(graph)[:len(panels)] - for unused_layer in list(graph)[len(panels):]: - unused_layer.show = False + for layer, (left, top, width, height) in zip( + 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 - 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"] resolved_panel = resolved_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_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() - _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")) + _apply_axis( + layer, "x", _panel_setting(plot_spec, panel, "x_axis"), + str(left_specs[0]["x"]), + ) + _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"): 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("left", 120) - panel_label.set_int("top", 120) + panel_left, panel_top = _panel_page_pixel(graph, geometry, 0.02, -0.14) + panel_label.set_float("left", round(panel_left)) + panel_label.set_float("top", round(panel_top)) 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_plots = _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, + _replace_series_legend(right_layer, right_specs, right_plots) + _apply_legend( + right_layer, + legend, + vertical_offset=700, + attach_to_layer=True, + graph=graph, + geometry=geometry, ) 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") + _apply_page_title( + graph, + primary_layers[0], + plot_spec.get("title"), + plot_spec.get("title_style"), + ) return graph @@ -799,11 +979,11 @@ def run(job_dir: Path) -> list[dict[str, Any]]: } plot_spec = request["operation"]["plot"] plot_type = plot_spec["type"] - if plot_type not in PLOT_CONFIG and plot_type 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") series_specs = ( [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"] ) 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, ) band_legend = None - if plot_type == "multi_panel": - graph = _build_multi_panel_graph( + if plot_type in COMPOSITION_PLOT_TYPES: + graph = _build_recipe_graph( op, plot_spec, worksheets, resolved_series ) layer = graph[0] @@ -895,7 +1075,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]: if plot_type == "grouped_column": layer.group() _apply_canvas(graph, plot_spec.get("canvas")) - if plot_type != "multi_panel": + if plot_type not in COMPOSITION_PLOT_TYPES: layer.rescale() if plot_type not in {"polar", "pie"}: _apply_axis(