From 2dd282aa0de8b2aa3df8d80919c679da3ce50d5e Mon Sep 17 00:00:00 2001 From: caoqianming Date: Mon, 24 Aug 2026 13:50:10 +0800 Subject: [PATCH] feat(origin): add stacked spectra and violin plots --- CHANGELOG.md | 2 + DESIGN.md | 2 +- PROGRESS.md | 4 +- RUN.md | 4 +- software-contracts/origin.plot.v2.json | 45 +++- tests/test_origin_acceptance.py | 69 ++++- tests/test_origin_worker.py | 236 ++++++++++++++++++ tests/test_software_contracts.py | 43 ++++ tests/test_software_nodes.py | 15 +- windows-node/README.md | 4 +- .../adapters/origin.plot@v2/acceptance.py | 135 +++++++++- .../adapters/origin.plot@v2/adapter.json | 2 +- .../adapters/origin.plot@v2/worker.py | 193 +++++++++++++- 13 files changed, 721 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432151c..c37066c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ - 修复服务更新或多实例切换期间,点击“停止”后对话可能一直停留在“停止中”的问题。 +- Origin 新增按纵向错位叠加的多谱线图,以及用于比较多组试验数据分布的小提琴图;适合 XRD/XPS/光谱与材料性能重复试验等场景。 + ## 0.67.0 — 2026-08-21 - Blender 从单一回转窑模板升级为受管的通用静态三维场景创作,可组合基础体、拉伸/旋转/扫掠、管道、修改器、材质、灯光、文字和最多八个自定义视图;回转窑作为可复用组件继续提供。旧版回转窑工程不再支持在线续作。 diff --git a/DESIGN.md b/DESIGN.md index 2853c24..19504cf 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -484,7 +484,7 @@ Scene Recipe 每次按完整目标状态从 factory-empty 场景确定性重建 Node 对每个 Workspace 只保留 `current` 和 `rollback` 两代。续作开始前把 `current` 原子切换为 `rollback`,Worker 打开该工程并将新状态写入 Job 输出区;成功后输出区提升为新 `current`,失败或进程中断则恢复 `rollback`。因此连续加工不会为每个 Job 永久复制一份工程,物理空间上限约为当前工程加一份回滚工程;Job 账本仍完整保留参数、摘要和 local manifest。`software_job_revise(source_job_id, operation, outputs)` 只允许从 Workspace 当前 head 继续,复用已登记输入并重新经过当前契约校验。当前 Origin adapter 以 OPJU 为 workspace state;ANSYS 静力 capability 仍是无状态一次性执行,契约中的 `workspace: null` 明确保持原发布流程。 -科研统计图继续以独立 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/violin` 的系列只绑定原始 Y 列,统计规则由 Origin 固定模板决定;`violin` 把跨输入的观测列复制进受控全 Y 展示表,通过与菜单等价的固定 `worksheet -p 206 Violin` 一次性提交完整选择,使 Origin 正确建立分类轴和每列一组的核密度分布。原始输入表仍保留,默认以分类标签替代图例,并固定预留底部空间显示分类标签与轴标题;首版拒绝逐系列样式,因为原生多 Y 小提琴在图层内是单个统计对象,不能稳定映射普通曲线样式。`bubble` 增加正数 `size` 数据角色,由受控 modifier column 驱动符号尺寸;`band` 要求同一输入的 `x/y/lower/upper`,先画上下界并填充到下一曲线,再叠加中心线,Worker 在打开 Origin 前拒绝非有限尺寸、非正尺寸和倒置边界。`stacked_column/stacked_area/stacked_bar` 统一把两条以上 XY 系列复制进内部连续 XYY 工作表,严格校验横坐标相同后建立 plot group,并只执行 Worker 内置的固定累计图层命令;请求不能提供命令、模板或工作表范围。材料谱图使用独立 `stacked_line` feature:请求显式给出 `stack_gap_percent`,Worker 保留原始输入表,并在内部展示表中把每条曲线的最小值对齐为自身基线,再按全体最大 Y 跨度及间隔百分比逐条纵向偏移;各系列可拥有不同 X 采样点,展示纵轴不支持对数尺度。默认在曲线右端直接标注系列名并隐藏易遮挡谱线的图例,用户显式提供 `legend` 时仍按请求显示;单图标题按实际画布宽度定位。该实现不依赖节点上的 `OFFSETSTACKY.OTP` 或隐式主题,避免模板版本差异,同时 OPJU 中仍可追溯未变换数据。`area/polar/pie` 继续使用固定 Origin 类型 ID。未经过目标 Origin 版本真机验证的统计属性不进入公共 schema,避免暴露看似可配但不能稳定复现的参数。 Workspace capability 的默认完成协议不发布正式产物。Node 只自动上传契约声明的轻量 `preview_outputs`,云端校验后放入 task 的 `//` 并记录带工作目录相对路径的 `preview_manifest`;预览可在对话和文件面板中查看,但没有 artifact UUID,不进入正式产物清单,也不作为后续加工的事实源。完整工程和其他重输出保留在 home node 的 `current` 中,`local_manifest` 只记录固定 output ID、摘要、大小和媒体类型。只有用户明确要求下载、交付或导出时,Agent 调用 `software_job_export`,服务端向 home node 下发所选 output ID,Node 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact;若所选输出就是已回传且哈希一致的预览文件,则直接为现有文件补登记。预览文件与 Artifact 身份因此仍是两条独立生命周期,既让用户在文件夹中拿到轻量结果,也避免工程和其他重文件自动回传。 diff --git a/PROGRESS.md b/PROGRESS.md index 643fd11..8fc4a1f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -2,7 +2,7 @@ > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。 -最后更新:2026-08-24(Origin 真机 QA harness MVP) +最后更新:2026-08-24(Origin 小提琴分布图) --- @@ -20,6 +20,8 @@ --- ## 已完成关键能力 +- **08-24 / Unreleased / Origin 小提琴分布图**:`origin.plot@v2` / adapter 1.3.0 新增 `violin`,每条 `input/y` 系列作为一组原始观测,Worker 复制到受控全 Y 展示表并通过 Origin 2024 原生 `Violin.otpu` 一次性生成分类分布图;默认隐藏冗余图例,保留分类标签并为 X 轴标题预留底部空间。首版不开放逐系列 style、分裂/半小提琴及带箱线变体,避免把未稳定映射的模板属性写入公共契约。固定 QA 新增三组抗压强度分布、中位数 oracle、OPJU 重开与全格式复导;Origin 2024 / originpro 1.1.15 数据、视觉与进程释放门禁通过,Origin/合同/Job/Node 专项 126 项 unittest、Ruff 致命规则、py_compile、diff 检查与独立 adapter 打包通过,未连接或写入数据库。 +- **08-24 / Unreleased / Origin 材料谱图错位叠加**:`origin.plot@v2` / adapter 1.2.0 新增 `stacked_line`,面向 XRD、XPS 和光谱多曲线比较;请求显式声明间隔百分比,Worker 保留原始工作表并生成基线对齐的受控展示表,按全体最大谱幅确定逐条偏移,允许不同 X 采样点且不依赖节点模板。默认使用曲线右端直标并按画布定位标题,显式图例请求仍兼容。固定 QA harness 同步修正 OriginPro 列式读取、空白工作簿及短/长名称和有效列标签识别;Origin 2024 / originpro 1.1.15 真机生成、OPJU 重开、PNG/SVG/PDF 复导、偏移基线 oracle、视觉检查与进程释放通过。Origin/合同/Job/Node 专项 124 项 unittest、ruff 致命规则、py_compile 与独立 adapter 打包校验通过,未连接或写入数据库。 - **08-24 / 0.67.0 / Origin 真机 QA harness MVP**:Origin adapter 新增独立 `acceptance.py`,以 annotations、2×2 Recipe、heatmap、surface_3d、stacked、band 六个固定合成用例通过生产 `worker.py` 子进程执行 OPJU/PNG/SVG/PDF 全格式任务,再由独立 Origin 会话重开工程、核对图层/系列/工作表/矩阵与固定数值 oracle 并复导三种图件;报告记录环境指纹、请求/产物摘要、像素尺寸和 Origin 进程释放结果,失败也原子落盘。独立 adapter 包同步携带验收脚本;Origin/合同/Job/Node 专项 121 项 unittest、ruff 致命规则、py_compile 与独立 adapter 打包校验通过,目标 Origin 2024 真机执行待部署节点完成,未连接或写入数据库。 - **08-21 / 0.67.0 / Origin 出版级标注与坐标增强第一批**:`origin.plot@v2` / adapter 1.1.0 新增坐标反向、次刻度数量,以及按数据坐标定位的参考线、参考区间和文字标注;普通二维单图在 `plot.annotations` 声明,Recipe / multi-panel 在各 `panel.annotations` 声明,固定 Worker 编译为受控 Origin 图形对象,不开放 LabTalk、模板或对象名。Workspace 最低 adapter 基线同步提升到 1.1.0,避免旧 adapter 接收不认识的共享字段;Origin/合同/Job/Node 专项 114 项 unittest 通过,Origin 2024 的 OPJU、PNG、SVG、PDF 真机视觉验收待目标节点执行,未连接或写入数据库。 diff --git a/RUN.md b/RUN.md index dad9fd0..40ae870 100644 --- a/RUN.md +++ b/RUN.md @@ -1146,7 +1146,7 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动 声明式绘图使用 `plot.type=recipe`、`recipe_version=1`,第一版有意复用上述 `layout/panels` 结构和限制,作为新的组合入口而不是开放脚本。对已有结果继续调整时,先用 `software_job_status(job_id)` 取得 `editable_request`,在其中形成完整的新 `operation` 与 `outputs`,再调用 `software_job_revise`;服务端自动复用源 Job 的 inputs,并要求 source Job 是该 Workspace 当前 head。Origin Worker 打开 Node 上的上一版 OPJU,将新工作表和图添加到工程后保存为新的 `current`;失败时恢复 `rollback`。Job 历史不被覆盖,但 Node 只保留当前和上一版工程文件。 -科研统计图使用独立单图 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` 和 `violin` 的系列填写 `input/y`,直接使用原始观测列;`violin` 每条系列是一组分布,分类标签取 `label` 或列名,默认隐藏冗余图例,首版不接受逐系列 `style`。`stacked_column` 至少提供两条同类 `input/x/y` 系列;`bubble` 使用 `input/x/y/size`,每行 size 必须是有限正数;`band` 使用 `input/x/y/lower/upper`,其中 y 是中心线且每行 lower 不得大于 upper。当前 schema 不开放自定义箱线百分位、直方图分箱、小提琴核密度/分裂/半图变体或分布拟合参数,也不允许把这些统计图作为 `multi_panel.series[].kind`;在目标 Origin 版本完成真机验证后再增量开放。 常用二维扩展单图 type 包括 `area`、`polar`、`pie`,均使用 `input/x/y`;其中 polar 的 X 是角度(度)、Y 是半径,pie 的 X 是扇区标签、Y 是数值,两者使用 Origin 原生坐标/标签系统且不接受笛卡尔 `x_axis/y_axis/z_axis` 参数。`stacked_area`、`stacked_bar` 与 `stacked_column` 一样至少提供两条 `input/x/y`。堆叠系列可以来自不同输入,但行数和逐行 X 值必须完全一致,否则 Worker 在启动绘图前以 `STACKED_PLOT_X_VALUES_MISMATCH` 拒绝,避免错位累计。统计图与这些扩展图暂不混入 `multi_panel.series[].kind`。 @@ -1156,7 +1156,7 @@ Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动 只更新 Origin adapter 时,不需要编译、重装或替换 Node EXE:运行 `windows-node\package-origin-adapter.bat` 可直接把 manifest、Worker、验收脚本、依赖清单和契约打成 `dist\origin.plot@v2-adapter.zip`。在 Node 机器上先从托盘退出 zcbot Windows Node,备份并整体替换 EXE 同级的 `adapters\origin.plot@v2\`,再启动 Node。不要在任务执行中覆盖目录。若 `requirements.txt` 发生变化,在对应软件卡中“安装/更新运行环境”;仅 `adapter.json`、契约、`worker.py` 或 `acceptance.py` 变化时无需更新 runtime。Node 启动后会用 Worker 的 `--probe` 核对 manifest 与 Worker 版本,版本不一致时该能力保持不可用。 -Origin adapter 1.1.0 起随包提供固定真机 QA harness,但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`\runtimes\origin\Scripts\python.exe \adapters\origin.plot@v2\acceptance.py --work-root \origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、band,也可重复传入 `--case ` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF,再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle,复导三种图件,并等待 Origin 进程退出;必要时可用 `--release-wait ` 调整释放等待时间。最终报告位于 `\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。 +Origin adapter 1.1.0 起随包提供固定真机 QA harness,但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`\runtimes\origin\Scripts\python.exe \adapters\origin.plot@v2\acceptance.py --work-root \origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band,也可重复传入 `--case ` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF,再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle,复导三种图件,并等待 Origin 进程退出;必要时可用 `--release-wait ` 调整释放等待时间。最终报告位于 `\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。 Blender adapter 可独立运行 `windows-node\package-blender-adapter.bat` 打成 `dist\blender.scene.author@v3-adapter.zip`,替换规则与 Origin 相同。升级时先从托盘退出 Node,删除程序目录中的 `adapters\blender.scene.author@v1\`、`adapters\blender.scene.author@v2\`,整体放入 v3 目录后重启;数据根目录中的旧 Workspace 不在清理范围。Blender 的 `requirements.txt` 当前不安装第三方包,`bpy` 不安装到轻量启动 runtime。 diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index 70edc95..5f4ebed 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -77,7 +77,9 @@ "stacked_area": "0.8.0", "polar": "0.8.0", "pie": "0.8.0", - "stacked_bar": "0.8.0" + "stacked_bar": "0.8.0", + "stacked_line": "1.2.0", + "violin": "1.3.0" }, "summary": { "title_path": ["operation", "plot", "title"] @@ -138,7 +140,8 @@ "grouped_column", "y_error", "contour", "surface_3d", "ternary", "heatmap", "multi_panel", "recipe", "box", "histogram", "stacked_column", "bubble", "band", "area", - "stacked_area", "polar", "pie", "stacked_bar" + "stacked_area", "polar", "pie", "stacked_bar", "stacked_line", + "violin" ] }, "series": { @@ -190,6 +193,12 @@ "maxItems": 64, "items": {"$ref": "#/$defs/annotation"} }, + "stack_gap_percent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "stacked_line 相邻曲线在全体最大 Y 跨度之外保留的间隔百分比。Worker 保留原始输入,并在内部展示表中将每条曲线基线对齐后依次偏移。" + }, "canvas": { "type": "object", "required": ["width_mm", "height_mm"], @@ -313,7 +322,7 @@ } }, { - "if": {"properties": {"type": {"enum": ["box", "histogram"]}}}, + "if": {"properties": {"type": {"enum": ["box", "histogram", "violin"]}}}, "then": { "properties": { "series": { @@ -329,6 +338,16 @@ } } }, + { + "if": {"properties": {"type": {"const": "violin"}}}, + "then": { + "properties": { + "series": { + "items": {"not": {"required": ["style"]}} + } + } + } + }, { "if": {"properties": {"type": {"enum": [ "stacked_column", "stacked_area", "stacked_bar" @@ -349,6 +368,26 @@ } } }, + { + "if": {"properties": {"type": {"const": "stacked_line"}}}, + "then": { + "required": ["stack_gap_percent"], + "properties": { + "series": { + "minItems": 2, + "items": { + "required": ["input", "x", "y"], + "not": {"anyOf": [ + {"required": ["z"]}, {"required": ["y_error"]}, + {"required": ["size"]}, {"required": ["lower"]}, + {"required": ["upper"]} + ]} + } + } + } + }, + "else": {"not": {"required": ["stack_gap_percent"]}} + }, { "if": {"properties": {"type": {"const": "bubble"}}}, "then": { diff --git a/tests/test_origin_acceptance.py b/tests/test_origin_acceptance.py index f2feb43..ba801ee 100644 --- a/tests/test_origin_acceptance.py +++ b/tests/test_origin_acceptance.py @@ -26,7 +26,10 @@ class OriginAcceptanceTests(unittest.TestCase): cases = acceptance._cases() self.assertEqual( list(cases), - ["annotations", "recipe_2x2", "heatmap", "surface_3d", "stacked", "band"], + [ + "annotations", "recipe_2x2", "heatmap", "surface_3d", + "stacked", "stacked_line", "violin", "band", + ], ) for case in cases.values(): normalized, digest = contract.normalize_request(case["request"]) @@ -43,6 +46,11 @@ class OriginAcceptanceTests(unittest.TestCase): self.assertEqual(cases["heatmap"]["oracle"]["grid_shape"], [5, 7]) self.assertTrue(cases["band"]["oracle"]["lower_not_above_center"]) self.assertTrue(cases["band"]["oracle"]["center_not_above_upper"]) + self.assertEqual( + cases["stacked_line"]["oracle"]["stacked_line_baselines"], + [0, 58.3, 116.6], + ) + self.assertEqual(cases["violin"]["oracle"]["violin_medians"], [50, 56, 65]) def test_stage_uses_stable_digest_and_keyed_csv_inputs(self) -> None: with tempfile.TemporaryDirectory() as directory: @@ -68,13 +76,13 @@ class OriginAcceptanceTests(unittest.TestCase): return self.labels def to_list2(self, **_arguments): - return self.rows + return [list(column) for column in zip(*self.rows)] case = acceptance._cases()["stacked"] headers, rows = acceptance._csv_fixture(case["files"]["sample"]) staging = [[row[0], row[1], row[2], row[3]] for row in rows] workbook = [ - Sheet("sample", headers, rows), + Sheet("sample", ["age", "相 A", "相 B", "相 C"], rows), Sheet("stacked_plot_data", ["age", "相 A", "相 B", "相 C"], staging), ] result = acceptance._validate_project_data([workbook], [], case) @@ -84,6 +92,61 @@ class OriginAcceptanceTests(unittest.TestCase): [15.0, 18.0, 17.0, 22.0], ) + def test_project_data_validation_recomputes_stacked_line_offsets(self) -> None: + class Sheet: + def __init__(self, name, labels, rows): + self.lname = name + self.labels = labels + self.rows = rows + + def get_labels(self, _kind): + return self.labels + + def to_list2(self, **_arguments): + return [list(column) for column in zip(*self.rows)] + + case = acceptance._cases()["stacked_line"] + headers, rows = acceptance._csv_fixture(case["files"]["spectrum"]) + display = [ + [row[0], row[1] - 12, row[0], row[2] - 15 + 58.3, row[0], row[3] - 8 + 116.6] + for row in rows + ] + workbook = [ + Sheet("spectrum", ["two_theta", "试样 A", "试样 B", "试样 C"], rows), + Sheet( + "stacked_line_plot_data", + ["two_theta", "试样 A", "two_theta", "试样 B", "two_theta", "试样 C"], + display, + ), + ] + result = acceptance._validate_project_data([workbook], [], case) + self.assertEqual( + result["oracle_validation"]["stacked_line_baselines"], + [0.0, 58.3, 116.6], + ) + + def test_project_data_validation_recomputes_violin_medians(self) -> None: + class Sheet: + def __init__(self, name, labels, rows): + self.lname = name + self.labels = labels + self.rows = rows + + def get_labels(self, _kind): + return self.labels + + def to_list2(self, **_arguments): + return [list(column) for column in zip(*self.rows)] + + case = acceptance._cases()["violin"] + _, rows = acceptance._csv_fixture(case["files"]["strength"]) + workbook = [[Sheet("strength", ["基准组", "掺量 5%", "掺量 10%"], rows)]] + result = acceptance._validate_project_data(workbook, [], case) + self.assertEqual( + result["oracle_validation"]["violin_medians"], + [50.0, 56.0, 65.0], + ) + def test_manifest_validation_checks_all_outputs_and_png_size(self) -> None: with tempfile.TemporaryDirectory() as directory: job_dir = Path(directory) diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index 622b08f..2e5faa2 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -1016,6 +1016,237 @@ class OriginWorkerUnitTests(unittest.TestCase): self.assertEqual(worker.PLOT_CONFIG["polar"], ("polar", 192)) self.assertEqual(worker.PLOT_CONFIG["pie"], ("pie", 225)) self.assertEqual(worker.PLOT_CONFIG["stacked_bar"], ("bar", 216)) + self.assertEqual(worker.PLOT_CONFIG["violin"], ("Violin", 206)) + + def test_violin_graph_submits_one_multi_y_range(self) -> None: + class FakeSheet: + def __init__(self): + self.columns = [] + self.axis_call = None + self.activated = False + self.command = None + self.obj = self + + def from_list(self, index, values, *, lname): + self.columns.append((index, values, lname)) + + def cols_axis(self, *arguments): + self.axis_call = arguments + + def lt_range(self, _include_sheet): + return "[Book1]ViolinData" + + def activate(self): + self.activated = True + + def LT_execute(self, command): + self.command = command + + class FakeLayer: + def __init__(self): + self.plots = [object()] + self.activated = False + self.command = None + self.obj = self + self.values = {} + + def set_int(self, name, value): + self.values[name] = value + + def set_float(self, name, value): + self.values[name] = value + + def activate(self): + self.activated = True + + def LT_execute(self, command): + self.command = command + + def plot_list(self): + return self.plots + + class FakeGraph: + def __init__(self): + self.layer = FakeLayer() + + def __getitem__(self, _index): + return self.layer + + class FakeOp: + def __init__(self): + self.sheet = FakeSheet() + self.graph = FakeGraph() + + def new_sheet(self, kind, *, lname): + self.sheet_call = (kind, lname) + return self.sheet + + def find_graph(self): + return self.graph + + op = FakeOp() + graph, layer, plots = worker._build_violin_graph( + op, + { + "a": (["y1", "y2"], [[1, 10], [2, 20]]), + "b": (["y3"], [[30], [40], [50]]), + }, + [ + {"input": "a", "y": "y1", "label": "A"}, + {"input": "a", "y": "y2", "label": "B"}, + {"input": "b", "y": "y3", "label": "C"}, + ], + [ + {"input": "a", "y": 0}, + {"input": "a", "y": 1}, + {"input": "b", "y": 0}, + ], + ) + self.assertIs(graph, op.graph) + self.assertIs(layer, op.graph.layer) + self.assertEqual(plots, op.graph.layer.plots) + self.assertEqual(op.sheet_call, ("w", "violin_plot_data")) + self.assertEqual(op.sheet.axis_call, ("y", 0, 2)) + self.assertEqual( + op.sheet.columns, + [(0, [1, 2], "A"), (1, [10, 20], "B"), (2, [30, 40, 50], "C")], + ) + self.assertTrue(op.sheet.activated) + self.assertEqual( + op.sheet.command, + "worksheet -s 1 0 3 0; worksheet -p 206 Violin;", + ) + self.assertEqual( + op.graph.layer.values, + {"unit": 1, "left": 18, "top": 14, "width": 68, "height": 58}, + ) + + def test_offset_stacked_line_builds_deterministic_display_columns(self) -> None: + class FakeSheet: + def __init__(self): + self.columns = [] + + def from_list(self, index, values, *, lname): + self.columns.append((index, values, lname)) + + class FakeLayer: + def __init__(self): + self.calls = [] + self.labels = [] + self.values = {} + + def set_int(self, name, value): + self.values[name] = value + + def set_float(self, name, value): + self.values[name] = value + + def add_plot(self, sheet, **arguments): + plot = (sheet, arguments) + self.calls.append(plot) + return plot + + def group(self, enabled): + self.grouped = enabled + + def add_label(self, text, x, y): + class Label: + def __init__(self): + self.name = "" + self.values = {} + + def set_int(self, name, value): + self.values[name] = value + + label = Label() + self.labels.append((text, x, y, label)) + return label + + class FakeGraph: + def __init__(self): + self.layer = FakeLayer() + + def __getitem__(self, index): + self.index = index + return self.layer + + class FakeOp: + def __init__(self): + self.sheet = FakeSheet() + self.graph = FakeGraph() + + def new_sheet(self, kind, *, lname): + self.sheet_call = (kind, lname) + return self.sheet + + def new_graph(self, *, template): + self.template = template + return self.graph + + op = FakeOp() + graph, layer, plots = worker._build_offset_stacked_line_graph( + op, + { + "a": (["x", "y"], [[10, 20], [20, 60]]), + "b": (["x", "y"], [[11, 100], [21, 120]]), + }, + [ + {"input": "a", "x": "x", "y": "y", "label": "A"}, + {"input": "b", "x": "x", "y": "y", "label": "B"}, + ], + [ + {"input": "a", "x": 0, "y": 1}, + {"input": "b", "x": 0, "y": 1}, + ], + 10, + ) + self.assertIs(graph, op.graph) + self.assertIs(layer, op.graph.layer) + self.assertEqual(op.template, "line") + self.assertEqual(op.sheet_call, ("w", "stacked_line_plot_data")) + self.assertEqual( + op.sheet.columns, + [ + (0, [10.0, 20.0], "x"), + (1, [0.0, 40.0], "A"), + (2, [11.0, 21.0], "x"), + (3, [44.0, 64.0], "B"), + ], + ) + self.assertEqual( + [item[1] for item in plots], + [ + {"colx": 0, "coly": 1, "type": "l"}, + {"colx": 2, "coly": 3, "type": "l"}, + ], + ) + self.assertFalse(layer.grouped) + self.assertEqual( + layer.values, + {"unit": 1, "left": 18, "top": 14, "width": 68, "height": 60}, + ) + self.assertEqual( + [(text, x, y) for text, x, y, _label in layer.labels], + [("A", 20.0, 40.0), ("B", 21.0, 64.0)], + ) + self.assertEqual( + [label.name for _text, _x, _y, label in layer.labels], + ["ZCBOT_STACK_LABEL_001", "ZCBOT_STACK_LABEL_002"], + ) + + def test_offset_stacked_line_rejects_log_y_axis(self) -> None: + request = self._request() + request["operation"]["plot"].update({ + "type": "stacked_line", + "stack_gap_percent": 8, + "series": [ + {"input": "sample", "x": "x", "y": "a"}, + {"input": "sample", "x": "x", "y": "b"}, + ], + "y_axis": {"scale": "log10"}, + }) + with self.assertRaisesRegex(ValueError, "STACKED_LINE_LOG_Y_UNSUPPORTED"): + worker._validate_semantics(request) def test_stacked_legend_only_contains_requested_series(self) -> None: class Label: @@ -1343,6 +1574,11 @@ class OriginWorkerUnitTests(unittest.TestCase): self.assertNotIn("left", layer.legend.values) self.assertNotIn("top", layer.legend.values) + default_layer = Layer() + worker._apply_legend(default_layer, None) + self.assertTrue(default_layer.legend.show) + self.assertEqual(default_layer.legend.values["smartpos"], 1) + 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 0df70c2..0155795 100644 --- a/tests/test_software_contracts.py +++ b/tests/test_software_contracts.py @@ -154,6 +154,49 @@ class SoftwareContractTests(unittest.TestCase): }) self.assertTrue(node_supports_request(contract, area, current_runtime)) + stacked_line = _request("line") + stacked_line["operation"]["plot"] = { + "type": "stacked_line", + "stack_gap_percent": 8, + "series": [ + {"input": "sample", "x": "x", "y": "sample_a"}, + {"input": "sample", "x": "x", "y": "sample_b"}, + ], + } + normalized, _ = contract.normalize_request(stacked_line) + self.assertEqual(normalized, stacked_line) + self.assertFalse(node_supports_request(contract, stacked_line, current_runtime)) + current_runtime["capability_runtime"]["origin.plot@v2"].update({ + "adapter_version": "1.2.0", + "features": ["stacked_line"], + }) + self.assertTrue(node_supports_request(contract, stacked_line, current_runtime)) + + stacked_line["operation"]["plot"].pop("stack_gap_percent") + with self.assertRaises(ValueError): + contract.normalize_request(stacked_line) + + violin = _request("line") + violin["operation"]["plot"] = { + "type": "violin", + "series": [ + {"input": "sample", "y": "strength"}, + {"input": "sample", "y": "modulus"}, + ], + } + normalized, _ = contract.normalize_request(violin) + self.assertEqual(normalized, violin) + self.assertFalse(node_supports_request(contract, violin, current_runtime)) + current_runtime["capability_runtime"]["origin.plot@v2"].update({ + "adapter_version": "1.3.0", + "features": ["violin"], + }) + self.assertTrue(node_supports_request(contract, violin, current_runtime)) + + violin["operation"]["plot"]["series"][0]["style"] = {"color": "#3366CC"} + with self.assertRaises(ValueError): + contract.normalize_request(violin) + def test_origin_contract_accepts_controlled_annotations_and_axis_options(self) -> None: contract = get_contract("origin.plot@v2") request = _request("line") diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 8dd42d9..9839e14 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -529,6 +529,10 @@ class SoftwareJobProtocolTests(unittest.TestCase): "heatmap": [{"input": "sample", "x": "x", "y": "y", "z": "value"}], "box": [{"input": "sample", "y": "strength"}], "histogram": [{"input": "sample", "y": "particle_size"}], + "violin": [ + {"input": "sample", "y": "strength"}, + {"input": "sample", "y": "modulus"}, + ], "stacked_column": [ {"input": "sample", "x": "sample_name", "y": "phase_a"}, {"input": "sample", "x": "sample_name", "y": "phase_b"}, @@ -552,12 +556,20 @@ class SoftwareJobProtocolTests(unittest.TestCase): {"input": "sample", "x": "sample_name", "y": "phase_a"}, {"input": "sample", "x": "sample_name", "y": "phase_b"}, ], + "stacked_line": [ + {"input": "sample", "x": "two_theta", "y": "sample_a"}, + {"input": "sample", "x": "two_theta", "y": "sample_b"}, + ], } for plot_type, series in cases.items(): request = { "schema_version": 2, "inputs": [{"key": "sample", "artifact_id": artifact_id}], - "operation": {"plot": {"type": plot_type, "series": series}}, + "operation": {"plot": { + "type": plot_type, + "series": series, + **({"stack_gap_percent": 8} if plot_type == "stacked_line" else {}), + }}, "outputs": outputs, } with self.subTest(plot_type=plot_type): @@ -633,6 +645,7 @@ class SoftwareJobProtocolTests(unittest.TestCase): {"type": "bubble", "series": [{"input": "sample", "x": "x", "y": "y"}]}, {"type": "band", "series": [{"input": "sample", "x": "x", "y": "y"}]}, {"type": "stacked_area", "series": [{"input": "sample", "x": "x", "y": "y"}]}, + {"type": "stacked_line", "stack_gap_percent": 8, "series": [{"input": "sample", "x": "x", "y": "y"}]}, {"type": "polar", "series": [{"input": "sample", "x": "x", "y": "y"}], "x_axis": {"title": "Angle"}}, ) for plot in invalid_plots: diff --git a/windows-node/README.md b/windows-node/README.md index ee43169..36cd0a9 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -12,11 +12,11 @@ windows-node\package-windows-node.bat 产物位于 `windows-node\dist\zcbot-windows-node--win-x64.zip`,旁边同时生成 SHA-256 文件。默认不包含 .NET Runtime,目标机器需预装 .NET 10 Desktop Runtime;需要免安装 .NET 的离线包时执行 `package-windows-node.bat --self-contained`。打包脚本是纯 BAT,使用 .NET SDK 以及 Windows 自带的 `tar.exe`、`certutil.exe`。 -固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式。adapter 0.6.0 提供 `multi_panel`:可组成 1–4 个二维 panel,在每个 panel 混合基础二维系列、绑定左右 Y 轴并添加对称 X/Y 误差列;adapter 0.7.0 增加 `box`、`histogram`、`stacked_column`、`bubble` 和 `band`,adapter 0.8.0 再增加 `area`、`stacked_area`、`polar`、`pie` 和 `stacked_bar`。Worker 生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格;所有堆叠系列必须逐行共享相同 X 值。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口;使用实际运行 Node 的专用 Windows 账号直接双击: +固定 Origin Worker 通过统一的 `origin.plot@v2` / `series[]` 数据角色模型支持 `line`、`scatter`、`line_scatter`、`column`、`bar`、`grouped_column`、`y_error`、`contour`、`surface_3d`、`ternary` 和 `heatmap`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式。adapter 0.6.0 提供 `multi_panel`:可组成 1–4 个二维 panel,在每个 panel 混合基础二维系列、绑定左右 Y 轴并添加对称 X/Y 误差列;adapter 0.7.0 增加 `box`、`histogram`、`stacked_column`、`bubble` 和 `band`,adapter 0.8.0 再增加 `area`、`stacked_area`、`polar`、`pie` 和 `stacked_bar`,adapter 1.2.0 增加适合 XRD/XPS/光谱比较的 `stacked_line`,adapter 1.3.0 增加用于多组原始观测分布比较的 `violin`。Worker 生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格;累计堆叠系列必须逐行共享相同 X 值,错位谱线允许各系列使用不同 X 采样点。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口;使用实际运行 Node 的专用 Windows 账号直接双击: 日常只更新 adapter 时运行 `package-origin-adapter.bat`,无需编译 Node。退出托盘 Node 后,整体替换安装目录中的 `adapters\origin.plot@v2\` 再启动即可;不要在任务执行期间覆盖文件。只有依赖清单变化才需要在对应软件卡中更新 runtime。 -Origin adapter 1.1.0 随包提供独立 CLI 真机 QA harness,使用固定合成数据覆盖 annotations、2×2 Recipe、heatmap、surface_3d、stacked 和 band,并验证 OPJU 重开、数据与数值 oracle、三种图件复导及 Origin 进程释放;该入口暂不接入 Node UI,运行命令和验收边界见根目录 `RUN.md`。 +Origin adapter 1.1.0 起随包提供独立 CLI 真机 QA harness;1.3.0 的固定合成数据覆盖 annotations、2×2 Recipe、heatmap、surface_3d、stacked、stacked_line、violin 和 band,并验证 OPJU 重开、数据与数值 oracle、三种图件复导及 Origin 进程释放;该入口暂不接入 Node UI,运行命令和验收边界见根目录 `RUN.md`。 Windows Node 不再提供统一安装脚本。解压发布包后直接运行 `Zcbot.WindowsNode.exe`,在主窗口完成节点注册、登录后自动启动、应用位置配置和各软件 runtime 的独立安装。runtime 安装依次尝试 `py -3.12` 和 PATH 中的 `python.exe`,要求 Python 3.12;依赖默认通过清华 PyPI 镜像安装,需要改用官方源或院内镜像时,在启动 Node 前设置 `ZCBOT_PIP_INDEX_URL`。runtime 位于 `\runtimes\\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。 diff --git a/windows-node/adapters/origin.plot@v2/acceptance.py b/windows-node/adapters/origin.plot@v2/acceptance.py index 0d79ff6..7d24d66 100644 --- a/windows-node/adapters/origin.plot@v2/acceptance.py +++ b/windows-node/adapters/origin.plot@v2/acceptance.py @@ -84,6 +84,20 @@ def _cases() -> dict[str, dict[str, Any]]: band_csv = "time,center,lower,upper\n" + "\n".join( ",".join(str(value) for value in row) for row in band_rows ) + "\n" + spectrum_rows = [ + (20, 12, 30, 8), (25, 40, 15, 18), (30, 20, 55, 25), + (35, 65, 20, 48), (40, 18, 35, 15), + ] + spectrum_csv = "two_theta,sample_a,sample_b,sample_c\n" + "\n".join( + ",".join(str(value) for value in row) for row in spectrum_rows + ) + "\n" + violin_rows = [ + (42, 45, 50), (45, 49, 54), (47, 51, 59), (49, 53, 62), (50, 56, 65), + (51, 58, 67), (53, 61, 70), (56, 64, 74), (60, 69, 79), + ] + violin_csv = "reference,dosage_5,dosage_10\n" + "\n".join( + ",".join(str(value) for value in row) for row in violin_rows + ) + "\n" return { "annotations": { @@ -259,12 +273,69 @@ def _cases() -> dict[str, dict[str, Any]]: }}, "outputs": OUTPUTS, }, + "expect": { + "minimum_graph_layers": 1, + "minimum_plot_counts": [1], + "minimum_worksheets": 2, + "annotation_names": [ + "ZCBOT_STACK_LABEL_001", + "ZCBOT_STACK_LABEL_002", + "ZCBOT_STACK_LABEL_003", + ], + }, + "oracle": {"stacked_totals": [sum(row[1:]) for row in stacked_rows]}, + }, + "stacked_line": { + "files": {"spectrum": spectrum_csv}, + "request": { + "schema_version": 2, + "inputs": [_input("stacked_line", "spectrum")], + "operation": {"plot": { + "type": "stacked_line", "title": "XRD 谱图错位叠加", + "canvas": {"width_mm": 160, "height_mm": 110}, + "stack_gap_percent": 10, + "series": [ + {"input": "spectrum", "x": "two_theta", "y": "sample_a", "label": "试样 A"}, + {"input": "spectrum", "x": "two_theta", "y": "sample_b", "label": "试样 B"}, + {"input": "spectrum", "x": "two_theta", "y": "sample_c", "label": "试样 C"}, + ], + "x_axis": {"title": "2θ", "unit": "°"}, + "y_axis": {"title": "错位强度", "unit": "a.u."}, + }}, + "outputs": OUTPUTS, + }, "expect": { "minimum_graph_layers": 1, "minimum_plot_counts": [3], "minimum_worksheets": 2, }, - "oracle": {"stacked_totals": [sum(row[1:]) for row in stacked_rows]}, + "oracle": {"stacked_line_baselines": [0, 58.3, 116.6]}, + }, + "violin": { + "files": {"strength": violin_csv}, + "request": { + "schema_version": 2, + "inputs": [_input("violin", "strength")], + "operation": {"plot": { + "type": "violin", "title": "不同配方抗压强度分布", + "canvas": {"width_mm": 150, "height_mm": 105}, + "series": [ + {"input": "strength", "y": "reference", "label": "基准组"}, + {"input": "strength", "y": "dosage_5", "label": "掺量 5%"}, + {"input": "strength", "y": "dosage_10", "label": "掺量 10%"}, + ], + "x_axis": {"title": "配方组别"}, + "y_axis": {"title": "抗压强度", "unit": "MPa"}, + "legend": {"enabled": False}, + }}, + "outputs": OUTPUTS, + }, + "expect": { + "minimum_graph_layers": 1, + "minimum_plot_counts": [3], + "minimum_worksheets": 2, + }, + "oracle": {"violin_medians": [50, 56, 65]}, }, "band": { "files": {"sample": band_csv}, @@ -345,13 +416,14 @@ def _csv_fixture(content: str) -> tuple[list[str], list[list[Any]]]: return rows[0], [[_normalized_cell(value) for value in row] for row in rows[1:]] -def _worksheet_snapshot(sheet: Any) -> dict[str, Any]: +def _worksheet_snapshot(sheet: Any, fallback_name: str = "") -> dict[str, Any]: labels = list(sheet.get_labels("L")) - rows = [list(row) for row in sheet.to_list2(c1=0, c2=max(0, len(labels) - 1))] + columns = sheet.to_list2(c1=0, c2=max(0, len(labels) - 1)) or [] + rows = [list(row) for row in zip(*columns)] while rows and all(value in (None, "") for value in rows[-1]): rows.pop() return { - "name": str(sheet.lname), + "name": str(sheet.lname or fallback_name), "labels": labels, "rows": [[_normalized_cell(value) for value in row[: len(labels)]] for row in rows], } @@ -360,14 +432,34 @@ def _worksheet_snapshot(sheet: Any) -> dict[str, Any]: def _validate_project_data( workbooks: list[Any], matrices: list[Any], case: dict[str, Any] ) -> dict[str, Any]: - sheets = [_worksheet_snapshot(sheet) for book in workbooks for sheet in book] + sheets = [] + for book in workbooks: + fallback_name = str( + getattr(book, "lname", "") or getattr(book, "name", "") or "" + ) + for sheet in book: + snapshot = _worksheet_snapshot(sheet, fallback_name) + if any(snapshot["labels"]) or snapshot["rows"]: + sheets.append(snapshot) by_name = {item["name"]: item for item in sheets} + plot = case["request"]["operation"]["plot"] + series_specs = ( + [item for panel in plot["panels"] for item in panel["series"]] + if plot["type"] in worker.COMPOSITION_PLOT_TYPES + else plot["series"] + ) for input_key, content in case["files"].items(): if input_key not in by_name: raise RuntimeError(f"OPJU_INPUT_SHEET_MISSING:{input_key}") headers, rows = _csv_fixture(content) actual = by_name[input_key] - if actual["labels"][: len(headers)] != headers: + expected_labels = list(headers) + for series in series_specs: + if series["input"] == input_key and "y" in series: + expected_labels[headers.index(series["y"])] = str( + series.get("label") or series["y"] + ) + if actual["labels"][: len(headers)] != expected_labels: raise RuntimeError(f"OPJU_INPUT_LABELS_MISMATCH:{input_key}") actual_rows = [row[: len(headers)] for row in actual["rows"][: len(rows)]] if actual_rows != rows: @@ -382,6 +474,37 @@ def _validate_project_data( if totals != expected: raise RuntimeError("OPJU_STACKED_TOTALS_MISMATCH") oracle_validation["stacked_totals"] = totals + if "stacked_line_baselines" in case["oracle"]: + staging = by_name.get("stacked_line_plot_data") + if staging is None: + raise RuntimeError("OPJU_STACKED_LINE_STAGING_MISSING") + baselines = [ + min(float(row[index]) for row in staging["rows"]) + for index in (1, 3, 5) + ] + expected = [float(value) for value in case["oracle"]["stacked_line_baselines"]] + if any( + abs(actual - target) > 1e-9 + for actual, target in zip(baselines, expected, strict=True) + ): + raise RuntimeError("OPJU_STACKED_LINE_BASELINES_MISMATCH") + oracle_validation["stacked_line_baselines"] = baselines + if "violin_medians" in case["oracle"]: + input_key = next(iter(case["files"])) + source = by_name[input_key] + medians = [] + for column_index in range(len(case["oracle"]["violin_medians"])): + values = sorted(float(row[column_index]) for row in source["rows"]) + middle = len(values) // 2 + medians.append( + values[middle] + if len(values) % 2 + else (values[middle - 1] + values[middle]) / 2 + ) + expected = [float(value) for value in case["oracle"]["violin_medians"]] + if medians != expected: + raise RuntimeError("OPJU_VIOLIN_MEDIANS_MISMATCH") + oracle_validation["violin_medians"] = medians if "grid_shape" in case["oracle"]: matrix_sheets = [sheet for book in matrices for sheet in book] if not matrix_sheets: diff --git a/windows-node/adapters/origin.plot@v2/adapter.json b/windows-node/adapters/origin.plot@v2/adapter.json index b2913b5..4f33076 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": "1.1.0", + "adapter_version": "1.3.0", "runtime": "python", "runtime_id": "origin", "entrypoint": "worker.py", diff --git a/windows-node/adapters/origin.plot@v2/worker.py b/windows-node/adapters/origin.plot@v2/worker.py index a7b4c61..74f7b29 100644 --- a/windows-node/adapters/origin.plot@v2/worker.py +++ b/windows-node/adapters/origin.plot@v2/worker.py @@ -42,8 +42,11 @@ PLOT_CONFIG = { "polar": ("polar", 192), "pie": ("pie", 225), "stacked_bar": ("bar", 216), + "stacked_line": ("line", "l"), + "violin": ("Violin", 206), } STACKED_PLOT_TYPES = {"stacked_column", "stacked_area", "stacked_bar"} +OFFSET_STACKED_PLOT_TYPES = {"stacked_line"} COMPOSITION_PLOT_TYPES = {"multi_panel", "recipe"} _CUMULATIVE_STACK_COMMAND = "layer -b s 1" XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"} @@ -75,7 +78,7 @@ PANEL_LAYOUT = { (2, 1): {"left": 13, "right": 13, "top": 14, "bottom": 12, "xgap": 0, "ygap": 10}, (2, 2): {"left": 9, "right": 4, "top": 14, "bottom": 12, "xgap": 11, "ygap": 10}, } -ADAPTER_VERSION = "1.1.0" +ADAPTER_VERSION = "1.3.0" def _server_executable(command: str) -> Path: @@ -293,6 +296,14 @@ def _validate_semantics(request: dict[str, Any]) -> None: raise ValueError("GROUPED_COLUMN_REQUIRES_MULTIPLE_SERIES") if plot_type in STACKED_PLOT_TYPES and len(series) < 2: raise ValueError("STACKED_PLOT_REQUIRES_MULTIPLE_SERIES") + if plot_type in OFFSET_STACKED_PLOT_TYPES and len(series) < 2: + raise ValueError("STACKED_LINE_REQUIRES_MULTIPLE_SERIES") + if plot_type in OFFSET_STACKED_PLOT_TYPES and ( + (plot.get("y_axis") or {}).get("scale") in {"log10", "ln", "log2"} + ): + raise ValueError("STACKED_LINE_LOG_Y_UNSUPPORTED") + if plot_type == "violin" and any("style" in item for item in series): + raise ValueError("VIOLIN_SERIES_STYLE_UNSUPPORTED") if plot_type in XYZ_PLOT_TYPES and len(series) != 1: raise ValueError("XYZ_PLOT_REQUIRES_ONE_SERIES") required_roles = ( @@ -301,7 +312,7 @@ def _validate_semantics(request: dict[str, Any]) -> None: else ("x", "y", "y_error") if plot_type == "y_error" else ("y",) - if plot_type in {"box", "histogram"} + if plot_type in {"box", "histogram", "violin"} else ("x", "y", "size") if plot_type == "bubble" else ("x", "y", "lower", "upper") @@ -820,7 +831,9 @@ def _apply_legend( graph: Any = None, geometry: tuple[int, int, int, int] | None = None, ) -> None: - if not isinstance(legend, dict): + if legend is None: + legend = {"enabled": True, "position": "auto"} + elif not isinstance(legend, dict): return label = _legend_label(layer) label.set_int("background", 0) @@ -920,13 +933,15 @@ def _apply_surface_3d_presentation( color_scale.show = legend.get("enabled", True) -def _apply_title(layer: Any, value: Any, style: Any, *, top: int = 120) -> None: +def _apply_title( + layer: Any, value: Any, style: Any, *, graph: Any = None, 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("left", round(graph.get_float("width") * 0.25) if graph else 2200) title.set_int("top", top) @@ -1211,6 +1226,121 @@ def _build_stacked_graph( return graph, layer, list(layer.plot_list()) +def _build_violin_graph( + op: Any, + input_data: dict[str, tuple[list[str], list[list[Any]]]], + series_specs: list[dict[str, Any]], + resolved_series: list[dict[str, Any]], + canvas: Any = None, +) -> tuple[Any, Any, list[Any]]: + """Submit all raw Y columns as one range so Origin builds a categorical axis.""" + staging = op.new_sheet("w", lname="violin_plot_data") + for index, (series_spec, resolved) in enumerate( + zip(series_specs, resolved_series, strict=True) + ): + _, rows = input_data[resolved["input"]] + staging.from_list( + index, + [row[resolved["y"]] if resolved["y"] < len(row) else None for row in rows], + lname=str(series_spec.get("label") or series_spec["y"]), + ) + staging.cols_axis("y", 0, len(series_specs) - 1) + + staging.activate() + staging.obj.LT_execute( + f"worksheet -s 1 0 {len(series_specs)} 0; worksheet -p 206 Violin;" + ) + graph = op.find_graph() + if graph is None: + raise RuntimeError("VIOLIN_GRAPH_MISSING") + if isinstance(canvas, dict): + for graph_layer in graph: + graph_layer.set_int("unit", 1) + _apply_canvas(graph, canvas) + layer = graph[0] + # Category labels are wider than numeric ticks; reserve enough room for + # both the labels and the X-axis title on publication-sized canvases. + layer.set_int("unit", 1) + for name, value in (("left", 18), ("top", 14), ("width", 68), ("height", 58)): + layer.set_float(name, value) + origin_plots = list(layer.plot_list()) + if not origin_plots: + raise RuntimeError("VIOLIN_PLOT_MISSING") + return graph, layer, origin_plots + + +def _build_offset_stacked_line_graph( + op: Any, + input_data: dict[str, tuple[list[str], list[list[Any]]]], + series_specs: list[dict[str, Any]], + resolved_series: list[dict[str, Any]], + gap_percent: float, + canvas: Any = None, +) -> tuple[Any, Any, list[Any]]: + values_by_series: list[tuple[list[float], list[float]]] = [] + maximum_span = 0.0 + for resolved in resolved_series: + _, rows = input_data[resolved["input"]] + x_values: list[float] = [] + y_values: list[float] = [] + for row in rows: + try: + x_values.append(_number(row[resolved["x"]], "x")) + y_values.append(_number(row[resolved["y"]], "y")) + except IndexError as exc: + raise ValueError("STACKED_LINE_ROW_INCOMPLETE") from exc + if not y_values: + raise ValueError("STACKED_LINE_SERIES_EMPTY") + maximum_span = max(maximum_span, max(y_values) - min(y_values)) + values_by_series.append((x_values, y_values)) + + if maximum_span == 0: + maximum_span = max( + 1.0, + max(abs(value) for _, values in values_by_series for value in values), + ) + offset_step = maximum_span * (1 + float(gap_percent) / 100) + staging = op.new_sheet("w", lname="stacked_line_plot_data") + graph = _new_graph(op, "line", canvas) + layer = graph[0] + # Spectral stacks need room below the bottom axis for large scientific + # tick labels and a unit-bearing X title after PDF-to-PNG rendering. + layer.set_int("unit", 1) + for name, value in ( + ("left", 18), + ("top", 14), + ("width", 68), + ("height", 60), + ): + layer.set_float(name, value) + plots = [] + for index, (series_spec, (x_values, y_values)) in enumerate( + zip(series_specs, values_by_series, strict=True) + ): + x_column = index * 2 + baseline = min(y_values) + display_values = [value - baseline + index * offset_step for value in y_values] + staging.from_list(x_column, x_values, lname=str(series_spec["x"])) + staging.from_list( + x_column + 1, + display_values, + lname=str(series_spec.get("label") or series_spec["y"]), + ) + plots.append(layer.add_plot(staging, colx=x_column, coly=x_column + 1, type="l")) + endpoint = max(range(len(x_values)), key=x_values.__getitem__) + curve_label = layer.add_label( + str(series_spec.get("label") or series_spec["y"]), + x_values[endpoint], + display_values[endpoint], + ) + curve_label.name = f"ZCBOT_STACK_LABEL_{index + 1:03d}" + curve_label.set_int("attach", 2) + curve_label.set_int("background", 0) + curve_label.set_int("fsize", 9) + layer.group(False) + return graph, layer, plots + + def _share_axis_limits(layers: list[Any], name: str) -> None: limits = [getattr(layer, f"{name}lim") for layer in layers] begin = min(item[0] for item in limits) @@ -1462,6 +1592,23 @@ def run(job_dir: Path) -> list[dict[str, Any]]: origin_plot_type, plot_spec.get("canvas"), ) + elif plot_type in OFFSET_STACKED_PLOT_TYPES: + graph, layer, origin_plots = _build_offset_stacked_line_graph( + op, + input_data, + series_specs, + resolved_series, + plot_spec["stack_gap_percent"], + plot_spec.get("canvas"), + ) + elif plot_type == "violin": + graph, layer, origin_plots = _build_violin_graph( + op, + input_data, + series_specs, + resolved_series, + plot_spec.get("canvas"), + ) else: template, origin_plot_type = PLOT_CONFIG[plot_type] graph = _new_graph(op, template, plot_spec.get("canvas")) @@ -1505,6 +1652,10 @@ def run(job_dir: Path) -> list[dict[str, Any]]: plot_spec.get("y_axis"), str(series_specs[0].get("y") or "Y"), ) + if plot_type == "violin": + x_title = layer.label("xb") + if x_title is not None: + x_title.show = True else: layer.rescale() if plot_type == "surface_3d": @@ -1514,13 +1665,19 @@ def run(job_dir: Path) -> list[dict[str, Any]]: plot_spec.get("z_axis"), str(series_specs[0].get("z") or "Z"), ) - for origin_plot, series_spec in zip(origin_plots, series_specs, strict=True): - style = series_spec.get("style") - if plot_type == "bubble" and style: - style = {key: value for key, value in style.items() if key != "symbol_size"} - _apply_series_style(origin_plot, style) + if plot_type != "violin": + for origin_plot, series_spec in zip( + origin_plots, series_specs, strict=True + ): + style = series_spec.get("style") + if plot_type == "bubble" and style: + style = { + key: value for key, value in style.items() + if key != "symbol_size" + } + _apply_series_style(origin_plot, style) _apply_annotations(layer, plot_spec.get("annotations")) - if plot_type in STACKED_PLOT_TYPES: + if plot_type in STACKED_PLOT_TYPES | OFFSET_STACKED_PLOT_TYPES: _replace_series_legend(layer, series_specs) if plot_type == "surface_3d": _apply_surface_3d_presentation( @@ -1540,10 +1697,20 @@ def run(job_dir: Path) -> list[dict[str, Any]]: ), ) else: - _apply_legend(layer, plot_spec.get("legend")) + legend_spec = plot_spec.get("legend") + if ( + plot_type in OFFSET_STACKED_PLOT_TYPES or plot_type == "violin" + ) and legend_spec is None: + legend_spec = {"enabled": False} + _apply_legend(layer, legend_spec) if band_legend is not None: layer.label("Legend").text = band_legend - _apply_title(layer, plot_spec.get("title"), plot_spec.get("title_style")) + _apply_title( + layer, + plot_spec.get("title"), + plot_spec.get("title_style"), + graph=graph, + ) requested_outputs = request["outputs"] formats = list(dict.fromkeys([ *[item["format"] for item in requested_outputs],