diff --git a/CHANGELOG.md b/CHANGELOG.md index 12c947a..808465e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ## Unreleased +- Origin 绘图新增出版排版控制:可指定毫米画布、坐标范围与对数尺度、刻度角度和字号、标题/图例字号、网格,以及每条曲线的颜色、线型、点型和透明度;旧绘图请求继续沿用默认样式。 - 新增专业软件 Job 中心:Agent 可将当前对话目录内的数据登记为稳定输入,提交、查询和停止 Windows Node 上的受控软件任务;文件栏底部固定展示任务状态,点击可从右侧打开按开启时间倒序、滚动加载的跨对话任务列表,并可收到完成通知或回到原对话分析结果。 - 专业软件任务完成后,输出文件会立即显示在当前对话的文件面板;任务中心收起时也不会再遮挡发送按钮。 - 专业软件生成的正式文件会保存在当前对话的正确目录并记录来源;运行参数和溯源信息收纳到隐藏元数据目录,减少产物列表噪声。 diff --git a/DESIGN.md b/DESIGN.md index e77816b..6455e9f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -472,7 +472,7 @@ Node 通过 `Authorization: Bearer` 与 `X-Node-Id` 建立 `/v1/software-nodes/c 第三阶段补齐多输入下载与恢复状态协议:请求使用通用 `inputs[]` 绑定 1–16 个 artifact,并由 `operation.plot.series[]` 以输入 key 引用各自的 X/Y 列;单文件不超过 100 MiB、总量不超过 512 MiB。已有 artifact 可直接提交;普通 task 文件先逐个调用 `register_artifact(path)` 登记稳定身份。Node 以自身 Bearer 身份访问每个任务绑定的只读下载端点,流式写入本 job 的 `input//`,同时校验大小与 SHA-256;不暴露工作区路径。Node 会原子读取/补报 `terminal.json`,断线后云端把活动任务标记 `disconnected` 并保留 Node/lease,重连按 job、lease、digest 恢复下载或幂等补报终态,不自动重派。Node UI 的“本机任务”只读取已经 accept 到本机的任务目录,不查询云端未派发 Job;每个任务以原子 `state.json` 持久化 `accepted/downloading_inputs/ready_to_run/software_running/uploading_outputs/succeeded/failed/cancelled` 通用阶段,窗口再与 request、terminal、upload-complete 合并成可恢复视图。 -第四阶段落地固定 Origin Worker:Node 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录;请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance,校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,`series[]` 以 `x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin,避免无法证明的双执行。 +第四阶段落地固定 Origin Worker:Node 仅从管理员安装的固定 Python 运行时启动随程序发布的 `worker.py`,参数只有本机 job 目录;请求不能指定脚本、解释器或文件路径。Worker 使用 `originpro` 生成 OPJU、PNG、SVG、PDF、plot spec 和 provenance,校验产物签名并原子写入终态。`origin.plot@v2` 保持单一外层契约,`series[]` 以 `x/y/z/y_error` 统一表达数据角色,再按 `plot.type` 判别必需角色;当前覆盖折线、散点、线点、柱/条形、分组柱形、Y 误差棒、等高线、三维曲面、三元图和规则网格热图,并以可选的 `canvas`、轴排版、图例、标题和 series style 统一表达出版级尺寸与样式,旧 XY 请求原样兼容。进程内 pipeline 按 job 去重,并脱离单次 WebSocket 的取消令牌运行;连接中断只延迟状态/终态上报。Node 进程若在 Worker 启动后重启,则保守失败而不重复驱动 Origin,避免无法证明的双执行。 第五阶段完成输出上传与发布:Node 只按固定 manifest ID 逐项流式 PUT,并携带 Node、lease、request digest 与内容摘要;云端重新绑定任务身份,不信 Node 提供的路径或媒体类型。文件先进入用户根下隐藏暂存区,固定文件名、单文件/总大小和 SHA-256 全部验证后,把 plot spec、provenance 整理进 `.meta/`,再将完整目录原子移动到 `/origin//`。PNG/SVG/PDF/OPJU 等正式输出登记平台 artifact UUID 和 `software_job_id`,`.meta/` 只落真实文件;成功状态返回 task-relative `output_dir`,Agent 以该目录为起点按需搜索。重复 PUT、complete 和重连均按摘要幂等;部分上传不可见,只有完整集合才能发布。Origin 执行槽与上传确认是两个正交状态:本地已有终态且固定 Worker 已退出时即释放软件执行槽,成功但尚无 `upload-complete.json` 的任务继续后台补传;若云端已经是 succeeded,重复 PUT/complete 必须按数据库持久化 manifest 校验并直接确认,不得按新版本目录规则重新发布旧 Job。 diff --git a/PROGRESS.md b/PROGRESS.md index 2733925..e4837de 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -22,6 +22,8 @@ ### 2026-08-14 +- **08-14 / Unreleased / Origin 出版级排版参数**:`origin.plot@v2` 新增可选画布、轴范围/尺度/步长/刻度排版/网格、标题与图例排版,以及逐系列颜色、线型、点型和透明度;PNG 宽度按物理画布与 DPI 计算,旧请求默认行为不变。共享契约、Node 二次校验、固定 Worker 与运行文档已同步,专项 68 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 .NET build 通过;全量 608 项仅 3 个既有数据库集成模块因显式测试库缺少 `users` 表未通过,未连接或写入生产 DB。 + - **08-14 / Unreleased / 专业软件契约与语言无关 adapter 边界**:将 capability 请求 schema、输入额度、输出 manifest、feature/adapter 版本要求和兼容运行时集中到 `software-contracts/*.json`;Core 自动发现契约并通用完成校验、工具 schema、调度、上传发布,不再包含 Origin 分支。Windows Node 以单一 adapter registry 派生注册能力、配置校验、界面和运行时上报,通用 Host 只处理 job 目录协议;adapter 可由 .NET 内置或固定进程以任意语言实现,Origin 的 Python Worker 仅是当前实现。调度同时按 feature/adapter 版本选节点,并跳过队首暂不可执行 Job。专项 82 项 unittest、Python 编译、Ruff 致命规则、diff 检查与 .NET build 通过;全量 605 项仅 3 个数据库集成模块因显式测试库未迁移、缺少 `users` 表而未通过,未连接或写入生产 DB。 - **08-14 / Unreleased / Origin 统一图型契约扩展**:`origin.plot@v2` 保持单一入口,`series[]` 增加 `z/y_error` 数据角色并由 `plot.type` 判别校验,在兼容既有 XY 请求的同时新增 column、bar、grouped_column、y_error、contour、surface_3d、ternary、heatmap;规则热图在 Worker 内拒绝缺格、重复坐标、非等间距和非有限数值。云端、Agent 工具、Node 二次校验和固定 Worker 已同步,专项 61 项 unittest、Python 编译与 .NET build 通过,未连接或写入生产 DB。 diff --git a/RUN.md b/RUN.md index d725952..709c73e 100644 --- a/RUN.md +++ b/RUN.md @@ -1100,7 +1100,7 @@ install-windows-node.bat 若 Python 未加入 PATH,可把绝对路径作为第一个参数,例如 `install-windows-node.bat "C:\Python312\python.exe"`。默认解释器为 `%ProgramData%\Zcbot\WindowsNode\runtimes\origin\Scripts\python.exe`。如需使用其他受管解释器,设置机器级 `ZCBOT_ORIGIN_PYTHON` 为绝对 `python.exe` 路径后重启 Node。`node.json`、可恢复任务和 runtime 集中保存在 `%ProgramData%\Zcbot\WindowsNode\`,不会因替换程序目录而丢失。运行时固定依赖见发布目录的 `origin-worker/requirements.txt`;任务请求无权选择解释器、脚本或路径。当前 Worker 支持 CSV/XLSX/JSON 输入,`line`、`scatter`、`line_scatter` 与 OPJU/PNG/SVG/PDF 输出。成功产物由 Node 流式上传,全部校验通过后发布到任务工作目录 `origin//`,plot spec 与 provenance 位于其 `.meta/`;上传中断会在重连时幂等续传。 -Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒;停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 1–16 个输入、跨输入系列和多个显式输出,只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`,Agent可在该目录内搜索并分析;正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 +Web 用户登录后,文件栏 Job 中心会聚合本人最近任务。活动任务约 4 秒刷新一次,空闲时降为约 30 秒;停止已派发任务是协作取消,状态先显示“正在停止”,Node 在线时立即接收,断线后在下次连接或心跳时重放。Agent 可调用 `software_capability_list`、`register_artifact`、`software_job_submit`、`software_job_status` 和 `software_job_cancel`。Origin 输入必须是 artifact:已有 UUID 可直接提交,普通 task 文件先逐个用相对路径登记;登记不会发布聊天交付卡片。提交工具接收 `inputs`、`operation`、`outputs`,支持 1–16 个输入、跨输入系列和多个显式输出;`plot.canvas` 可指定毫米画布,轴可指定范围、步长、尺度、刻度角度/字号、标题字号和网格,`legend` 可控制显隐、位置和字号,`series[].style` 可控制颜色、线宽/线型、点型/点大小和透明度。所有排版字段可选,旧请求保持默认样式。任务只创建固定 v2 schema 的持久任务,不会阻塞当前对话等待完成。成功状态提供 `output_dir`,Agent可在该目录内搜索并分析;正式输出的 artifact 带 `software_job_id`,供结果卡和产物详情展示来源。Node 输出上传的逐任务诊断日志位于 `%ProgramData%\Zcbot\WindowsNode\jobs\\logs\node-output-upload.log`;日志包含上传阶段、产物文件名、重试次数和 Windows `HRESULT`,单文件达到 1 MiB 后轮转一份 `.1`,不记录 Node Token 或认证请求头。 专业软件契约位于 `software-contracts/*.json`,Core 会在启动时自动发现。新增 feature 时更新对应契约与 Node adapter;新增软件时新增契约,并只在 Windows Node 的 `NodeAdapterRegistry.CreateDefault` 注册已安装实现。通用协议不要求 adapter 使用 Python:实现可为 .NET 内置代码或由固定 runner 启动的任意语言受信进程;Origin 安装器创建 Python 3.12 runtime 只服务当前 Origin adapter。 diff --git a/software-contracts/origin.plot.v2.json b/software-contracts/origin.plot.v2.json index e0727ac..715e4d8 100644 --- a/software-contracts/origin.plot.v2.json +++ b/software-contracts/origin.plot.v2.json @@ -55,17 +55,17 @@ }, "feature_path": ["operation", "plot", "type"], "features": { - "line": "0.3.0", - "scatter": "0.3.0", - "line_scatter": "0.3.0", - "column": "0.4.0", - "bar": "0.4.0", - "grouped_column": "0.4.0", - "y_error": "0.4.0", - "contour": "0.4.0", - "surface_3d": "0.4.0", - "ternary": "0.4.0", - "heatmap": "0.4.0" + "line": "0.5.0", + "scatter": "0.5.0", + "line_scatter": "0.5.0", + "column": "0.5.0", + "bar": "0.5.0", + "grouped_column": "0.5.0", + "y_error": "0.5.0", + "contour": "0.5.0", + "surface_3d": "0.5.0", + "ternary": "0.5.0", + "heatmap": "0.5.0" }, "summary": { "title_path": ["operation", "plot", "title"] @@ -137,12 +137,23 @@ "y": {"type": "string", "minLength": 1, "maxLength": 128}, "z": {"type": "string", "minLength": 1, "maxLength": 128}, "y_error": {"type": "string", "minLength": 1, "maxLength": 128}, - "label": {"type": "string", "minLength": 1, "maxLength": 200} + "label": {"type": "string", "minLength": 1, "maxLength": 200}, + "style": {"$ref": "#/$defs/series_style"} } } }, "template": {"const": "publication_double_column"}, "title": {"type": "string", "maxLength": 500}, + "title_style": {"$ref": "#/$defs/text_style"}, + "canvas": { + "type": "object", + "required": ["width_mm", "height_mm"], + "additionalProperties": false, + "properties": { + "width_mm": {"type": "number", "minimum": 40, "maximum": 1000}, + "height_mm": {"type": "number", "minimum": 40, "maximum": 1000} + } + }, "x_axis": {"$ref": "#/$defs/axis"}, "y_axis": {"$ref": "#/$defs/axis"}, "z_axis": {"$ref": "#/$defs/axis"}, @@ -150,8 +161,11 @@ "type": "object", "additionalProperties": false, "properties": { - "enabled": {"const": true}, - "position": {"const": "top_right"} + "enabled": {"type": "boolean"}, + "position": { + "enum": ["top_left", "top_right", "bottom_left", "bottom_right"] + }, + "font_size": {"type": "number", "minimum": 6, "maximum": 72} } }, "error_bars": {"type": "null"} @@ -279,7 +293,33 @@ "properties": { "title": {"type": "string"}, "unit": {"type": "string"}, - "scale": {"const": "linear"} + "scale": {"enum": ["linear", "log10", "ln", "log2"]}, + "minimum": {"type": "number"}, + "maximum": {"type": "number"}, + "major_step": {"type": "number", "exclusiveMinimum": 0}, + "tick_label_angle": {"type": "number", "minimum": -180, "maximum": 180}, + "tick_label_font_size": {"type": "number", "minimum": 6, "maximum": 72}, + "title_font_size": {"type": "number", "minimum": 6, "maximum": 72}, + "grid": {"enum": ["none", "major", "major_minor"]} + } + }, + "text_style": { + "type": "object", + "additionalProperties": false, + "properties": { + "font_size": {"type": "number", "minimum": 6, "maximum": 72} + } + }, + "series_style": { + "type": "object", + "additionalProperties": false, + "properties": { + "color": {"type": "string", "pattern": "^#[0-9A-Fa-f]{6}$"}, + "line_width": {"type": "number", "minimum": 0.1, "maximum": 20}, + "line_style": {"enum": ["solid", "dash", "dot", "dash_dot", "dash_dot_dot"]}, + "symbol": {"enum": ["circle", "square", "triangle_up", "diamond", "cross", "plus"]}, + "symbol_size": {"type": "number", "minimum": 1, "maximum": 100}, + "transparency": {"type": "integer", "minimum": 0, "maximum": 100} } } } diff --git a/tests/test_origin_worker.py b/tests/test_origin_worker.py index 7be120d..e8e8dd7 100644 --- a/tests/test_origin_worker.py +++ b/tests/test_origin_worker.py @@ -40,6 +40,77 @@ class OriginWorkerUnitTests(unittest.TestCase): self.assertEqual(worker._axis_title({"title": "Stress", "unit": "MPa"}, "Y"), "Stress (MPa)") self.assertEqual(worker._axis_title(None, "Time"), "Time") + def test_layout_helpers_apply_validated_values(self) -> None: + class FakeAxis: + title = "" + scale = "linear" + limits = None + + def set_limits(self, begin, end, step): + self.limits = (begin, end, step) + + class FakeLayer: + def __init__(self): + self.axes = {name: FakeAxis() for name in ("x", "y", "z")} + self.values = {} + self.labels = {} + + def axis(self, name): + return self.axes[name] + + def set_float(self, name, value): + self.values[name] = value + + def set_int(self, name, value): + self.values[name] = value + + def label(self, name): + class FakeLabel: + def set_int(_, prop, value): + self.labels[(name, prop)] = value + return FakeLabel() + + layer = FakeLayer() + worker._apply_axis(layer, "x", { + "title": "Time", "unit": "d", "scale": "log10", + "minimum": 1, "maximum": 100, "major_step": 1, + "tick_label_angle": 45, "tick_label_font_size": 10, + "title_font_size": 12, "grid": "major_minor", + }, "X") + self.assertEqual(layer.axes["x"].title, "Time (d)") + self.assertEqual(layer.axes["x"].scale, "log10") + self.assertEqual(layer.axes["x"].limits, (1, 100, 1)) + self.assertEqual(layer.values, { + "x.label.rotate": 45, "x.label.pt": 10, "x.grid.show": 3, + }) + self.assertEqual(layer.labels, {("xb", "fsize"): 12}) + + def test_series_style_maps_to_origin_properties(self) -> None: + class FakePlot: + def __init__(self): + self.values = {} + self.color = None + self.symbol_kind = None + self.symbol_size = None + self.transparency = None + + def set_float(self, name, value): + self.values[name] = value + + def set_int(self, name, value): + self.values[name] = value + + plot = FakePlot() + worker._apply_series_style(plot, { + "color": "#3366CC", "line_width": 1.5, "line_style": "dash_dot", + "symbol": "diamond", "symbol_size": 8, "transparency": 20, + }) + 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_size, 8) + self.assertEqual(plot.transparency, 20) + def test_keyed_input_directory_requires_exactly_one_file(self) -> None: with tempfile.TemporaryDirectory() as directory: root = Path(directory) diff --git a/tests/test_software_contracts.py b/tests/test_software_contracts.py index bba4037..7c0223e 100644 --- a/tests/test_software_contracts.py +++ b/tests/test_software_contracts.py @@ -45,14 +45,14 @@ class SoftwareContractTests(unittest.TestCase): "available_slots": 1, "origin": {"health": "ready", "adapter_version": "0.3.0"}, } - self.assertTrue(node_supports_request(contract, _request("line"), legacy_runtime)) + self.assertFalse(node_supports_request(contract, _request("line"), legacy_runtime)) self.assertFalse(node_supports_request(contract, _request("heatmap"), legacy_runtime)) current_runtime = { "capability_runtime": { "origin.plot@v2": { "health": "ready", "available_slots": 1, - "adapter_version": "0.4.0", + "adapter_version": "0.5.0", "features": ["line", "heatmap"], } } diff --git a/tests/test_software_nodes.py b/tests/test_software_nodes.py index 8e1b64b..f186c38 100644 --- a/tests/test_software_nodes.py +++ b/tests/test_software_nodes.py @@ -18,8 +18,8 @@ from core.software_jobs import ( abandon_offer, list_jobs, mark_node_jobs_disconnected, - record_job_terminal, offer_next_job, + record_job_terminal, replay_succeeded_outputs, request_job_cancel, respond_to_offer, @@ -324,8 +324,8 @@ class SoftwareJobProtocolTests(unittest.TestCase): base_plot = request["operation"]["plot"] for case_plot in ( {**base_plot, "template": "custom"}, - {**base_plot, "x_axis": {"scale": "log10"}}, - {**base_plot, "legend": {"enabled": False}}, + {**base_plot, "x_axis": {"scale": "symlog"}}, + {**base_plot, "legend": {"position": "outside"}}, {**base_plot, "series": [base_plot["series"][0], base_plot["series"][0]]}, ): with self.subTest(plot=case_plot), self.assertRaisesRegex( @@ -347,6 +347,41 @@ class SoftwareJobProtocolTests(unittest.TestCase): {**request, "outputs": [{"key": "project", "type": "figure", "format": "png"}]} ) + def test_origin_request_accepts_publication_layout_options(self) -> None: + request = { + "schema_version": 2, + "inputs": [{"key": "sample", "artifact_id": str(uuid4())}], + "operation": {"plot": { + "type": "line_scatter", + "series": [{ + "input": "sample", "x": "time", "y": "strength", + "style": { + "color": "#3366CC", "line_width": 1.5, + "line_style": "dash", "symbol": "circle", + "symbol_size": 8, "transparency": 10, + }, + }], + "title": "Strength development", + "title_style": {"font_size": 16}, + "canvas": {"width_mm": 180, "height_mm": 120}, + "x_axis": { + "scale": "log10", "minimum": 1, "maximum": 100, + "major_step": 1, "tick_label_angle": 45, + "tick_label_font_size": 10, "title_font_size": 12, + "grid": "major", + }, + "y_axis": {"minimum": 0, "maximum": 80, "major_step": 10}, + "legend": {"enabled": False, "position": "top_left", "font_size": 9}, + }}, + "outputs": [{ + "key": "figure_png", "type": "figure", "format": "png", + "options": {"dpi": 600}, + }], + } + normalized, digest = _canonical_request("origin.plot@v2", request) + self.assertEqual(normalized, request) + self.assertEqual(len(digest), 64) + def test_origin_request_supports_unified_series_roles(self) -> None: artifact_id = str(uuid4()) outputs = [{"key": "figure_png", "type": "figure", "format": "png"}] @@ -459,10 +494,10 @@ class SoftwareJobProtocolTests(unittest.TestCase): node = type("Node", (), {})() node.node_id = uuid4() node.capabilities = ["origin.plot@v2"] - node.runtime = { - "available_slots": 1, - "origin": {"health": "ready", "adapter_version": "0.3.0"}, - } + node.runtime = {"capability_runtime": {"origin.plot@v2": { + "health": "ready", "available_slots": 1, "adapter_version": "0.5.0", + "features": ["line"], + }}} jobs = [] for plot_type in ("heatmap", "line"): job = type("Job", (), {})() diff --git a/windows-node/README.md b/windows-node/README.md index 49e4554..968739b 100644 --- a/windows-node/README.md +++ b/windows-node/README.md @@ -12,7 +12,7 @@ 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`,生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格。运行时独立于 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`,并支持毫米画布、轴范围/尺度/刻度排版、标题、图例及逐系列样式,生成 OPJU、PNG、SVG、PDF、plot spec、provenance 与原子 `terminal.json`。热图输入必须是完整、等间距且坐标不重复的规则 XYZ 网格。运行时独立于 zcbot 服务端 Python。发布目录包含统一安装入口;使用实际运行 Node 的专用 Windows 账号直接双击: ```text install-windows-node.bat diff --git a/windows-node/Zcbot.WindowsNode/JobInboxStore.cs b/windows-node/Zcbot.WindowsNode/JobInboxStore.cs index f404b03..bda7693 100644 --- a/windows-node/Zcbot.WindowsNode/JobInboxStore.cs +++ b/windows-node/Zcbot.WindowsNode/JobInboxStore.cs @@ -509,7 +509,7 @@ internal sealed class JobInboxStore(string jobsDirectory) if (plot.ValueKind != JsonValueKind.Object || !HasOnlyProperties( plot, "type", "series", "template", "title", "x_axis", "y_axis", "z_axis", - "legend", "error_bars") + "legend", "error_bars", "title_style", "canvas") || !plot.TryGetProperty("type", out var plotType) || plotType.GetString() is not { } plotTypeName || !supportedPlotTypes.Contains(plotTypeName, StringComparer.Ordinal) @@ -520,6 +520,8 @@ internal sealed class JobInboxStore(string jobsDirectory) || series.GetArrayLength() is < 1 or > 16 || plot.TryGetProperty("template", out var template) && template.GetString() != "publication_double_column" + || !IsValidTextStyle(plot, "title_style") + || !IsValidCanvas(plot) || !IsValidAxis(plot, "x_axis") || !IsValidAxis(plot, "y_axis") || !IsValidAxis(plot, "z_axis") @@ -548,7 +550,7 @@ internal sealed class JobInboxStore(string jobsDirectory) foreach (var item in series.EnumerateArray()) { if (item.ValueKind != JsonValueKind.Object - || !HasOnlyProperties(item, "input", "x", "y", "z", "y_error", "label") + || !HasOnlyProperties(item, "input", "x", "y", "z", "y_error", "label", "style") || !item.TryGetProperty("input", out var input) || input.GetString() is not { } inputKey || !inputKeys.Contains(inputKey) @@ -559,6 +561,7 @@ internal sealed class JobInboxStore(string jobsDirectory) || item.TryGetProperty("label", out var label) && (label.ValueKind != JsonValueKind.String || label.GetString()!.Length is < 1 or > 200) + || !IsValidSeriesStyle(item) || !identities.Add(string.Join( "\0", new[] { inputKey }.Concat(requiredRoles.Select( role => item.GetProperty(role).GetString()!))))) @@ -603,24 +606,101 @@ internal sealed class JobInboxStore(string jobsDirectory) private static bool IsValidAxis(JsonElement plot, string name) { if (!plot.TryGetProperty(name, out var axis)) return true; - return axis.ValueKind == JsonValueKind.Object - && HasOnlyProperties(axis, "title", "unit", "scale") - && (!axis.TryGetProperty("title", out var title) || title.ValueKind == JsonValueKind.String) - && (!axis.TryGetProperty("unit", out var unit) || unit.ValueKind == JsonValueKind.String) - && (!axis.TryGetProperty("scale", out var scale) || scale.GetString() == "linear"); + if (axis.ValueKind != JsonValueKind.Object + || !HasOnlyProperties( + axis, "title", "unit", "scale", "minimum", "maximum", "major_step", + "tick_label_angle", "tick_label_font_size", "title_font_size", "grid")) + { + return false; + } + if (axis.TryGetProperty("title", out var titleValue) + && titleValue.ValueKind != JsonValueKind.String + || axis.TryGetProperty("unit", out var unitValue) + && unitValue.ValueKind != JsonValueKind.String + || axis.TryGetProperty("scale", out var scale) + && scale.GetString() is not ("linear" or "log10" or "ln" or "log2") + || axis.TryGetProperty("grid", out var grid) + && grid.GetString() is not ("none" or "major" or "major_minor") + || !IsOptionalNumber(axis, "minimum") + || !IsOptionalNumber(axis, "maximum") + || !IsOptionalNumber(axis, "major_step", 0, double.PositiveInfinity, false) + || !IsOptionalNumber(axis, "tick_label_angle", -180, 180) + || !IsOptionalNumber(axis, "tick_label_font_size", 6, 72) + || !IsOptionalNumber(axis, "title_font_size", 6, 72)) + { + return false; + } + return !axis.TryGetProperty("minimum", out var minimum) + || !axis.TryGetProperty("maximum", out var maximum) + || minimum.GetDouble() < maximum.GetDouble(); } private static bool IsValidLegend(JsonElement plot) { if (!plot.TryGetProperty("legend", out var legend)) return true; return legend.ValueKind == JsonValueKind.Object - && HasOnlyProperties(legend, "enabled", "position") + && HasOnlyProperties(legend, "enabled", "position", "font_size") && (!legend.TryGetProperty("enabled", out var enabled) - || enabled.ValueKind == JsonValueKind.True) + || enabled.ValueKind is JsonValueKind.True or JsonValueKind.False) && (!legend.TryGetProperty("position", out var position) - || position.GetString() == "top_right"); + || position.GetString() is "top_left" or "top_right" or "bottom_left" or "bottom_right") + && IsOptionalNumber(legend, "font_size", 6, 72); } + private static bool IsValidTextStyle(JsonElement parent, string name) => + !parent.TryGetProperty(name, out var style) + || style.ValueKind == JsonValueKind.Object + && HasOnlyProperties(style, "font_size") + && IsOptionalNumber(style, "font_size", 6, 72); + + private static bool IsValidCanvas(JsonElement plot) => + !plot.TryGetProperty("canvas", out var canvas) + || canvas.ValueKind == JsonValueKind.Object + && HasOnlyProperties(canvas, "width_mm", "height_mm") + && canvas.TryGetProperty("width_mm", out _) + && canvas.TryGetProperty("height_mm", out _) + && IsOptionalNumber(canvas, "width_mm", 40, 1000) + && IsOptionalNumber(canvas, "height_mm", 40, 1000); + + private static bool IsValidSeriesStyle(JsonElement series) + { + if (!series.TryGetProperty("style", out var style)) return true; + return style.ValueKind == JsonValueKind.Object + && HasOnlyProperties( + style, "color", "line_width", "line_style", "symbol", "symbol_size", + "transparency") + && (!style.TryGetProperty("color", out var color) + || color.ValueKind == JsonValueKind.String && IsHexColor(color.GetString()!)) + && (!style.TryGetProperty("line_style", out var lineStyle) + || lineStyle.GetString() is "solid" or "dash" or "dot" or "dash_dot" or "dash_dot_dot") + && (!style.TryGetProperty("symbol", out var symbol) + || symbol.GetString() is "circle" or "square" or "triangle_up" or "diamond" or "cross" or "plus") + && IsOptionalNumber(style, "line_width", 0.1, 20) + && IsOptionalNumber(style, "symbol_size", 1, 100) + && (!style.TryGetProperty("transparency", out var transparency) + || transparency.TryGetInt32(out var value) && value is >= 0 and <= 100); + } + + private static bool IsOptionalNumber( + JsonElement parent, string name, double minimum = double.NegativeInfinity, + double maximum = double.PositiveInfinity, bool inclusiveMinimum = true) + { + if (!parent.TryGetProperty(name, out var value)) return true; + if (value.ValueKind != JsonValueKind.Number || !value.TryGetDouble(out var number) + || double.IsNaN(number) || double.IsInfinity(number)) + { + return false; + } + return (inclusiveMinimum ? number >= minimum : number > minimum) && number <= maximum; + } + + private static bool IsHexColor(string value) => + value.Length == 7 && value[0] == '#' + && value.Skip(1).All(character => + character is >= '0' and <= '9' + || character is >= 'a' and <= 'f' + || character is >= 'A' and <= 'F'); + private static bool IsValidInputTransfers(JsonElement transfers, Guid jobId) { if (transfers.ValueKind != JsonValueKind.Array diff --git a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs index cb732a9..08ac36a 100644 --- a/windows-node/Zcbot.WindowsNode/NodeAdapters.cs +++ b/windows-node/Zcbot.WindowsNode/NodeAdapters.cs @@ -18,7 +18,7 @@ internal interface INodeAdapter internal sealed class OriginPlotNodeAdapter(JobInboxStore inbox) : INodeAdapter { - internal const string CurrentAdapterVersion = "0.4.0"; + internal const string CurrentAdapterVersion = "0.5.0"; internal const string ContractFilename = "origin.plot.v2.json"; private static readonly NodeAdapterContract Contract = NodeAdapterContract.Load(ContractFilename, CurrentAdapterVersion); diff --git a/windows-node/origin-worker/worker.py b/windows-node/origin-worker/worker.py index 4939d10..443dfe3 100644 --- a/windows-node/origin-worker/worker.py +++ b/windows-node/origin-worker/worker.py @@ -31,6 +31,27 @@ PLOT_CONFIG = { } XYZ_PLOT_TYPES = {"contour", "surface_3d", "ternary", "heatmap"} FORMATS = {"opju", "png", "svg", "pdf"} +LINE_STYLES = { + "solid": 1, + "dash": 2, + "dot": 3, + "dash_dot": 4, + "dash_dot_dot": 5, +} +SYMBOLS = { + "square": 0, + "circle": 1, + "triangle_up": 2, + "diamond": 3, + "cross": 9, + "plus": 10, +} +LEGEND_POSITIONS = { + "top_left": (700, 500), + "top_right": (6800, 500), + "bottom_left": (700, 7200), + "bottom_right": (6800, 7200), +} def _atomic_json(path: Path, value: Any) -> None: @@ -117,6 +138,71 @@ def _axis_title(axis: Any, fallback: str) -> str: return f"{title} ({unit})" if unit else title +def _hex_color(value: str) -> tuple[int, int, int]: + return tuple(int(value[index:index + 2], 16) for index in (1, 3, 5)) + + +def _apply_canvas(graph: Any, canvas: Any) -> None: + if not isinstance(canvas, dict): + return + width = float(canvas["width_mm"]) + height = float(canvas["height_mm"]) + graph.set_float("width", width / 25.4 * graph.get_float("resx")) + graph.set_float("height", height / 25.4 * graph.get_float("resy")) + + +def _apply_axis(layer: Any, name: str, spec: Any, fallback: str) -> None: + settings = spec if isinstance(spec, dict) else {} + axis = layer.axis(name) + axis.title = _axis_title(settings, fallback) + if "scale" in settings: + axis.scale = settings["scale"] + if any(key in settings for key in ("minimum", "maximum", "major_step")): + axis.set_limits( + settings.get("minimum"), settings.get("maximum"), settings.get("major_step") + ) + if "tick_label_angle" in settings: + layer.set_float(f"{name}.label.rotate", float(settings["tick_label_angle"])) + if "tick_label_font_size" in settings: + layer.set_float(f"{name}.label.pt", float(settings["tick_label_font_size"])) + if "title_font_size" in settings: + title_object = {"x": "xb", "y": "yl", "z": "zf"}[name] + layer.label(title_object).set_int("fsize", round(settings["title_font_size"])) + if "grid" in settings: + grid_value = {"none": 0, "major": 1, "major_minor": 3}[settings["grid"]] + layer.set_int(f"{name}.grid.show", grid_value) + + +def _apply_series_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 "line_style" in style: + origin_plot.set_int("line.type", LINE_STYLES[style["line_style"]]) + if "symbol" in style: + origin_plot.symbol_kind = SYMBOLS[style["symbol"]] + if "symbol_size" in style: + origin_plot.symbol_size = float(style["symbol_size"]) + if "transparency" in style: + origin_plot.transparency = int(style["transparency"]) + + +def _apply_legend(layer: Any, legend: Any) -> None: + if not isinstance(legend, dict): + return + label = layer.label("Legend") + label.show = legend.get("enabled", True) + if "font_size" in legend: + label.set_int("fsize", round(legend["font_size"])) + if "position" in legend: + left, top = LEGEND_POSITIONS[legend["position"]] + label.set_int("left", left) + label.set_int("top", top) + + 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(".")] @@ -266,11 +352,12 @@ def run(job_dir: Path) -> list[dict[str, Any]]: ) graph = op.new_graph(template="heatmap") layer = graph[0] - layer.add_mplot(matrix_sheet, 0, type=105) + origin_plots = [layer.add_mplot(matrix_sheet, 0, type=105)] else: template, origin_plot_type = PLOT_CONFIG[plot_type] graph = op.new_graph(template=template) layer = graph[0] + origin_plots = [] for resolved in resolved_series: arguments = { "coly": resolved["y"], @@ -281,21 +368,26 @@ def run(job_dir: Path) -> list[dict[str, Any]]: arguments["colz"] = resolved["z"] if plot_type == "y_error": arguments["colyerr"] = resolved["y_error"] - layer.add_plot(worksheets[resolved["input"]], **arguments) + origin_plots.append(layer.add_plot(worksheets[resolved["input"]], **arguments)) if plot_type == "grouped_column": layer.group() layer.rescale() - layer.axis("x").title = _axis_title( - plot_spec.get("x_axis"), str(series_specs[0].get("x") or "X") + _apply_canvas(graph, plot_spec.get("canvas")) + _apply_axis( + layer, "x", plot_spec.get("x_axis"), str(series_specs[0].get("x") or "X") ) - layer.axis("y").title = _axis_title(plot_spec.get("y_axis"), "Y") + _apply_axis(layer, "y", plot_spec.get("y_axis"), "Y") if plot_type == "surface_3d": - layer.axis("z").title = _axis_title( - plot_spec.get("z_axis"), str(series_specs[0].get("z") or "Z") + _apply_axis( + layer, "z", 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): + _apply_series_style(origin_plot, series_spec.get("style")) + _apply_legend(layer, plot_spec.get("legend")) if plot_spec.get("title"): title = layer.add_label(str(plot_spec["title"])) - title.set_int("fsize", 18) + title_font_size = (plot_spec.get("title_style") or {}).get("font_size", 18) + title.set_int("fsize", round(title_font_size)) title.set_int("left", 2200) title.set_int("top", 120) requested_outputs = request["outputs"] @@ -313,7 +405,8 @@ def run(job_dir: Path) -> list[dict[str, Any]]: dpi = (png_output.get("options") or {}).get("dpi", 300) if png_output else 300 if not isinstance(dpi, int) or isinstance(dpi, bool) or not 72 <= dpi <= 1200: raise ValueError("OUTPUT_DPI_INVALID") - pixel_width = round(dpi * 160 / 25.4) + canvas_width_mm = (plot_spec.get("canvas") or {}).get("width_mm", 160) + pixel_width = round(dpi * canvas_width_mm / 25.4) for extension in ("png", "svg", "pdf"): if extension in formats: target = output / f"figure.{extension}" @@ -332,7 +425,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]: except PackageNotFoundError: originpro_version = "embedded" provenance = { - "adapter_version": "0.4.0", + "adapter_version": "0.5.0", "originpro_version": originpro_version, "request_digest": request_record["request_digest"], "inputs": [ @@ -346,6 +439,7 @@ def run(job_dir: Path) -> list[dict[str, Any]]: "outputs": requested_outputs, "requested_dpi": dpi if png_output else None, "png_pixel_width": pixel_width if png_output else None, + "canvas_mm": plot_spec.get("canvas"), } plot_spec_path = output / "plot-spec.json" provenance_path = output / "provenance.json"