feat(origin): add stacked spectra and violin plots

This commit is contained in:
caoqianming 2026-08-24 13:50:10 +08:00
parent df2b636ee5
commit 2dd282aa0d
13 changed files with 721 additions and 33 deletions

View File

@ -10,6 +10,8 @@
- 修复服务更新或多实例切换期间,点击“停止”后对话可能一直停留在“停止中”的问题。
- Origin 新增按纵向错位叠加的多谱线图,以及用于比较多组试验数据分布的小提琴图;适合 XRD/XPS/光谱与材料性能重复试验等场景。
## 0.67.0 — 2026-08-21
- Blender 从单一回转窑模板升级为受管的通用静态三维场景创作,可组合基础体、拉伸/旋转/扫掠、管道、修改器、材质、灯光、文字和最多八个自定义视图;回转窑作为可复用组件继续提供。旧版回转窑工程不再支持在线续作。

View File

@ -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 stateANSYS 静力 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 的 `<output_namespace>/<job_id>/` 并记录带工作目录相对路径的 `preview_manifest`;预览可在对话和文件面板中查看,但没有 artifact UUID不进入正式产物清单也不作为后续加工的事实源。完整工程和其他重输出保留在 home node 的 `current` 中,`local_manifest` 只记录固定 output ID、摘要、大小和媒体类型。只有用户明确要求下载、交付或导出时Agent 调用 `software_job_export`,服务端向 home node 下发所选 output IDNode 才流式上传,云端复核后移入 task 输出目录并登记正式 Artifact若所选输出就是已回传且哈希一致的预览文件则直接为现有文件补登记。预览文件与 Artifact 身份因此仍是两条独立生命周期,既让用户在文件夹中拿到轻量结果,也避免工程和其他重文件自动回传。

View File

@ -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 真机视觉验收待目标节点执行,未连接或写入数据库。

4
RUN.md
View File

@ -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 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、band也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle复导三种图件并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\acceptance-report.json`。输入均为脚本生成的合成数据,但 PNG/SVG/PDF 仍需人工完成视觉 gate 后才能判定目标机器通过。
Origin adapter 1.1.0 起随包提供固定真机 QA harness但暂不接入 Node UI。在专用测试节点确认没有活动 Job 后,从 PowerShell 使用 Origin 卡片已安装的受管解释器运行:`<data_root>\runtimes\origin\Scripts\python.exe <node_program>\adapters\origin.plot@v2\acceptance.py --work-root <new_empty_parent>\origin-acceptance-YYYYMMDD`。`--work-root` 必须指向尚不存在的新目录;adapter 1.3.0 默认依次执行 annotations、recipe_2x2、heatmap、surface_3d、stacked、stacked_line、violin、band也可重复传入 `--case <name>` 只跑指定用例。每个用例先通过生产 Worker 子进程生成 OPJU/PNG/SVG/PDF再由独立 Origin 会话重开工程,核对图层、系列、工作表/矩阵和数值 oracle复导三种图件并等待 Origin 进程退出;必要时可用 `--release-wait <seconds>` 调整释放等待时间。最终报告位于 `<work-root>\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。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,11 +12,11 @@ windows-node\package-windows-node.bat
产物位于 `windows-node\dist\zcbot-windows-node-<version>-win-x64.zip`,旁边同时生成 SHA-256 文件。默认不包含 .NET Runtime目标机器需预装 .NET 10 Desktop Runtime需要免安装 .NET 的离线包时执行 `package-windows-node.bat --self-contained`。打包脚本是纯 BAT使用 .NET SDK 以及 Windows 自带的 `tar.exe`、`certutil.exe`。
固定 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`:可组成 14 个二维 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`:可组成 14 个二维 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 harness1.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 位于 `<data_root>\runtimes\<runtime_id>\Scripts\python.exe`。全新节点默认使用 `%LocalAppData%\Zcbot\WindowsNode`;已有 `%ProgramData%\Zcbot\WindowsNode` 的升级节点继续沿用原目录。

View File

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

View File

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

View File

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