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
- Origin 绘图新增声明式 Recipe可组合 14 个二维面板、左右坐标轴、误差棒和逐系列样式对已有专业软件任务不满意时Agent 可复用原输入提交完整的新绘图方案,生成新版本产物且不会覆盖旧结果。
- 修复 Origin 二维复合图中多条系列被错误套用同一种红色三角样式、标题和图例缺失或重叠的问题;颜色、线型、点型、误差棒、坐标轴和图例现在会按请求分别呈现。
- Windows Node 接收专业软件任务时不再因适配器契约数据被提前释放而离线,已注册节点可持续接收并校验新任务。
- 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 合并成可恢复视图。
第四阶段落地固定 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避免暴露看似可配但不能稳定复现的参数。

View File

@ -22,6 +22,10 @@
### 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.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/`;上传中断会在重连时幂等续传。
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 版本完成真机验证后再增量开放。

View File

@ -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:
"""选择最早可执行的 JobNode 组合,避免跨 capability 队首阻塞。"""
if not node_ids:

View File

@ -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),
]

View File

@ -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": {

View File

@ -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]],

View File

@ -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",

View File

@ -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)}

View File

@ -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."

View File

@ -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",

View File

@ -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,14 +443,52 @@ 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:
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
@ -446,22 +496,104 @@ def _apply_legend(layer: Any, legend: Any, vertical_offset: int = 0) -> None:
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(