dsh-matlab-workbench 0.0.0-stage → 0.1.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DSH-MATLAB-Workbench contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.en.md ADDED
@@ -0,0 +1,57 @@
1
+ # DSH-MATLAB-Workbench
2
+
3
+ [简体中文](README.md) · [Installation](#installation) · [Real PID demo](#real-pid-sweep-demo)
4
+
5
+ An independent **DSH Desktop** community plugin (`v0.1.0-beta.2`) for MATLAB experiments. The Agent executes code through the **official MathWorks MATLAB MCP Server**; Workbench prepares session-owned experiments, verifies MATLAB-generated PNG/CSV/numeric metrics, attaches images to the conversation, and displays PID sweep comparisons. It does not bundle MATLAB, replace the official MCP server, or estimate precise metrics from image pixels.
6
+
7
+ **Beyond a generic MCP bridge:** unique immutable experiment IDs; PNG CRC and SHA-256 integrity checks; MATLAB-sourced metrics; explicit Agent `read_image` attachments; PID gain validation; progress, error and uncertain-outcome states; individual plots and overlays.
8
+
9
+ **Beta v0.1.0-beta.2 evidence:** On October 25, 2026, one real DSH Desktop 0.2.1-rc.2 session used official MATLAB MCP v0.15.0 with MATLAB R2025b, generated 3 runs / 4 PNGs, verified CSV and metrics, displayed the comparison tabs and supported Agent analysis. **A fresh-user installation of the portable connection example below has not yet been verified.** Historical M1 acceptance remains **FAIL** due to unresolved lifecycle issues; see [known limitations](docs/known-limitations.md).
10
+
11
+ ## Requirements
12
+
13
+ - Windows x64 and DSH Desktop. Tested: 0.2.1-rc.2. The manifest also allows 0.2.1-rc.3, whose real Desktop installation has **not** been tested.
14
+ - An independently licensed, installed MATLAB. Tested: **R2025b**. Consult [MathWorks](https://github.com/matlab/matlab-mcp-server) for the MCP server's wider MATLAB version support. This example uses base MATLAB `ode45`, with no Control System Toolbox.
15
+ - The separately installed [official MATLAB MCP Server](https://github.com/matlab/matlab-mcp-server/releases) (tested **v0.15.0**). Neither its binary nor MATLAB or a license is distributed with this plugin.
16
+ - A DSH Agent that exposes `evaluate_matlab_code`, `run_matlab_file`, and the host's `read_image` attachment tool.
17
+
18
+ ## Installation
19
+
20
+ 1. In an environment with the DSH CLI installed and the target profile initialized, run `npx @deepseek-ai/dsh plugin --profile desktop add dsh-matlab-workbench@beta`. This installs the package selected by the npm `beta` dist-tag. Replace `desktop` if your DSH Desktop uses a different profile name. A local source directory remains useful for development, but is not evidence of the published-package install.
21
+ 2. Install the official Windows binary named `matlab-mcp-server-windows-x64.exe` from its v0.15.0 release; put its directory and MATLAB's `bin` directory on the PATH seen by Desktop. Test from the same Windows environment with `matlab-mcp-server-windows-x64.exe --version` and `matlab -batch "disp(version)"`. Restart Desktop normally **only after saving your active session** if PATH changes need to be picked up.
22
+ 3. **If there is no existing MATLAB MCP connection:** in Desktop **Settings → Plugins**, use the local-directory installer for `integration/matlab-mcp-gate`. This editable, public [connection example](integration/matlab-mcp-gate/cordis.patch.yml) registers **one** `matlab` stdio server using the official executable on PATH, in `new/nodesktop` mode. It contains no private or machine-specific paths. **Skip this step if a `matlab` server is already registered.** To customize binary paths/flags, copy the example into your own configuration directory and edit that copy; do not patch DSH core.
23
+ 4. Activate the installed bundle through the DSH Desktop profile/plugin workflow. Bundle installation or updates may require a normal Desktop restart; save active sessions first and do not register a second `matlab` MCP connection. This Beta has not yet passed a fresh Desktop installation test.
24
+ 5. Confirm the Agent sees `evaluate_matlab_code`, `run_matlab_file`, `dsh_matlab_prepare_sweep` and `dsh_matlab_collect_sweep`; check for **PID Input**, **MATLAB Figures**, and **PID Experiments** tabs. Use a session workspace with local write access. On startup failure, inspect the server executable/PATH and existing `matlab` registrations before changing configuration. Avoid automatically retrying MATLAB writes.
25
+
26
+ The one-machine M3 test used a private Gate wiring configuration, but **users do not depend on that setup**. The public connection example uses the same DSH MCP client settings format; **it has not yet passed a clean-machine installation test**. This plugin does not edit the Desktop profile, MATLAB `startup.m`, or unrelated plugins automatically.
27
+
28
+ ## Real PID sweep demo
29
+
30
+ In **PID Input**, enter one `Kp,Ki,Kd` set per line, validate, and send the generated Agent message in the **same Desktop session**:
31
+
32
+ ```text
33
+ 1,0.5,0.2
34
+ 3,1,0.5
35
+ 2,0.8,0.9
36
+ ```
37
+
38
+ The Agent runs `dsh_matlab_prepare_sweep`, executes the returned `setupCode` and `executeCode` with the **official** `evaluate_matlab_code`, confirms the completion marker, calls `dsh_matlab_collect_sweep` to verify all CSV/metrics/plots and the overlay, then calls `read_image` for **every** returned PNG. Preparation is not execution. Unverified/incomplete sweeps must not appear as completed comparisons.
39
+
40
+ Actual MATLAB R2025b metrics from the October 25, 2026, real Desktop demonstration, for a 12-second simulation window:
41
+
42
+ | Kp, Ki, Kd | IAE ↓ | ISE | 2% settling | Overshoot |
43
+ | --- | ---: | ---: | ---: | ---: |
44
+ | 1, 0.5, 0.2 | 1.9249665 | 0.6647999 | Undetermined in window | 0% |
45
+ | 3, 1, 0.5 | **0.9647213** | 0.2703758 | 9.10 s | 0% |
46
+ | 2, 0.8, 0.9 | 1.2256675 | 0.4373808 | 8.71 s | 0% |
47
+
48
+ Controller 2 has the lowest window IAE; controller 3 settles sooner. The fixed example plant is `y''+2ζωy'+ω²y=ω²u` (ζ=0.48, ω=3 rad/s), integrated with base MATLAB `ode45`. These are not hardware results or universal tuning recommendations. Session outputs are stored in `.dsh-matlab/`; do not publish the entire private session workspace.
49
+
50
+ ## Development, feedback, limits
51
+
52
+ Run `npm run check` for syntax and `npm test` for Node regression tests using synthetic, non-private fixtures. `npm run test:matlab:m2` is an optional **standalone MATLAB** integration test (MATLAB on PATH or `MATLAB_EXE`) and is **not** a Desktop MCP acceptance test. Node 24 is the tested development environment; the plugin uses no third-party runtime npm packages.
53
+
54
+ Please file issues through the project's GitHub **Issues** page with sanitized reproduction steps and DSH/MATLAB/MCP version numbers, never tokens or private session logs. Suggested GitHub topics: `dsh-plugin`, `deepseek-harness`, `matlab`, `pid`, `simulation`. This is an **unofficial community project** unaffiliated with and not endorsed by DSH/DeepSeek or MathWorks. MATLAB® is a MathWorks trademark; users need their own MATLAB authorization and must follow its license. Project code: [MIT](LICENSE).
55
+
56
+ **Known limitations:** the historical M1 disable/tab lifecycle failure remains unresolved; `link:` uninstallation can leave a junction; cancelling or timing out a MATLAB MCP request does not prove execution stopped. See [known limitations and evidence scope](docs/known-limitations.md). Only one Windows/DSH/MATLAB combination has been verified. `nodesktop` does not guarantee that all MATLAB graphics commands avoid windows.
57
+
package/README.md CHANGED
@@ -1,3 +1,57 @@
1
- # Temporary Holding Version
1
+ # DSH-MATLAB-Workbench
2
+
3
+ [English](README.en.md) · [安装 / Installation](#安装) · [真实 PID Demo](#真实-pid-实验-demo)
4
+
5
+ 面向 **DSH Desktop** 的 MATLAB 工程实验工作台(社区插件,`v0.1.0-beta.2`)。Agent 通过 **MathWorks 官方 MATLAB MCP Server** 执行 MATLAB;本插件在同一 DSH 会话中准备实验、核验 MATLAB 产出的 PNG/CSV/数值指标、显示实验曲线和 PID 参数扫描对比。它不内置 MATLAB、不代替官方 MCP,也不会把图片中的像素当成精确指标。
6
+
7
+ **与通用 MATLAB MCP 桥接的区别:** 每次实验使用独立且不可覆盖的 ID;对图像做 PNG/CRC/SHA-256 校验,核对 CSV 和 MATLAB 指标来源;通过 `read_image` 让 Agent 真正获取图片附件;Desktop 有参数输入、运行/核验/加载/失败状态、单组曲线和叠加对比。Agent 仍需明确执行和检查每一步。
8
+
9
+ **Beta 状态(v0.1.0-beta.2):** 真实 DSH Desktop(0.2.1-rc.2)→ 官方 MATLAB MCP(v0.15.0)→ MATLAB R2025b → 3 组 PID / 4 张 PNG / CSV / 指标 → Desktop 展示及 Agent 分析,已于 2026-10-25 在一台开发机完成。**下面面向新用户的独立安装流程尚未在全新环境验收**,不能据此保证所有 DSH/MATLAB 组合可用。历史 M1 正式验收为 **FAIL**;[已知限制](docs/known-limitations.md)。
10
+
11
+ ## 前置条件
12
+
13
+ - Windows x64、DSH Desktop;已验证版本为 DSH Desktop 0.2.1-rc.2(`package.json` 声明兼容 0.2.1-rc.2/rc.3,但 rc.3 尚未实机验证)。
14
+ - 有效的 MATLAB 授权与可执行的 MATLAB;实测为 **R2025b**。官方 MCP Server 支持的 MATLAB 版本范围请以 [MathWorks 文档](https://github.com/matlab/matlab-mcp-server) 为准。此仓库的示例只验证 R2025b;不需要 Control System Toolbox。
15
+ - 单独安装 [官方 MATLAB MCP Server](https://github.com/matlab/matlab-mcp-server/releases)(实测 **v0.15.0**)。它和 MATLAB 本体及许可证均**不随插件分发**。
16
+ - Agent 必须能调用 `evaluate_matlab_code` / `run_matlab_file`,并支持 DSH 的 `read_image` 图片附件。
17
+
18
+ ## 安装
19
+
20
+ 1. 在已安装 DSH CLI 且已初始化目标 profile 的环境运行 `npx @deepseek-ai/dsh plugin --profile desktop add dsh-matlab-workbench@beta`。此命令从 npm 安装 Beta dist-tag 指向的包;如你的 DSH Desktop 使用其他 profile 名称,请把 `desktop` 替换为该名称。源码目录安装仍可用于开发,不是本次发布的安装验收证据。
21
+ 2. 在 Windows 安装官方 MATLAB MCP Server v0.15.0 的 `matlab-mcp-server-windows-x64.exe`。将其所在目录与 MATLAB 的 `bin` 目录加入当前 Desktop 可见的 `PATH`。可先从同一 Windows 环境检查 `matlab-mcp-server-windows-x64.exe --version` 和 `matlab -batch "disp(version)"`。PATH 改动可能要重新启动 Desktop 才能生效;**保存当前会话后**再正常重启。
22
+ 3. **只有 Desktop 尚未连接 MATLAB MCP 时**,在 Desktop **设置 → 插件**的本地目录安装入口选取 `integration/matlab-mcp-gate`。这是开源的可编辑**连接示例**,使用 PATH 上的官方 MCP,可查看 [连接配置](integration/matlab-mcp-gate/cordis.patch.yml),默认 `new/nodesktop`;它不含私有文件路径。已有一个 `matlab` server 的用户**跳过此步**,避免重复注册。需要定制参数时,先复制该示例到自己的配置目录后修改(例如绝对程序路径),不要改 DSH 核心。
23
+ 4. 安装后按 DSH Desktop 的 profile/插件管理流程激活该 bundle。Bundle 安装或更新可能需要正常重启 Desktop;先保存会话,且不要重复添加 `matlab` MCP 连接。此 Beta 尚未完成全新 Desktop 环境验收。
24
+ 5. 确认当前 Agent 的可用工具中出现 `evaluate_matlab_code`、`run_matlab_file`、`dsh_matlab_prepare_sweep`、`dsh_matlab_collect_sweep`,以及 Workbench 的「PID 参数准备」「MATLAB 图像」「PID 实验对比」页签。在有写入权限的本地工作区开启会话。若有 MCP 启动错误,检查二进制和 MATLAB 的 PATH、Windows 进程环境及是否重复注册;不要盲目重复安装或重试有写入的 MATLAB 调用。
25
+
26
+ **注意:** 本机测试曾使用独立的私有 Gate 接线与本地 `link:`;这不是新用户的安装依赖。上面的可移植连接示例是按相同 DSH MCP Client 配置格式提供,**尚未在一台全新 Desktop 做独立安装验收**。本项目不自动修改 Desktop profile、MATLAB `startup.m` 或其他插件。
27
+
28
+ ## 真实 PID 实验 Demo
29
+
30
+ 打开「PID 参数准备」,每行输入一组 `Kp,Ki,Kd`,然后单击「检查并生成 Agent 指令」,把生成的消息发到**同一会话**:
31
+
32
+ ```text
33
+ 1,0.5,0.2
34
+ 3,1,0.5
35
+ 2,0.8,0.9
36
+ ```
37
+
38
+ Agent 调 `dsh_matlab_prepare_sweep` 得到 `setupCode`、`executeCode`、`sweepId`;经**官方** `evaluate_matlab_code` 执行代码,检查完成标记;再调 `dsh_matlab_collect_sweep` 验证 3 组 CSV/指标/PNG 和叠加图;最后对返回的**每张** PNG 调 `read_image`。Desktop 页面随后按会话展示曲线与对比指标。准备阶段不会执行 MATLAB;未核验完整产物不能视为成功。
39
+
40
+ 以下是 2026-10-25 **真实 MATLAB R2025b 运行**产生的 12 秒窗口示例指标(不是 AI 看图估算):
41
+
42
+ | Kp, Ki, Kd | IAE ↓ | ISE | 2% 调节时间 | 超调 |
43
+ | --- | ---: | ---: | ---: | ---: |
44
+ | 1, 0.5, 0.2 | 1.9249665 | 0.6647999 | 窗口内未确定 | 0% |
45
+ | 3, 1, 0.5 | **0.9647213** | 0.2703758 | 9.10 s | 0% |
46
+ | 2, 0.8, 0.9 | 1.2256675 | 0.4373808 | 8.71 s | 0% |
47
+
48
+ 组 2 在本次窗口内 IAE 最低,组 3 调节更快。示例使用 MATLAB `ode45`,对象 `y''+2ζωy'+ω²y=ω²u`(ζ=0.48,ω=3 rad/s),并非通用 PID 自动整定;结果不代表真实硬件性能。结果文件位于会话工作区的 `.dsh-matlab/`,请勿上传包含个人会话数据的整个工作区。
49
+
50
+ ## 开发、反馈与限制
51
+
52
+ `npm run check` 执行源码语法检查,`npm test` 执行不依赖私有验收图的 Node 测试;`npm run test:matlab:m2` 是可选的独立 MATLAB 测试(本机 MATLAB 需在 PATH,可设 `MATLAB_EXE`),**无法替代 Desktop MCP 端到端验收**。Node 24 是开发测试基线,插件运行本身没有第三方 npm 依赖。
53
+
54
+ 问题反馈请使用本项目 GitHub 仓库的 **Issues**,注明 DSH Desktop / MATLAB / MCP 版本、所见步骤及脱敏错误,不上传会话、令牌或私有日志。仓库建议 Topics:`dsh-plugin`、`deepseek-harness`、`matlab`、`pid`、`simulation`。社区项目,**非 DeepSeek/DSH 或 MathWorks 官方产品,不获其背书**。MATLAB® 属于 MathWorks;使用者须自行遵守 MATLAB 授权条款。本项目源码采用 [MIT](LICENSE)。
55
+
56
+ **已知限制:** M1 历史生命周期/停用页签残留未明确根因,部分目录链接卸载后可能遗留 junction;取消或超时并不证明 MATLAB 已停止,不能自动重跑同一有写入任务。见 [限制与验收事实](docs/known-limitations.md)。测试中仅验证一套本机组合;`nodesktop` 不保证所有 MATLAB 图形操作都无窗口。
2
57
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
@@ -0,0 +1,4 @@
1
+ - insert:
2
+ - id: dsh-matlab-workbench
3
+ name: dsh-matlab-workbench
4
+ config: {}
@@ -0,0 +1,127 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { randomUUID, createHash } from 'node:crypto';
4
+ import { crc32 } from 'node:zlib';
5
+
6
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
7
+ const hash = bytes => createHash('sha256').update(bytes).digest('hex');
8
+ function pngDimensions(bytes) {
9
+ if(bytes.length<33||!bytes.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))throw Error('Invalid PNG signature');
10
+ let offset=8,first=true,idatBytes=0,ended=false,width=0,height=0;
11
+ while(offset<bytes.length) {
12
+ if(bytes.length-offset<12)throw Error('Truncated PNG chunk');
13
+ const length=bytes.readUInt32BE(offset),end=offset+12+length;
14
+ if(end>bytes.length)throw Error('Truncated PNG data');
15
+ const type=bytes.toString('ascii',offset+4,offset+8);
16
+ if(!/^[A-Za-z]{4}$/.test(type)||crc32(bytes.subarray(offset+4,end-4))!==bytes.readUInt32BE(end-4))throw Error('Invalid PNG chunk CRC');
17
+ if(first) {
18
+ if(type!=='IHDR'||length!==13)throw Error('Invalid PNG IHDR');
19
+ width=bytes.readUInt32BE(offset+8);height=bytes.readUInt32BE(offset+12);first=false;
20
+ } else if(type==='IHDR')throw Error('Duplicate PNG IHDR');
21
+ if(type==='IDAT')idatBytes+=length;
22
+ if(type==='IEND') { if(length!==0||end!==bytes.length)throw Error('Invalid PNG end');ended=true; }
23
+ offset=end;
24
+ }
25
+ if(!ended||!idatBytes||!width||!height||width*height>40_000_000)throw Error('Incomplete PNG or invalid dimensions');
26
+ return {width,height};
27
+ }
28
+ export function matlabLiteral(value) {
29
+ if (typeof value !== 'string' || /[\x00-\x1f]/.test(value)) throw Error('Invalid MATLAB string');
30
+ return `'${value.replaceAll("'", "''")}'`;
31
+ }
32
+ export function requireId(id) { if (!UUID.test(id)) throw Error('Invalid UUID'); return id; }
33
+ export async function checkedDirectory(dir) {
34
+ if (!path.isAbsolute(dir) || /^(\\\\[?.]\\|\\\\)/.test(dir)) throw Error('An absolute local path is required');
35
+ const absolute=path.resolve(dir);
36
+ let current=path.parse(absolute).root;
37
+ for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
38
+ current=path.join(current,part);
39
+ const stat=await fs.lstat(current);
40
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('Directory links/reparse paths are not accepted');
41
+ }
42
+ const real=await fs.realpath(absolute);
43
+ if (path.relative(absolute,real)!=='') throw Error('Physical directory differs from requested directory');
44
+ return real;
45
+ }
46
+ async function childDirectory(parent,name,create=false) {
47
+ await checkedDirectory(parent);
48
+ const dir=path.join(parent,name);
49
+ if(create) { try { await fs.mkdir(dir); } catch(e) { if(e.code!=='EEXIST')throw e; } }
50
+ return checkedDirectory(dir);
51
+ }
52
+ async function runDirectory(workspace,runId) {
53
+ requireId(runId);
54
+ const root=await childDirectory(await checkedDirectory(workspace),'.dsh-matlab');
55
+ return childDirectory(await childDirectory(root,'runs'),runId);
56
+ }
57
+ async function fileBytes(dir,name,cap) {
58
+ await checkedDirectory(dir);
59
+ const file=path.join(dir,name),stat=await fs.lstat(file);
60
+ if(!stat.isFile()||stat.isSymbolicLink()||stat.size>cap)throw Error(`Invalid or oversized artifact: ${name}`);
61
+ const handle=await fs.open(file,'r');
62
+ try {
63
+ const opened=await handle.stat();
64
+ if(opened.size!==stat.size||opened.ino!==stat.ino)throw Error('Artifact changed while opening');
65
+ const bytes=await handle.readFile();
66
+ if(bytes.length>cap)throw Error('Artifact exceeds limit');
67
+ return bytes;
68
+ } finally { await handle.close(); }
69
+ }
70
+ const json = async (dir,name) => JSON.parse((await fileBytes(dir,name,1024*1024)).toString('utf8'));
71
+ export async function prepareRun(workspace,helperDir,provenance={}) {
72
+ const base=await checkedDirectory(workspace);
73
+ const root=await childDirectory(base,'.dsh-matlab',true);
74
+ const runs=await childDirectory(root,'runs',true);
75
+ const runId=randomUUID(),imageId=randomUUID();
76
+ const runDir=await childDirectory(runs,runId,true);
77
+ const helper=await checkedDirectory(helperDir);
78
+ const prepared={schemaVersion:1,status:'prepared',runId,imageId,runDir,createdUtc:new Date().toISOString(),provenance};
79
+ await fs.writeFile(path.join(runDir,'prepared.json'),JSON.stringify(prepared,null,2),{flag:'wx'});
80
+ return {...prepared,setupCode:`addpath(${matlabLiteral(helper)});`,exportCode:`dsh_m1_save_figure(fig, ${matlabLiteral(runDir)}, ${matlabLiteral(imageId)}, 'Simulation result');`,next:'Execute MATLAB through the official MCP; preparation is not execution. Then collect and call read_image.'};
81
+ }
82
+ export async function collectFigure(workspace,runId) {
83
+ const dir=await runDirectory(workspace,runId);
84
+ const prepared=await json(dir,'prepared.json');
85
+ requireId(prepared.imageId);
86
+ if(prepared.schemaVersion!==1||prepared.runId!==runId||prepared.runDir!==dir)throw Error('Invalid preparation record');
87
+ const metadata=await json(dir,'figure.json');
88
+ const name=prepared.imageId+'.png';
89
+ if(metadata.schemaVersion!==1||metadata.imageId!==prepared.imageId||metadata.file!==name||typeof metadata.title!=='string'||metadata.title.length>200)throw Error('Figure metadata does not match preparation');
90
+ const bytes=await fileBytes(dir,name,16*1024*1024);
91
+ const {width,height}=pngDimensions(bytes);
92
+ let metrics=null,data=null;
93
+ try { metrics=await json(dir,'metrics.json'); } catch(e) { if(e.code!=='ENOENT')throw e; }
94
+ if(metrics!==null) {
95
+ if(metrics.source!=='MATLAB'||typeof metrics.method!=='string'||!Number.isFinite(metrics.sampleInterval_s)||!Number.isFinite(metrics.window_s))throw Error('Missing MATLAB metric provenance');
96
+ const samples=await fileBytes(dir,'samples.csv',16*1024*1024);
97
+ data={file:path.join(dir,'samples.csv'),sha256:hash(samples),bytes:samples.length};
98
+ }
99
+ const artifact={schemaVersion:1,status:'artifact_ready',runId,imageId:prepared.imageId,path:path.join(dir,name),sha256:hash(bytes),bytes:bytes.length,width,height,title:metadata.title,exportedUtc:metadata.exportedUtc,metrics,data,provenance:prepared.provenance};
100
+ const destination=path.join(dir,'artifact.json');
101
+ try { const prior=await json(dir,'artifact.json'); if(JSON.stringify(prior)!==JSON.stringify(artifact))throw Error('Collected artifact changed; create a new run'); }
102
+ catch(e) { if(e.code!=='ENOENT')throw e; const temporary=path.join(dir,randomUUID()+'.tmp'); await fs.writeFile(temporary,JSON.stringify(artifact,null,2),{flag:'wx'}); await fs.rename(temporary,destination); }
103
+ return artifact;
104
+ }
105
+ export async function collectStandaloneFigure(workspace,runId) {
106
+ const dir=await runDirectory(workspace,runId);
107
+ const prepared=await json(dir,'prepared.json');
108
+ if(prepared.provenance?.sweepId)throw Error('PID runs must be indexed through dsh_matlab_collect_sweep after the whole sweep completes');
109
+ return collectFigure(workspace,runId);
110
+ }
111
+ export async function listFigures(workspace,{includeSweeps=true}={}) {
112
+ const root=path.join(await checkedDirectory(workspace),'.dsh-matlab','runs');
113
+ try { await checkedDirectory(root); } catch(e) { if(e.code==='ENOENT')return []; throw e; }
114
+ const entries=await fs.readdir(root,{withFileTypes:true}),figures=[];
115
+ for(const entry of entries) {
116
+ if(!UUID.test(entry.name))continue;
117
+ if(entry.isSymbolicLink())throw Error('Unexpected run link');
118
+ if(!entry.isDirectory())continue;
119
+ try {
120
+ await fs.access(path.join(root,entry.name,'artifact.json'));
121
+ if(!includeSweeps&&(await json(path.join(root,entry.name),'prepared.json')).provenance?.sweepId)continue;
122
+ figures.push(await collectFigure(workspace,entry.name));
123
+ }
124
+ catch(e) { if(e.code!=='ENOENT')throw e; }
125
+ }
126
+ return figures;
127
+ }
@@ -0,0 +1,96 @@
1
+ import {promises as fs} from 'node:fs';
2
+ import path from 'node:path';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {checkedDirectory, prepareRun, collectFigure, matlabLiteral, requireId} from './figures.mjs';
5
+
6
+ const maxRuns=12;
7
+ function validateParameters(parameters) {
8
+ if(!Array.isArray(parameters)||parameters.length<2||parameters.length>maxRuns)throw Error('PID sweep requires 2–12 parameter sets');
9
+ const seen=new Set();
10
+ return parameters.map((row,index)=>{
11
+ if(!row||typeof row!=='object'||Array.isArray(row)||Object.keys(row).sort().join(',')!=='kd,ki,kp')throw Error(`Invalid PID row ${index+1}`);
12
+ for(const name of ['kp','ki','kd'])if(typeof row[name]!=='number'||!Number.isFinite(row[name])||row[name]<0||row[name]>100)throw Error(`Invalid ${name} in PID row ${index+1}: use a finite number from 0 to 100`);
13
+ // The sample plant has a stable closed loop iff this Routh condition holds for ki>0.
14
+ const a2=2*0.48*3+9*row.kd,a1=9*(1+row.kp),a0=9*row.ki;
15
+ if(row.ki>0&&a2*a1<=a0)throw Error(`Unstable PID row ${index+1} for the documented sample plant`);
16
+ const key=JSON.stringify([row.kp,row.ki,row.kd]);
17
+ if(seen.has(key))throw Error(`Duplicate PID row ${index+1}: each experiment must use different gains`);
18
+ seen.add(key);
19
+ return {kp:row.kp,ki:row.ki,kd:row.kd};
20
+ });
21
+ }
22
+ async function child(parent,name,create=false) {
23
+ const root=await checkedDirectory(parent),target=path.join(root,name);
24
+ if(create) { try {await fs.mkdir(target);}catch(e){if(e.code!=='EEXIST')throw e;} }
25
+ return checkedDirectory(target);
26
+ }
27
+ async function sweepsRoot(workspace,create=false) {
28
+ const root=await child(await checkedDirectory(workspace),'.dsh-matlab',create);
29
+ return child(root,'sweeps',create);
30
+ }
31
+ async function sweepDir(workspace,sweepId) {return child(await sweepsRoot(workspace),requireId(sweepId));}
32
+ async function readRecord(dir,name) {
33
+ const target=path.join(await checkedDirectory(dir),name),stat=await fs.lstat(target);
34
+ if(!stat.isFile()||stat.isSymbolicLink()||stat.size>128*1024)throw Error(`Invalid sweep record: ${name}`);
35
+ return JSON.parse(await fs.readFile(target,'utf8'));
36
+ }
37
+ export async function prepareSweep(workspace,helperDir,parameters) {
38
+ const rows=validateParameters(parameters);
39
+ const base=await checkedDirectory(workspace),helper=await checkedDirectory(helperDir);
40
+ const root=await sweepsRoot(base,true),sweepId=randomUUID(),dir=await child(root,sweepId,true);
41
+ const items=[];
42
+ for(const [index,params] of rows.entries()) {
43
+ const run=await prepareRun(base,helper,{kind:'pid_sweep',sweepId,index,parameters:params});
44
+ items.push({index,parameters:params,runId:run.runId,imageId:run.imageId,runDir:run.runDir});
45
+ }
46
+ const comparison=await prepareRun(base,helper,{kind:'pid_comparison',sweepId});
47
+ const record={schemaVersion:2,kind:'pid_sweep',status:'prepared',sweepId,sweepDir:dir,createdUtc:new Date().toISOString(),plant:{model:'y_second_order_pid',zeta:0.48,wn_rad_s:3,reference:1,sampleInterval_s:0.01,window_s:12},items,comparison:{runId:comparison.runId,imageId:comparison.imageId,runDir:comparison.runDir}};
48
+ await fs.writeFile(path.join(dir,'sweep.json'),JSON.stringify(record,null,2),{flag:'wx'});
49
+ return {...record,setupCode:`addpath(${matlabLiteral(helper)});`,executeCode:`dsh_m2_pid_sweep(${matlabLiteral(dir)});`,next:'Run setupCode and executeCode through the official MATLAB MCP. After the success marker, call dsh_matlab_collect_sweep; then read_image for each returned PNG, especially the comparison overlay.'};
50
+ }
51
+ async function loadSweep(workspace,sweepId) {
52
+ const dir=await sweepDir(workspace,sweepId),record=await readRecord(dir,'sweep.json');
53
+ if(record.schemaVersion!==2||record.kind!=='pid_sweep'||record.sweepId!==sweepId||record.sweepDir!==dir||record.status!=='prepared')throw Error('Invalid sweep manifest');
54
+ const parameters=validateParameters(record.items?.map(x=>x.parameters));
55
+ if(!Array.isArray(record.items)||!record.comparison||record.items.length!==parameters.length)throw Error('Invalid sweep items');
56
+ const ids=new Set();
57
+ for(const [index,item] of record.items.entries()) {
58
+ requireId(item.runId);requireId(item.imageId);
59
+ if(item.index!==index||ids.has(item.runId)||path.dirname(item.runDir)!==path.join(await checkedDirectory(workspace),'.dsh-matlab','runs')||path.basename(item.runDir)!==item.runId)throw Error('Invalid sweep run path');
60
+ ids.add(item.runId);
61
+ }
62
+ requireId(record.comparison.runId);requireId(record.comparison.imageId);
63
+ if(ids.has(record.comparison.runId)||record.comparison.runDir!==path.join(await checkedDirectory(workspace),'.dsh-matlab','runs',record.comparison.runId))throw Error('Invalid comparison run');
64
+ return record;
65
+ }
66
+ export async function collectSweep(workspace,sweepId) {
67
+ const manifest=await loadSweep(workspace,requireId(sweepId));
68
+ let completion;
69
+ try {completion=await readRecord(manifest.sweepDir,'completed.json');} catch(e){if(e.code==='ENOENT')throw Error('Sweep is incomplete: no MATLAB completion record');throw e;}
70
+ if(completion.schemaVersion!==2||completion.sweepId!==sweepId||completion.runCount!==manifest.items.length||typeof completion.completedUtc!=='string')throw Error('Invalid MATLAB completion record');
71
+ const figures=[];
72
+ for(const item of manifest.items) {
73
+ const figure=await collectFigure(workspace,item.runId);
74
+ const m=figure.metrics;
75
+ if(figure.imageId!==item.imageId||figure.provenance?.kind!=='pid_sweep'||figure.provenance.sweepId!==sweepId||figure.provenance.index!==item.index||JSON.stringify(figure.provenance.parameters)!==JSON.stringify(item.parameters)||m?.source!=='MATLAB'||m?.schemaVersion!==2||m?.kind!=='pid_step_response'||JSON.stringify(m.pid)!==JSON.stringify(item.parameters)||m.sampleInterval_s!==0.01||m.window_s!==12||!['within_recorded_window','not_determined_in_window'].includes(m.settlingStatus)||!['integratedAbsoluteError','integratedSquaredError','maxAbsoluteControl','overshoot_percent','analyticSteadyStateError'].every(key=>Number.isFinite(m[key])&&m[key]>=0)||!figure.data)throw Error('Sweep artifact or MATLAB metrics mismatch');
76
+ figures.push(figure);
77
+ }
78
+ const comparison=await collectFigure(workspace,manifest.comparison.runId);
79
+ if(comparison.imageId!==manifest.comparison.imageId||comparison.provenance?.kind!=='pid_comparison'||comparison.provenance.sweepId!==sweepId)throw Error('Comparison artifact mismatch');
80
+ figures.push(comparison);
81
+ return {kind:'dsh-matlab-figures',sweepId,figures,comparisonMetric:'integratedAbsoluteError',note:'IAE is integrated over the fixed 12 s window. Lower is better for this example; verify settling and control effort separately.'};
82
+ }
83
+ export async function listSweeps(workspace) {
84
+ let root;
85
+ try {root=await sweepsRoot(workspace);}catch(e){if(e.code==='ENOENT')return [];throw e;}
86
+ const entries=await fs.readdir(root,{withFileTypes:true}),results=[];
87
+ for(const entry of entries) {
88
+ if(!requireIdIfValid(entry.name))continue;
89
+ if(entry.isSymbolicLink())throw Error('Unexpected sweep link');
90
+ if(!entry.isDirectory())continue;
91
+ try {results.push(await collectSweep(workspace,entry.name));}
92
+ catch(e){if(e.message==='Sweep is incomplete: no MATLAB completion record')continue;throw e;}
93
+ }
94
+ return results;
95
+ }
96
+ function requireIdIfValid(value) {try{requireId(value);return true;}catch{return false;}}
@@ -0,0 +1,122 @@
1
+ window.__ModuleLoader__.load({
2
+ id:'dsh-matlab-workbench',
3
+ factory(require) {
4
+ const React=require('react'),h=React.createElement;
5
+ const dictionaries={
6
+ zh:{figures:{tab:'MATLAB 图像',empty:'尚无已收集图像。请先运行 MATLAB,再调用 dsh_matlab_collect。',pending:'等待 Agent 调用 read_image',loading:'正在载入图像…',failed:'图像载入失败',source:'MATLAB 数值结果',expand:'放大 / 收起',boundary:'图像用于定性观察;精确数值来自 MATLAB 数据计算。'},compare:{tab:'PID 实验对比',empty:'尚无已完成并收集的 PID 扫描。',boundary:'指标由 MATLAB ode45 数据计算。按 12 秒窗口内 IAE 比较,不能代替稳定性、控制量和实际对象的验证。',overlay:'MATLAB 曲线叠加图',run:'组别',gain:'PID 参数 (Kp / Ki / Kd)',iae:'IAE ↓',ise:'ISE',overshoot:'超调 %',settling:'2% 调节时间 (s)',effort:'最大控制量',best:'本组最低 IAE',unknown:'未确定',curves:'单组实验曲线',status:'当前状态',preparing:'正在准备参数…',prepared:'已准备:请由 Agent 使用官方 MATLAB MCP 执行',running:'MATLAB MCP 正在执行…',awaiting:'MCP 返回了完成标记,等待产物核验',verifying:'正在校验全部 MATLAB 产物…',loading:'已核验指标,等待 read_image 附件',complete:'全部指标及图像已就绪',failed:'校验失败;检查 MATLAB 运行日志,勿自动重试',uncertain:'MCP 状态不确定;确认执行是否结束后再操作',none:'等待 Agent 准备扫描',images:'图像附件'},setup:{tab:'PID 参数准备',guide:'每行一组 Kp, Ki, Kd,用英文逗号分隔。2–12 组;各值在 0–100 内。这里仅校验并生成消息,不运行 MATLAB。',label:'PID 参数(每行 Kp,Ki,Kd)',validate:'检查并生成 Agent 指令',ready:'将以下指令复制到当前会话发送给 Agent',instruction:'请使用 dsh_matlab_prepare_sweep 准备以下参数,通过官方 MATLAB MCP 执行 setupCode 和 executeCode;核验完成标记后调用 dsh_matlab_collect_sweep,再用 read_image 读取全部 PNG。不可根据图像猜测精确指标,执行失败时不要自动重试。参数:',error:'参数有误',rowCount:'请输入 2–12 组 PID 参数',format:'第 {row} 行:请按 Kp,Ki,Kd 输入三个数字',range:'第 {row} 行:各参数必须是 0–100 的有限数值',unstable:'第 {row} 行:参数使示例对象闭环不稳定',duplicate:'第 {row} 行:与前面的参数组重复'}},
7
+ en:{figures:{tab:'MATLAB Figures',empty:'No collected figures. Run MATLAB, then call dsh_matlab_collect.',pending:'Waiting for the Agent to call read_image',loading:'Loading image…',failed:'Image load failed',source:'MATLAB numerical results',expand:'Expand / collapse',boundary:'Images support qualitative observations; precise metrics come from MATLAB data calculations.'},compare:{tab:'PID Experiments',empty:'No completed and collected PID sweeps in this session.',boundary:'Metrics come from MATLAB ode45 samples. Compare IAE over 12 seconds alongside stability, control effort and real plant checks.',overlay:'MATLAB overlay',run:'Run',gain:'PID gains (Kp / Ki / Kd)',iae:'IAE ↓',ise:'ISE',overshoot:'Overshoot %',settling:'2% settling time (s)',effort:'Max control',best:'Lowest IAE in sweep',unknown:'Undetermined',curves:'Individual response curves',status:'Status',preparing:'Preparing PID parameters…',prepared:'Prepared: ask the Agent to execute via official MATLAB MCP',running:'Executing in MATLAB MCP…',awaiting:'MCP returned completion marker; verify all artifacts',verifying:'Verifying all MATLAB artifacts…',loading:'Metrics verified; awaiting read_image attachments',complete:'Metrics and images ready',failed:'Verification failed; inspect MATLAB log and do not retry automatically',uncertain:'Unknown MCP outcome; confirm execution stopped before proceeding',none:'Ask Agent to prepare a sweep',images:'Image attachments'},setup:{tab:'PID Input',guide:'One Kp, Ki, Kd set per line, comma-separated. 2–12 rows; values between 0 and 100. This only validates and drafts an Agent message. MATLAB is not executed here.',label:'PID parameters (Kp,Ki,Kd per line)',validate:'Validate and build Agent message',ready:'Copy this instruction into the current session for the Agent',instruction:'Use dsh_matlab_prepare_sweep for these parameters, run setupCode and executeCode via official MATLAB MCP, verify the completion marker, call dsh_matlab_collect_sweep, then read_image for all PNGs. Never guess precise metrics from images or automatically retry failed writes. Parameters: ',error:'Invalid PID parameters',rowCount:'Enter 2–12 PID parameter rows',format:'Row {row}: enter three numeric values as Kp,Ki,Kd',range:'Row {row}: values must be finite and between 0 and 100',unstable:'Row {row}: gains are unstable for the sample plant',duplicate:'Row {row}: duplicate gain set'}}
8
+ };
9
+ function FigureImage({figure,sessionId,loadAttachment,t}) {
10
+ const [state,setState]=React.useState({key:null,url:null,error:null});
11
+ const attachmentId=figure.attachment?.attachmentId;
12
+ const key=JSON.stringify([sessionId,attachmentId]);
13
+ React.useEffect(()=>{
14
+ let active=true;
15
+ setState({key,url:null,error:null});
16
+ if(attachmentId) loadAttachment({sessionId,attachmentId}).then(result=>{
17
+ if(!active)return;
18
+ if(!result.ok)throw Error(result.error?.message||'Attachment unavailable');
19
+ const value=result.value;
20
+ if(!/^image\/(png|jpeg|webp|gif)$/.test(value.attachment.mediaType))throw Error('Unsupported image type');
21
+ setState({key,url:`data:${value.attachment.mediaType};base64,${value.data}`,error:null});
22
+ }).catch(error=>{if(active)setState({key,url:null,error:String(error.message)});});
23
+ return ()=>{active=false;};
24
+ },[sessionId,attachmentId,loadAttachment]);
25
+ if(!attachmentId)return h('p',{role:'status'},t('figures.pending'));
26
+ if(state.key!==key)return h('p',{role:'status'},t('figures.loading'));
27
+ if(state.error)return h('p',{role:'alert'},`${t('figures.failed')}: ${state.error}`);
28
+ if(!state.url)return h('p',{role:'status'},t('figures.loading'));
29
+ return h('details',{open:true},h('summary',null,t('figures.expand')),h('img',{src:state.url,alt:figure.title,style:{display:'block',maxWidth:'100%',height:'auto'}}));
30
+ }
31
+ function validateRows(input,t) {
32
+ const fail=(reason,index)=>{throw Error(t('setup.'+reason).replace('{row}',String(index+1)));};
33
+ const lines=input.split(/\r?\n/).map(line=>line.trim()).filter(Boolean);
34
+ if(lines.length<2||lines.length>12)fail('rowCount',0);
35
+ const seen=new Set();
36
+ return lines.map((line,index)=>{
37
+ const fields=line.split(',').map(value=>value.trim());
38
+ if(fields.length!==3||fields.some(v=>!v||!/^\+?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(v)))fail('format',index);
39
+ const [kp,ki,kd]=fields.map(Number);
40
+ if([kp,ki,kd].some(value=>!Number.isFinite(value)||value<0||value>100))fail('range',index);
41
+ if(ki>0&&(2*0.48*3+9*kd)*9*(1+kp)<=9*ki)fail('unstable',index);
42
+ const key=JSON.stringify([kp,ki,kd]);
43
+ if(seen.has(key))fail('duplicate',index);
44
+ seen.add(key);
45
+ return {kp,ki,kd};
46
+ });
47
+ }
48
+ function SetupView({t}) {
49
+ const [draft,setDraft]=React.useState('1,0.5,0.2\n3,1,0.5\n2,0.8,0.9');
50
+ const [result,setResult]=React.useState(null);
51
+ const check=()=>{try{setResult({parameters:validateRows(draft,t)});}catch(error){setResult({error:error.message});}};
52
+ return h('section',{'aria-label':t('setup.tab'),style:{padding:20,overflow:'auto',height:'100%',color:'var(--dsw-alias-text-primary)'}},
53
+ h('h2',null,t('setup.tab')),h('p',null,t('setup.guide')),
54
+ h('label',{htmlFor:'dsh-pid-input'},t('setup.label')),
55
+ h('textarea',{id:'dsh-pid-input',rows:7,value:draft,onChange:event=>{setDraft(event.target.value);setResult(null);},style:{display:'block',width:'100%',maxWidth:620,margin:'12px 0',fontFamily:'monospace'}}),
56
+ h('button',{type:'button',onClick:check},t('setup.validate')),
57
+ result?.error?h('p',{role:'alert'},`${t('setup.error')}: ${result.error}`):null,
58
+ result?.parameters?h('div',{role:'status'},h('p',null,t('setup.ready')),h('pre',{style:{whiteSpace:'pre-wrap',overflowWrap:'anywhere',userSelect:'text',padding:12,border:'1px solid var(--dsw-alias-border-base)'}},t('setup.instruction')+JSON.stringify({parameters:result.parameters}))):null);
59
+ }
60
+ return {inject:['slots','locale','remote','remote.session'],apply(ctx) {
61
+ const flat=Object.fromEntries(Object.entries(dictionaries).map(([language,dict])=>[language,Object.fromEntries(Object.entries(dict).flatMap(([group,entries])=>Object.entries(entries).map(([key,value])=>[group+'.'+key,value])))]));
62
+ ctx.effect(()=>ctx.locale.register('matlabWorkbench',flat));
63
+ const t=ctx.locale.bind('matlabWorkbench');
64
+ // Keep one function per plugin lifetime, independent of service proxy identity.
65
+ // Desktop 0.2.1 accepts exactly one argument; it exposes no per-read abort.
66
+ const loadAttachment=request=>ctx.remote.session.attachment(request);
67
+ function FigureView(props) {
68
+ const snapshot=props.useProjection('matlabFigures');
69
+ const figures=snapshot?.figures||[];
70
+ return h('section',{'aria-label':t('figures.tab'),'data-dsh-matlab-figure-view':'active',style:{padding:20,overflow:'auto',height:'100%',color:'var(--dsw-alias-text-primary)',background:'var(--dsw-alias-bg-base)'}},
71
+ h('h2',null,t('figures.tab')),h('p',null,t('figures.boundary')),
72
+ figures.length?figures.map(figure=>h('article',{key:figure.imageId,style:{border:'1px solid var(--dsw-alias-border-base)',borderRadius:8,padding:16,marginBottom:16}},
73
+ h('h3',null,figure.title),h('div',null,figure.imageId),
74
+ h(FigureImage,{figure,sessionId:props.sessionId,loadAttachment,t}),
75
+ h('p',{style:{overflowWrap:'anywhere'}},`${figure.width} × ${figure.height} · SHA-256 ${figure.sha256}`),
76
+ figure.metrics?h('details',null,h('summary',null,t('figures.source')),h('pre',{style:{whiteSpace:'pre-wrap'}},JSON.stringify(figure.metrics,null,2))):null
77
+ )):h('p',null,t('figures.empty')));
78
+ }
79
+ const numeric=value=>typeof value==='number'&&Number.isFinite(value)?value.toFixed(3):t('compare.unknown');
80
+ function ComparisonView(props) {
81
+ const snapshot=props.useProjection('matlabFigures'),figures=snapshot?.figures||[],sweeps=snapshot?.sweeps||{};
82
+ const groups=new Map();
83
+ for(const figure of figures) {
84
+ const provenance=figure.provenance;
85
+ if(!provenance?.sweepId||!['pid_sweep','pid_comparison'].includes(provenance.kind))continue;
86
+ if(!groups.has(provenance.sweepId))groups.set(provenance.sweepId,{rows:[],overlay:null});
87
+ const group=groups.get(provenance.sweepId);
88
+ if(provenance.kind==='pid_comparison')group.overlay=figure;
89
+ else if(figure.metrics?.source==='MATLAB'&&figure.metrics.kind==='pid_step_response')group.rows.push(figure);
90
+ }
91
+ for(const sweepId of Object.keys(sweeps))if(!groups.has(sweepId))groups.set(sweepId,{rows:[],overlay:null});
92
+ const complete=[...groups];
93
+ return h('section',{'aria-label':t('compare.tab'),style:{padding:20,overflow:'auto',height:'100%',color:'var(--dsw-alias-text-primary)',background:'var(--dsw-alias-bg-base)'}},
94
+ h('h2',null,t('compare.tab')),h('p',null,t('compare.boundary')),
95
+ snapshot?.activity?h('p',{role:snapshot.activity.error?'alert':'status'},snapshot.activity.error||t('compare.preparing')):null,
96
+ complete.length?complete.map(([sweepId,group])=>{
97
+ const current=sweeps[sweepId],phase=current?.phase||'complete';
98
+ const status={prepared:'prepared',running:'running','awaiting-collection':'awaiting',verifying:'verifying',loading:'loading',complete:'complete','verify-failed':'failed','image-failed':'failed',unknown:'uncertain'}[phase]||'none';
99
+ const rows=group.rows.slice().sort((a,b)=>a.provenance.index-b.provenance.index);
100
+ const valid=rows.filter(f=>Number.isFinite(f.metrics.integratedAbsoluteError));
101
+ const best=valid.length?valid.reduce((a,b)=>a.metrics.integratedAbsoluteError<=b.metrics.integratedAbsoluteError?a:b):null;
102
+ const headers=['run','gain','iae','ise','overshoot','settling','effort'].map(name=>h('th',{key:name,scope:'col',style:{padding:8,textAlign:'left'}},t('compare.'+name)));
103
+ return h('article',{key:sweepId,style:{border:'1px solid var(--dsw-alias-border-base)',padding:16,borderRadius:8,marginBottom:18}},
104
+ h('h3',null,`Sweep ${sweepId}`),
105
+ h('p',{role:phase==='verify-failed'||phase==='image-failed'||phase==='unknown'?'alert':'status'},`${t('compare.status')}: ${t('compare.'+status)}${current?.imagesLoaded!==undefined?' · '+t('compare.images')+': '+current.imagesLoaded+'/'+current.imageCount:''}${current?.error?' · '+current.error:''}`),
106
+ rows.length>=2&&group.overlay?h('div',{style:{overflowX:'auto'}},h('table',{style:{width:'100%',borderCollapse:'collapse'}},
107
+ h('thead',null,h('tr',null,...headers)),
108
+ h('tbody',null,...rows.map(figure=>{
109
+ const m=figure.metrics,p=figure.provenance.parameters;
110
+ const values=[`${figure.provenance.index+1}${best?.imageId===figure.imageId?' ★':''}`,`${numeric(p.kp)} / ${numeric(p.ki)} / ${numeric(p.kd)}`,numeric(m.integratedAbsoluteError),numeric(m.integratedSquaredError),numeric(m.overshoot_percent),m.settlingStatus==='within_recorded_window'?numeric(m.sampledSettlingTime_s):t('compare.unknown'),numeric(m.maxAbsoluteControl)];
111
+ return h('tr',{key:figure.imageId,title:best?.imageId===figure.imageId?t('compare.best'):''},...values.map((value,index)=>h('td',{key:index,style:{padding:8,borderTop:'1px solid var(--dsw-alias-border-base)'}},value)));
112
+ })))):null,
113
+ group.overlay?h('div',null,h('h4',null,t('compare.overlay')),h(FigureImage,{figure:group.overlay,sessionId:props.sessionId,loadAttachment,t})):null,
114
+ rows.length?h('details',null,h('summary',null,t('compare.curves')),...rows.map(row=>h('div',{key:row.imageId},h('h4',null,`${t('compare.run')} ${row.provenance.index+1}`),h(FigureImage,{figure:row,sessionId:props.sessionId,loadAttachment,t})))):null);
115
+ }):h('p',null,t('compare.empty')));
116
+ }
117
+ ctx.slots.inject('conversation.view',()=>ctx.slots.register({name:'conversation.view',id:'matlab-pid-setup',order:29,label:()=>t('setup.tab')},props=>SetupView({...props,t})));
118
+ ctx.slots.inject('conversation.view',()=>ctx.slots.register({name:'conversation.view',id:'matlab-figures',order:30,label:()=>t('figures.tab')},FigureView));
119
+ ctx.slots.inject('conversation.view',()=>ctx.slots.register({name:'conversation.view',id:'matlab-pid-comparison',order:31,label:()=>t('compare.tab')},ComparisonView));
120
+ }};
121
+ }
122
+ });
@@ -0,0 +1,54 @@
1
+ import {fileURLToPath} from 'node:url';
2
+ import {prepareRun,collectStandaloneFigure,listFigures,requireId} from '../../core/figures.mjs';
3
+ import {prepareSweep,collectSweep,listSweeps} from '../../core/sweeps.mjs';
4
+ import {emptyState,foldFigures,stateSchema} from './projection.mjs';
5
+
6
+ export const inject=['tools','systemPrompt','sessionProjections','workingDirectory'];
7
+ const helperDir=fileURLToPath(new URL('../../matlab/',import.meta.url));
8
+ const output={schema:{type:'object',additionalProperties:true},render:(_args,value)=>[{type:'text',text:JSON.stringify(value)}]};
9
+ export function apply(ctx) {
10
+ const disposers=[];
11
+ const workspace=async exec=>{
12
+ exec.signal.throwIfAborted();
13
+ if(!exec.agent)throw Error('A session-owned Agent invocation is required');
14
+ const cwd=await ctx.workingDirectory.ensure(exec.agent,exec.signal);
15
+ exec.signal.throwIfAborted();
16
+ return cwd;
17
+ };
18
+ const register=(name,description,properties,required,execute)=>{
19
+ disposers.push(ctx.tools.register({name,description,parameters:{type:'object',properties,required,additionalProperties:false},output,
20
+ async execute(args,exec){
21
+ if(!args||typeof args!=='object'||Array.isArray(args)||Object.keys(args).some(k=>!Object.hasOwn(properties,k)))throw Error('Invalid arguments');
22
+ for(const key of required)if(!(key==='parameters'?Array.isArray(args[key]):typeof args[key]==='string'))throw Error(`${key} is required`);
23
+ return execute(args,exec);
24
+ }
25
+ }));
26
+ };
27
+ try {
28
+ disposers.push(ctx.sessionProjections.register({key:'matlabFigures',stateSchema,init:emptyState,apply:foldFigures,stateVersion:1,wire:{viewSchema:stateSchema,view:state=>state}}));
29
+ register('dsh_matlab_prepare','Prepare a unique workspace run and safe MATLAB helper code. Does not execute MATLAB or save an image.',{},[],async (_args,exec)=>prepareRun(await workspace(exec),helperDir,{execution:'unknown',scriptHash:'unknown'}));
30
+ register('dsh_matlab_collect','Verify one completed MATLAB export and index it. Returns a PNG path; call read_image to provide actual visual input. Does not claim simulation success.',{runId:{type:'string'}},['runId'],async (args,exec)=>{
31
+ requireId(args.runId); const figure=await collectStandaloneFigure(await workspace(exec),args.runId);
32
+ exec.signal.throwIfAborted();
33
+ return {kind:'dsh-matlab-figures',figures:[figure],next:`Call read_image with file_path=${figure.path}. Precise metrics are MATLAB-computed; never replace them with visual estimates.`};
34
+ });
35
+ register('dsh_matlab_list_figures','Revalidate standalone M1 figure artifacts; use list_sweeps for complete M2 scans.',{},[],async (_args,exec)=>({kind:'dsh-matlab-figures',figures:await listFigures(await workspace(exec),{includeSweeps:false})}));
36
+ register('dsh_matlab_prepare_sweep','Prepare 2–12 PID gain combinations for the fixed documented second-order MATLAB sample plant. Does not run MATLAB.',{
37
+ parameters:{type:'array',minItems:2,maxItems:12,items:{type:'object',properties:{kp:{type:'number'},ki:{type:'number'},kd:{type:'number'}},required:['kp','ki','kd'],additionalProperties:false}}
38
+ },['parameters'],async (args,exec)=>prepareSweep(await workspace(exec),helperDir,args.parameters));
39
+ register('dsh_matlab_collect_sweep','Verify an entire completed MATLAB PID sweep: all per-run CSV, metrics, PNG and overlay. Incomplete sweeps fail without indexing.',{
40
+ sweepId:{type:'string'}
41
+ },['sweepId'],async (args,exec)=>{
42
+ requireId(args.sweepId);const result=await collectSweep(await workspace(exec),args.sweepId);
43
+ exec.signal.throwIfAborted();
44
+ return {...result,next:'Call read_image once for EACH returned figure path, especially the overlay; the Desktop comparison tab will display numerical metrics from MATLAB and the overlay after the attachment arrives.'};
45
+ });
46
+ register('dsh_matlab_list_sweeps','Revalidate and list only completed, whole PID sweeps with verified artifacts.',{},[],async (_args,exec)=>{
47
+ const groups=await listSweeps(await workspace(exec));return {kind:'dsh-matlab-figures',figures:groups.flatMap(group=>group.figures),sweeps:groups.map(group=>group.sweepId)};
48
+ });
49
+ disposers.push(ctx.systemPrompt.section({name:'dsh-matlab-workbench',order:9050,text:
50
+ 'For MATLAB figures: dsh_matlab_prepare prepares unique absolute paths and helper code; it does not execute. Execute with the official MATLAB MCP. Pass an explicit figure handle to dsh_m1_save_figure, then dsh_matlab_collect and read_image on its exact path. For M2 PID sweeps, call dsh_matlab_prepare_sweep with an array of {kp,ki,kd}; execute its setupCode and executeCode through the official MATLAB MCP, check DSH_M2_SWEEP_SUCCEEDED, then call dsh_matlab_collect_sweep and read_image for every returned path. Use only verified completed sweeps in comparison. The example is a fixed second-order plant, with derivative on measurement, 12 second window and ode45. Compare MATLAB computed IAE (lower over this window), settling status, and control effort, without treating a ranking as universally optimal. The MATLAB Figures tab displays only collected artifacts referenced by this session. A displayed figure is not evidence the model has seen it. Describe image observations separately from numerical results. Missing, failed, timed-out or cancelled execution is not success; cancellation does not prove MATLAB has stopped. Do not retry writes automatically.'}));
51
+ ctx.logger?.info?.('DSH_M1_HOST_APPLY');
52
+ } catch(error) { for(const dispose of disposers.reverse())dispose(); throw error; }
53
+ return ()=>{for(const dispose of disposers.reverse())dispose();ctx.logger?.info?.('DSH_M1_HOST_DISPOSE');};
54
+ }
@@ -0,0 +1,100 @@
1
+ // DSH event adapter: pure replay fold; no filesystem reads or custom events.
2
+ const keyPath=value=>String(value).replaceAll('\\','/').toLowerCase();
3
+ const matlabExecution=name=>/matlab.*(evaluate_matlab_code|run_matlab_file)/i.test(name);
4
+ const textResult=message=>message?.content?.filter(c=>c.type==='text').map(c=>c.text).join('\n')||'';
5
+ const matlabSucceeded=message=>!message?.isError&&/(?:^|\r?\n)\s*DSH_M2_SWEEP_SUCCEEDED\s*(?:\r?\n|$)/.test(textResult(message))&&!/(?:^|\r?\n)\s*(?:Error using|Error:|Exception:|Failed:)/i.test(textResult(message));
6
+ const sweepForFigure=figure=>figure?.provenance?.sweepId;
7
+ export const emptyState=()=>({calls:{},figures:[],images:{},sweeps:{},activity:null,activeSweepId:null});
8
+ export const stateSchema={parse(value){
9
+ if(!value||typeof value!=='object'||!Array.isArray(value.figures)||!value.calls||!value.images)throw Error('Invalid MATLAB projection state');
10
+ return value;
11
+ }};
12
+ function updateSweep(next,id,patch) {
13
+ if(!id)return;
14
+ next.sweeps={...(next.sweeps||{}),[id]:{...(next.sweeps?.[id]||{}),...patch}};
15
+ }
16
+ function refreshImages(next,id) {
17
+ if(!id||!next.sweeps?.[id])return;
18
+ const figures=next.figures.filter(f=>sweepForFigure(f)===id),expected=next.sweeps[id].imageCount;
19
+ if(!expected||figures.length!==expected)return;
20
+ const available=figures.filter(f=>f.attachment?.attachmentId).length;
21
+ updateSweep(next,id,{phase:available===expected?'complete':'loading',imagesLoaded:available});
22
+ }
23
+ export function foldFigures(state,event) {
24
+ if(event.type==='tool/call') {
25
+ const {callId,name,arguments:raw}=event.data;
26
+ if(!['dsh_matlab_prepare_sweep','dsh_matlab_collect','dsh_matlab_list_figures','dsh_matlab_collect_sweep','dsh_matlab_list_sweeps','read_image'].includes(name)&&!matlabExecution(name))return state;
27
+ let args;try {args=typeof raw==='string'?JSON.parse(raw):raw;}catch {args={};}
28
+ const next={...state,calls:{...state.calls,[callId]:{name,args}},sweeps:state.sweeps||{}};
29
+ if(name==='dsh_matlab_prepare_sweep')next.activity={phase:'preparing'};
30
+ if(name==='dsh_matlab_collect_sweep')updateSweep(next,args?.sweepId,{phase:'verifying',error:null});
31
+ if(matlabExecution(name)) {
32
+ const id=state.activeSweepId,sweep=id&&state.sweeps?.[id];
33
+ if(sweep?.sweepDir&&JSON.stringify(args).includes(sweep.sweepDir)&&['prepared','running','awaiting-collection','unknown'].includes(sweep.phase)){
34
+ next.calls[callId].sweepId=id;
35
+ updateSweep(next,id,{phase:'running',error:null});
36
+ }
37
+ }
38
+ return next;
39
+ }
40
+ if(event.type!=='tool/result')return state;
41
+ const message=event.data.message,callId=message?.toolCallId,call=state.calls[callId];
42
+ if(!call)return state;
43
+ const calls={...state.calls}; delete calls[callId];
44
+ const next={...state,calls,sweeps:state.sweeps||{}};
45
+ if(call.name==='dsh_matlab_prepare_sweep') {
46
+ if(message.isError){next.activity={phase:'error',error:textResult(message).slice(0,300)||'Preparation failed'};return next;}
47
+ let data;try{data=JSON.parse(textResult(message));}catch{}
48
+ if(data?.status==='prepared'&&data?.kind==='pid_sweep'&&typeof data.sweepId==='string'){
49
+ next.activeSweepId=data.sweepId;
50
+ updateSweep(next,data.sweepId,{phase:'prepared',runCount:data.items?.length||0,sweepDir:data.sweepDir});
51
+ next.activity=null;
52
+ }else next.activity={phase:'error',error:'Invalid preparation response'};
53
+ return next;
54
+ }
55
+ if(call.sweepId) {
56
+ // MCP may return MATLAB errors as ordinary text even when isError=false.
57
+ updateSweep(next,call.sweepId,{phase:matlabSucceeded(message)?'awaiting-collection':'unknown',error:matlabSucceeded(message)?null:textResult(message).slice(0,300)||'MATLAB MCP completion not confirmed'});
58
+ return next;
59
+ }
60
+ if(call.name==='dsh_matlab_collect_sweep'&&message.isError){updateSweep(next,call.args?.sweepId,{phase:'verify-failed',error:textResult(message).slice(0,300)||'Artifact verification failed'});return next;}
61
+ if(message.isError&&call.name!=='read_image')return next;
62
+ if(call.name==='read_image') {
63
+ const attachment=message.content?.find(c=>c.type==='image')?.attachment;
64
+ const target=state.figures.find(f=>keyPath(f.path)===keyPath(call.args?.file_path));
65
+ if(message.isError||!attachment?.attachmentId||typeof call.args?.file_path!=='string'){
66
+ if(target&&sweepForFigure(target))updateSweep(next,sweepForFigure(target),{phase:'image-failed',error:textResult(message).slice(0,300)||'read_image returned no image attachment'});
67
+ return next;
68
+ }
69
+ const key=keyPath(call.args.file_path);
70
+ next.images={...state.images,[key]:attachment};
71
+ next.figures=state.figures.map(f=>keyPath(f.path)===key?{...f,attachment}:f);
72
+ for(const figure of next.figures)if(keyPath(figure.path)===key)refreshImages(next,sweepForFigure(figure));
73
+ return next;
74
+ }
75
+ let payload; try { payload=JSON.parse(textResult(message)); } catch {}
76
+ if(payload?.kind!=='dsh-matlab-figures'||!Array.isArray(payload.figures)){
77
+ if(call.name==='dsh_matlab_collect_sweep')updateSweep(next,call.args?.sweepId,{phase:'verify-failed',error:textResult(message).slice(0,300)||'Invalid sweep collection response'});
78
+ return next;
79
+ }
80
+ if(call.name==='dsh_matlab_collect_sweep'&&payload.sweepId!==call.args?.sweepId){
81
+ updateSweep(next,call.args?.sweepId,{phase:'verify-failed',error:'Sweep ID mismatch in collection response'});
82
+ return next;
83
+ }
84
+ const figures=new Map(state.figures.map(f=>[f.imageId,f]));
85
+ for(const figure of payload.figures) {
86
+ if(figure?.schemaVersion!==1||typeof figure.imageId!=='string'||typeof figure.path!=='string')continue;
87
+ const attachment=state.images[keyPath(figure.path)];
88
+ figures.set(figure.imageId,{...figure,...attachment?{attachment}:{}});
89
+ }
90
+ next.figures=[...figures.values()];
91
+ if(call.name==='dsh_matlab_collect_sweep'&&payload.sweepId){
92
+ updateSweep(next,payload.sweepId,{phase:'loading',imageCount:payload.figures.length,error:null});
93
+ refreshImages(next,payload.sweepId);
94
+ }
95
+ if(call.name==='dsh_matlab_list_sweeps')for(const id of payload.sweeps||[]){
96
+ updateSweep(next,id,{phase:'loading',imageCount:next.figures.filter(f=>sweepForFigure(f)===id).length,error:null});
97
+ refreshImages(next,id);
98
+ }
99
+ return next;
100
+ }
@@ -0,0 +1,31 @@
1
+ function dsh_m1_step_response(runDir,imageId)
2
+ % One control-system experiment; base MATLAB ODE solver, no optional toolbox.
3
+ zeta=0.48; wn=3; dt=0.002; duration=12;
4
+ tRequested=(0:dt:duration)';
5
+ options=odeset('RelTol',1e-9,'AbsTol',1e-11);
6
+ [t,x]=ode45(@(~,x)[x(2);wn^2*(1-x(1))-2*zeta*wn*x(2)],tRequested,[0;0],options);
7
+ y=x(:,1); [peak,k]=max(y); last=find(abs(y-1)>0.02,1,'last');
8
+ A=[0 1;-wn^2 -2*zeta*wn]; B=[0;wn^2];
9
+ equilibrium=-(A\B); steadyStateError=1-equilibrium(1);
10
+ settling=[]; settlingStatus='not_determined';
11
+ if isempty(last), settling=t(1); settlingStatus='within_recorded_window';
12
+ elseif last<numel(t), settling=t(last+1); settlingStatus='within_recorded_window'; end
13
+ metrics=struct('source','MATLAB','method','ode45 samples: max(y), last out-of-2%-band sample plus one', ...
14
+ 'matlab',version,'solver','ode45','relativeTolerance',1e-9,'absoluteTolerance',1e-11, ...
15
+ 'zeta',zeta,'wn_rad_s',wn,'sampleInterval_s',dt,'window_s',duration,'reference',1, ...
16
+ 'knownFinalValue',1,'settlingBandFraction',0.02,'peak',peak,'peakTime_s',t(k), ...
17
+ 'overshoot_percent',100*(peak-1),'sampledSettlingTime_s',settling,'settlingStatus',settlingStatus, ...
18
+ 'finalSampleError',1-y(end),'analyticSteadyStateError',steadyStateError, ...
19
+ 'steadyStateMethod','MATLAB solves x_eq = -(A\B), error = reference - x_eq(1) for stable A');
20
+ writematrix([t,y],fullfile(runDir,'samples.csv'));
21
+ fid=fopen(fullfile(runDir,'metrics.json'),'w','n','UTF-8'); assert(fid>=0);
22
+ closer=onCleanup(@()fclose(fid)); fwrite(fid,jsonencode(metrics),'char'); clear closer;
23
+ fig=figure('Visible','off');
24
+ plot(t,y,'LineWidth',1.7); hold on; yline(1,'--'); yline(0.98,':'); yline(1.02,':');
25
+ grid on; xlabel('Time (s)'); ylabel('Output'); title('M1: numerical unit-step response');
26
+ % Random visual control is deliberately omitted from stdout and metadata.
27
+ tokens={'ORBIT','MAPLE','COMET','RIVER','CEDAR','LANTERN'};
28
+ text(6,0.45,['Visual control: ' tokens{randi(numel(tokens))}],'FontSize',13);
29
+ dsh_m1_save_figure(fig,runDir,imageId,'Unit-step response / ODE45'); close(fig);
30
+ disp('DSH_M1_EXPERIMENT_SUCCEEDED'); disp(jsonencode(metrics));
31
+ end
package/icon.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect x="4" y="4" width="56" height="56" rx="12" fill="#16334a"/><path d="M13 46h38M16 48V17M17 44c5 0 5-24 12-24s5 16 11 16 5-8 10-8" fill="none" stroke="#68d6ed" stroke-width="3"/></svg>
@@ -0,0 +1,18 @@
1
+ # Optional connection example for users without an existing DSH MATLAB MCP server.
2
+ # Install the official Windows executable on PATH using the name below.
3
+ # MATLAB must also be discoverable on PATH. Do not add this if matlab already exists.
4
+ - insert:
5
+ - id: dsh-matlab-mcp-connection-example
6
+ name: '@deepseek-ai/dsh-mcp-client'
7
+ config:
8
+ serverName: matlab
9
+ transport: stdio
10
+ command: 'matlab-mcp-server-windows-x64.exe'
11
+ args:
12
+ - '--matlab-session-mode=new'
13
+ - '--matlab-display-mode=nodesktop'
14
+ - '--disable-telemetry=true'
15
+ toolCallTimeoutMs: 600000
16
+ failOnStartupError: false
17
+ env:
18
+ WINDIR: !!js process.env.WINDIR
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "dsh-matlab-mcp-connection-example",
3
+ "version": "0.1.0-beta.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Optional portable DSH connection to the independently installed official MATLAB MCP server",
7
+ "dsh": {"bundle": {"patch": "./cordis.patch.yml"}},
8
+ "files": ["cordis.patch.yml", "package.json"]
9
+ }
package/locale/en.json ADDED
@@ -0,0 +1 @@
1
+ {"meta":{"title":"MATLAB Figure Workbench","description":"Save and verify MATLAB figures, browse them in FigureView, and retain MATLAB-computed metrics."},"figures":{"tab":"MATLAB Figures","empty":"No collected figures. Run MATLAB, then call dsh_matlab_collect.","pending":"Waiting for the Agent to call read_image","loading":"Loading image…","failed":"Image load failed","source":"MATLAB numerical results","expand":"Expand / collapse","boundary":"Images support qualitative observations; precise metrics come from MATLAB data calculations."}}
package/locale/zh.json ADDED
@@ -0,0 +1 @@
1
+ {"meta":{"title":"MATLAB 图像工作台","description":"保存和核验 MATLAB 图像,在 FigureView 浏览结果;精确指标由 MATLAB 计算。"},"figures":{"tab":"MATLAB 图像","empty":"尚无已收集图像。请先运行 MATLAB,再调用 dsh_matlab_collect。","pending":"等待 Agent 调用 read_image","loading":"正在载入图像…","failed":"图像载入失败","source":"MATLAB 数值结果","expand":"放大 / 收起","boundary":"图像用于定性观察;精确数值来自 MATLAB 数据计算。"}}
@@ -0,0 +1,32 @@
1
+ function metadata = dsh_m1_save_figure(fig, runDir, imageId, titleText)
2
+ % Explicit figure handle, absolute run path, unique ID; commit metadata last.
3
+ arguments
4
+ fig (1,1) matlab.ui.Figure
5
+ runDir (1,1) string
6
+ imageId (1,1) string
7
+ titleText (1,1) string = "Simulation result"
8
+ end
9
+ assert(strlength(titleText)<=200,'DSHM1:Title','Title too long');
10
+ assert(~isempty(regexp(char(runDir),'^([A-Za-z]:[\\/]|/[^/])','once')), ...
11
+ 'DSHM1:AbsolutePath','An absolute local run directory is required');
12
+ assert(isempty(regexp(char(runDir),'[\x00-\x1f]','once')), 'DSHM1:Path','Invalid path');
13
+ assert(~isempty(regexp(char(imageId),'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$','once')), ...
14
+ 'DSHM1:Id','Invalid image UUID');
15
+ record=jsondecode(fileread(fullfile(runDir,'prepared.json')));
16
+ assert(record.schemaVersion==1 && string(record.imageId)==imageId && string(record.runDir)==runDir, ...
17
+ 'DSHM1:Preparation','Run does not match Host preparation');
18
+ png=fullfile(runDir,imageId+'.png'); partial=fullfile(runDir,imageId+'.partial.png');
19
+ manifest=fullfile(runDir,'figure.json'); temporary=fullfile(runDir,imageId+'.partial.json');
20
+ assert(~isfile(png)&&~isfile(partial)&&~isfile(manifest)&&~isfile(temporary), ...
21
+ 'DSHM1:Exists','Refusing to overwrite an existing figure or partial export');
22
+ assert(~isempty(fig.Children),'DSHM1:Empty','Cannot export an empty figure');
23
+ exportgraphics(fig,partial,'Resolution',150);
24
+ [ok,msg]=movefile(partial,png); assert(ok,'DSHM1:Commit','%s',msg);
25
+ utc=datetime('now','TimeZone','UTC','Format',"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
26
+ metadata=struct('schemaVersion',1,'imageId',char(imageId),'file',char(imageId+'.png'), ...
27
+ 'title',char(titleText),'exportedUtc',char(utc));
28
+ fid=fopen(temporary,'w','n','UTF-8'); assert(fid>=0,'DSHM1:Metadata','Cannot create figure metadata');
29
+ closer=onCleanup(@()fclose(fid));
30
+ fwrite(fid,unicode2native(jsonencode(metadata),'UTF-8'),'uint8'); clear closer;
31
+ [ok,msg]=movefile(temporary,manifest); assert(ok,'DSHM1:Commit','%s',msg);
32
+ end
@@ -0,0 +1,142 @@
1
+ function summary = dsh_m2_pid_sweep(sweepDir)
2
+ % Run each prepared PID experiment once. Only commit completion after all
3
+ % MATLAB CSV, numerical metrics and figure exports have succeeded.
4
+ arguments
5
+ sweepDir (1,1) string
6
+ end
7
+ assert(isfolder(sweepDir),'DSHM2:Directory','Sweep directory is missing');
8
+ manifestPath=fullfile(sweepDir,'sweep.json');
9
+ assert(isfile(manifestPath),'DSHM2:Manifest','Host manifest is missing');
10
+ manifest=jsondecode(fileread(manifestPath));
11
+ assert(manifest.schemaVersion==2 && strcmp(manifest.kind,'pid_sweep') && ...
12
+ strcmp(manifest.status,'prepared') && string(manifest.sweepDir)==sweepDir, ...
13
+ 'DSHM2:Manifest','Unexpected sweep manifest');
14
+ assert(numel(manifest.items)>=2 && numel(manifest.items)<=12,'DSHM2:Count','Invalid run count');
15
+ assert(~isfile(fullfile(sweepDir,'completed.json')),'DSHM2:Exists','Sweep was already completed');
16
+ assert(isfield(manifest,'comparison'),'DSHM2:Manifest','Comparison run missing');
17
+ plant=manifest.plant;
18
+ assert(plant.zeta==0.48 && plant.wn_rad_s==3 && plant.reference==1 && ...
19
+ plant.sampleInterval_s==0.01 && plant.window_s==12, ...
20
+ 'DSHM2:Plant','Unsupported plant definition');
21
+ for index=1:numel(manifest.items)
22
+ item=manifest.items(index);
23
+ assert(item.index==index-1,'DSHM2:Manifest','Run index mismatch');
24
+ checkRun(item.runDir,item.runId,item.imageId,manifest.sweepId);
25
+ gains=item.parameters;
26
+ assert(all(isfinite([gains.kp gains.ki gains.kd])) && ...
27
+ all([gains.kp gains.ki gains.kd]>=0) && all([gains.kp gains.ki gains.kd]<=100), ...
28
+ 'DSHM2:Gains','Invalid PID gains');
29
+ a2=2*plant.zeta*plant.wn_rad_s+plant.wn_rad_s^2*gains.kd;
30
+ a1=plant.wn_rad_s^2*(1+gains.kp);
31
+ a0=plant.wn_rad_s^2*gains.ki;
32
+ assert(gains.ki==0 || a2*a1>a0,'DSHM2:Unstable','Unstable closed loop');
33
+ assert(~isfile(fullfile(item.runDir,'metrics.json')) && ...
34
+ ~isfile(fullfile(item.runDir,'samples.csv')) && ...
35
+ ~isfile(fullfile(item.runDir,'figure.json')), ...
36
+ 'DSHM2:Exists','Refusing to overwrite existing experiment');
37
+ end
38
+ checkRun(manifest.comparison.runDir,manifest.comparison.runId, ...
39
+ manifest.comparison.imageId,manifest.sweepId);
40
+ assert(~isfile(fullfile(manifest.comparison.runDir,'figure.json')), ...
41
+ 'DSHM2:Exists','Comparison figure already exists');
42
+
43
+ stepTime=(0:plant.sampleInterval_s:plant.window_s)';
44
+ trajectories=zeros(numel(stepTime),numel(manifest.items));
45
+ labels=cell(numel(manifest.items),1);
46
+ opts=odeset('RelTol',1e-9,'AbsTol',1e-11);
47
+ for index=1:numel(manifest.items)
48
+ item=manifest.items(index); g=item.parameters;
49
+ % Plant: y'' + 2*zeta*wn*y' + wn^2*y = wn^2*u.
50
+ % PID derivative acts on the measured output to avoid setpoint kick.
51
+ [t,x]=ode45(@(~,x)pidODE(x,g,plant),stepTime,[0;0;0],opts);
52
+ y=x(:,1); v=x(:,2); integralError=x(:,3);
53
+ u=g.kp*(plant.reference-y)+g.ki*integralError-g.kd*v;
54
+ assert(all(isfinite([y;u])) && max(abs([y;u]))<1e6, ...
55
+ 'DSHM2:Diverged','Simulation output diverged');
56
+ errorSignal=plant.reference-y;
57
+ [peak,peakIndex]=max(y);
58
+ lastOutside=find(abs(errorSignal)>0.02*abs(plant.reference),1,'last');
59
+ settle=[];settleStatus='not_determined_in_window';
60
+ if isempty(lastOutside)
61
+ settle=t(1);settleStatus='within_recorded_window';
62
+ elseif lastOutside<numel(t)
63
+ settle=t(lastOutside+1);settleStatus='within_recorded_window';
64
+ end
65
+ if g.ki>0
66
+ analyticFinal=plant.reference;
67
+ else
68
+ analyticFinal=plant.reference*g.kp/(1+g.kp);
69
+ end
70
+ metrics=struct('schemaVersion',2,'kind','pid_step_response', ...
71
+ 'source','MATLAB','method','ode45 sampled trajectory; MATLAB trapz and last outside reference 2 percent band', ...
72
+ 'matlab',version,'solver','ode45','relativeTolerance',1e-9, ...
73
+ 'absoluteTolerance',1e-11,'sampleInterval_s',plant.sampleInterval_s, ...
74
+ 'window_s',plant.window_s,'reference',plant.reference, ...
75
+ 'settlingBandFraction',0.02,'plant',plant,'pid',g, ...
76
+ 'derivativeMethod','derivative on measurement; no setpoint kick', ...
77
+ 'peak',peak,'peakTime_s',t(peakIndex), ...
78
+ 'overshoot_percent',max(0,100*(peak-plant.reference)/abs(plant.reference)), ...
79
+ 'sampledSettlingTime_s',settle,'settlingStatus',settleStatus, ...
80
+ 'integratedAbsoluteError',trapz(t,abs(errorSignal)), ...
81
+ 'integratedSquaredError',trapz(t,errorSignal.^2), ...
82
+ 'maxAbsoluteControl',max(abs(u)), ...
83
+ 'finalSampleError',errorSignal(end), ...
84
+ 'analyticSteadyStateError',plant.reference-analyticFinal, ...
85
+ 'steadyStateMethod','Stable closed-loop final-value theorem: Ki>0 => zero reference error; Ki=0 => 1/(1+Kp)');
86
+ writematrix([t y u],fullfile(item.runDir,'samples.csv'));
87
+ writeNewJson(fullfile(item.runDir,'metrics.json'),metrics);
88
+ fig=figure('Visible','off'); cleaner=onCleanup(@()close(fig));
89
+ plot(t,y,'LineWidth',1.5);hold on;
90
+ yline(plant.reference,'--');yline(plant.reference*0.98,':');yline(plant.reference*1.02,':');
91
+ grid on;xlabel('Time (s)');ylabel('Output y');
92
+ title(sprintf('PID %d: Kp=%.3g Ki=%.3g Kd=%.3g',index,g.kp,g.ki,g.kd));
93
+ dsh_m1_save_figure(fig,string(item.runDir),string(item.imageId), ...
94
+ "PID step response " + index);
95
+ clear cleaner;
96
+ trajectories(:,index)=y;
97
+ labels{index}=sprintf('%d: P=%.3g I=%.3g D=%.3g',index,g.kp,g.ki,g.kd);
98
+ end
99
+
100
+ fig=figure('Visible','off');cleaner=onCleanup(@()close(fig));
101
+ plot(stepTime,trajectories,'LineWidth',1.5);hold on;
102
+ yline(plant.reference,'--k','Reference');grid on;
103
+ xlabel('Time (s)');ylabel('Output y');title('M2 PID parameter sweep comparison');
104
+ legend(labels,'Location','best');
105
+ dsh_m1_save_figure(fig,string(manifest.comparison.runDir), ...
106
+ string(manifest.comparison.imageId),'PID sweep overlay comparison');
107
+ clear cleaner;
108
+ utc=datetime('now','TimeZone','UTC','Format',"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'");
109
+ summary=struct('schemaVersion',2,'sweepId',manifest.sweepId, ...
110
+ 'runCount',numel(manifest.items),'completedUtc',char(utc));
111
+ writeNewJson(fullfile(sweepDir,'completed.json'),summary);
112
+ disp('DSH_M2_SWEEP_SUCCEEDED');disp(jsonencode(summary));
113
+ end
114
+
115
+ function dx=pidODE(x,g,plant)
116
+ e=plant.reference-x(1);
117
+ u=g.kp*e+g.ki*x(3)-g.kd*x(2);
118
+ dx=[x(2);plant.wn_rad_s^2*(u-x(1))-2*plant.zeta*plant.wn_rad_s*x(2);e];
119
+ end
120
+
121
+ function checkRun(runDir,runId,imageId,sweepId)
122
+ assert(isfolder(runDir),'DSHM2:Run','Run directory missing');
123
+ prepared=jsondecode(fileread(fullfile(runDir,'prepared.json')));
124
+ assert(prepared.schemaVersion==1 && string(prepared.runId)==string(runId) && ...
125
+ string(prepared.runDir)==string(runDir) && ...
126
+ string(prepared.imageId)==string(imageId) && ...
127
+ strcmp(prepared.provenance.sweepId,sweepId), ...
128
+ 'DSHM2:Run','Run preparation mismatch');
129
+ end
130
+
131
+ function writeNewJson(target,value)
132
+ assert(~isfile(target),'DSHM2:Exists','Refusing to replace an existing record');
133
+ temporary=string(tempname(fileparts(target)))+".partial.json";
134
+ fid=fopen(temporary,'w','n','UTF-8');
135
+ assert(fid>=0,'DSHM2:Write','Unable to create record');
136
+ closer=onCleanup(@()fclose(fid));
137
+ fwrite(fid,unicode2native(jsonencode(value),'UTF-8'),'uint8');
138
+ clear closer;
139
+ assert(~isfile(target),'DSHM2:Exists','Refusing to replace an existing record');
140
+ [ok,msg]=movefile(temporary,target);
141
+ assert(ok,'DSHM2:Commit','%s',msg);
142
+ end
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "dsh-matlab-workbench",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-beta.2",
4
+ "type": "module",
5
+ "description": "DSH Desktop MATLAB experiment workbench: MCP figures, PID sweeps, verified metrics and comparisons",
6
+ "license": "MIT",
7
+ "keywords": [
8
+ "dsh-plugin",
9
+ "deepseek-harness",
10
+ "matlab",
11
+ "matlab-mcp-server",
12
+ "pid",
13
+ "simulation",
14
+ "engineering"
15
+ ],
16
+ "engines": {
17
+ "node": ">=24",
18
+ "dsh": "0.2.1-rc.2 || 0.2.1-rc.3"
19
+ },
20
+ "exports": {
21
+ ".": "./dsh/host/index.mjs",
22
+ "./client": "./dsh/client/index.js",
23
+ "./package.json": "./package.json",
24
+ "./icon": "./icon.svg",
25
+ "./locale/*.json": "./locale/*.json"
26
+ },
27
+ "files": [
28
+ "core",
29
+ "dsh",
30
+ "matlab/dsh_m1_save_figure.m",
31
+ "matlab/dsh_m2_pid_sweep.m",
32
+ "examples",
33
+ "integration",
34
+ "locale",
35
+ "icon.svg",
36
+ "cordis.patch.yml",
37
+ "LICENSE",
38
+ "README.md",
39
+ "README.en.md"
40
+ ],
41
+ "scripts": {
42
+ "test": "node --test tests/*.test.mjs",
43
+ "test:matlab:m2": "node probes/m2/run-matlab-validation.mjs",
44
+ "check": "node --check core/figures.mjs && node --check core/sweeps.mjs && node --check dsh/host/index.mjs && node --check dsh/host/projection.mjs && node --check dsh/client/index.js"
45
+ },
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ },
50
+ "client": {
51
+ "platform": "web",
52
+ "immediately": false,
53
+ "inject": []
54
+ }
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/lemonxiny55/dsh-matlab-workbench.git"
59
+ },
60
+ "homepage": "https://github.com/lemonxiny55/dsh-matlab-workbench#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/lemonxiny55/dsh-matlab-workbench/issues"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public",
66
+ "tag": "beta"
67
+ }
68
+ }