【Python】simulink与python联合仿真

1.1 Simulink 的边界:事件驱动、算法复杂性与 AI 集成瓶颈

Simulink 的核心优势在于其强大的微分方程求解器和对连续时间系统、离散时间系统的精确描述能力。其基于“信号流”和“框图”的建模范式,使得工程师可以直观地构建与物理现实高度对应的数学模型。然而,这种优势也带来了其天然的局限性:

  1. 基于时间的驱动核心 (Time-Based Core Engine):
    Simulink 的“心脏”是一个时间驱动的仿真引擎(Simulation Engine)。无论是定步长(Fixed-Step)还是变步长(Variable-Step)求解器,其核心逻辑都是“推进时间,计算状态,更新输出”。这种机制对于描述物理系统的演化规律(如电路的电压变化、机械臂的运动轨迹)是完美的。但是,当系统逻辑由“外部事件”而非“时间流逝”驱动时,Simulink 的表达就变得非常笨拙。

    • 案例思考: 想象一个复杂的网络服务器集群。其核心驱动事件是用户的随机请求(Request)、服务器的宕机(Failure)、数据的异步IO完成(I/O Completion)。这些事件的发生时间点是随机的、不可预测的。如果用 Simulink 对其进行建模,你可能需要借助 Stateflow 或 Discrete-Event System 等工具箱,但这会使得模型变得异常复杂,且难以描述复杂的事件处理逻辑,如带优先级的任务队列、基于内容的路由策略等。Python 的 asyncio 库或 SimPy 这样的离散事件仿真框架,在处理此类问题时则显得游刃有余。
  2. 算法实现的复杂性与局限性:
    虽然可以通过 MATLAB Function Block 或 S-Function(使用 C/C++ 或 MATLAB 语言)在 Simulink 中实现复杂的算法,但其开发效率、调试难度和可移植性远低于 Python。

    • 案例思考: 假设我们需要在控制算法中嵌入一个复杂的优化问题,例如,根据实时电价、天气预报(影响光伏发电)和用户用电习惯,动态规划未来24小时的家庭储能系统充放电策略。这是一个典型的随机动态规划问题。在 Python 中,你可以轻易地调用 SciPy.optimizeCVXPYGurobi 等强大的优化库来求解。如果要在 S-Function 中用 C++ 实现同样的功能,你需要手动实现复杂的数值优化算法,或者链接庞大的第三方 C++ 库,这将是一场工程噩梦。
  3. AI/ML 模型集成的“重”与“慢”:
    MathWorks 正在积极地通过 Deep Learning Toolbox 和 Statistics and Machine Learning Toolbox 等工具箱来弥合与 AI 生态的差距。你确实可以将一个预训练好的 ONNX 模型或 TensorFlow 模型导入 Simulink 中进行推理。但这个过程存在几个根本性问题:

    • 训练与推理的割裂: AI 模型的核心生命周期是“数据清洗 -> 特征工程 -> 模型训练 -> 验证 -> 调优 -> 部署推理”。整个工作流几乎完全在 Python 生态中完成(pandas, scikit-learn, PyTorch, TensorFlow)。将最终的模型“导入”到 Simulink 中,仅仅是整个链条的最后一环。当需要进行在线学习(Online Learning)或模型的持续再训练时,这种导入模式就显得力不从心。
    • 生态的滞后性: AI 领域的发展日新月异。当一个新的、革命性的模型架构(例如,某种新的 Transformer 变体)在 arXiv 上发表时,其 PyTorchTensorFlow 的实现版本通常在几天甚至几小时内就会出现在 GitHub 上。而等待 MathWorks 官方工具箱支持这个新模型,可能需要数月甚至数年的时间。
    • 部署的复杂性: 将包含 AI 模型的 Simulink 系统生成 C/C++ 代码进行硬件在环(HIL)或嵌入式部署时,其依赖项和代码体积会急剧膨胀,给部署带来巨大挑战。
1.2 Python 的疆域:从数据科学到强化学习的生态优势

与 Simulink 的“专精”不同,Python 的核心优势在于其“广博”的生态系统和无与伦比的“胶水语言”特性。

  1. 数据科学与机器学习的全栈能力:
    Python 拥有从数据读取 (pandas)、数据清洗、数值计算 (numpy, scipy)、机器学习 (scikit-learn)、深度学习 (PyTorch, TensorFlow, JAX) 到数据可视化 (matplotlib, seaborn, plotly) 的完整工具链。这使得 Python 成为现代 AI 算法开发与验证的“母语”。

  2. 高级决策与运筹优化:
    复杂的决策逻辑,如路径规划(A* 算法)、资源调度(整数规划)、博弈论分析等,在 Python 中有大量成熟的库(如 NetworkX, PuLP, OR-Tools)可供直接使用。这些问题本质上是符号计算和图论的范畴,用 Simulink 的信号流来表达是极其困难的。

  3. 通信与集成的便捷性:
    Python 提供了极其丰富的网络编程、数据库连接、API 调用(RESTful, gRPC)和消息队列(RabbitMQ, ZeroMQ, Kafka)库。这使得 Python 程序可以轻松地与外部世界进行交互,无论是连接一个真实的硬件设备,还是拉取一个云端的数据库,或是订阅一个实时的数据流。

  4. 强化学习(Reinforcement Learning)的天然平台:
    强化学习是“智能体(Agent)”与“环境(Environment)”交互学习的范式。Simulink 模型是构建高保真“环境”的绝佳工具(例如,模拟一辆自动驾驶汽车的动力学和传感器)。而“智能体”的大脑,即策略网络和价值网络的训练与决策,几乎无一例外地是在 Python 框架(如 Stable-Baselines3, Ray RLlib)中实现的。联合仿真是连接 RL 的“大脑”与“身体”的最自然方式。

1.3 “联合”的本质:模型交换 vs. 协同仿真

在探讨具体技术之前,必须厘清两个极易混淆的概念:模型交换(Model Exchange)和协同仿真(Co-simulation)。这两种技术都属于功能模型接口(Functional Mock-up Interface, FMI)标准的一部分,但其哲学思想和应用场景截然不同。

  • 模型交换 (Model Exchange):

    • 核心思想: “我把我的模型方程给你,你来负责求解”。
    • 工作流程: 一个工具(如 Simulink)将其子系统模型导出为一个功能模型单元(Functional Mock-up Unit, FMU)。这个 FMU 文件中包含了该子系统的所有状态方程 (dx/dt = f(x, u, t)) 和输出方程 (y = g(x, u, t)),但 不包含 求解器。另一个主控仿真工具(Master)导入这个 FMU,并使用 自己的 求解器来对这个 FMU 的状态进行积分和求解。
    • 优点: 数值计算上可能更精确,因为整个大系统是由一个统一的求解器来求解的,避免了不同求解器之间的同步误差。
    • 缺点:
      1. 知识产权暴露: 模型的动态方程被暴露给了主控工具。
      2. 求解器兼容性: 导出的模型可能包含一些特性(如过零检测),是主控工具的求解器无法处理的。
      3. “黑盒”限制: 对于一些无法或不愿提供内部状态方程的模型(例如,一个用 Python 写的、基于规则的复杂决策系统),这种模式完全不适用。
  • 协同仿真 (Co-simulation):

    • 核心思想: “我们各自管好自己的模型和求解器,在约定的时间点同步一下数据就行”。
    • 工作流程: 参与联合仿真的每个工具(Simulink, Python 程序等)都是一个独立的“黑盒”。每个黑盒内部都有自己的状态和自己的求解器(或执行逻辑)。主控程序(Master,可以是其中一个工具,也可以是一个独立的程序)负责协调时间推进。在每个通信步长(Communication Step Size)的末尾,各个工具暂停自己的仿真,交换输入/输出数据,然后再继续独立仿真,直到下一个通信点。
    • 优点:
      1. 保护知识产权: 模型内部实现是完全封装的,只暴露输入/输出接口。
      2. 异构系统友好: 可以集成任何类型的仿真工具或程序,无论其内部是连续时间模型、离散事件模型、还是一个基于 if-else 的 Python 脚本。
      3. 灵活性高: 实现方式多样,从简单的 TCP/IP 套接字到复杂的消息队列都可以。
    • 缺点:
      1. 数值稳定性: 由于数据只在离散的通信点交换,如果通信步长设置不当,可能会在耦合系统之间引入数值振荡或不稳定。输入端口的数据在两个通信点之间通常是零阶保持(Zero-Order Hold)的,这可能与真实情况不符。
      2. 代数环问题: 如果 Simulink 的输出 y_sim 依赖于 Python 的输入 u_sim,而 Python 的输出 y_py 又在同一个时间步内依赖于 y_sim,这就形成了一个代数环,会导致仿真死锁。

本书的核心焦点是协同仿真 (Co-simulation),因为它是连接 Simulink 物理世界和 Python 智能世界的更通用、更灵活的桥梁。我们将要探讨的,正是如何在 Simulink 和 Python 这两个独立的“黑盒”之间,构建起高效、稳定、可靠的数据交换通道,并利用这个通道去解决真实世界中的复杂问题。

1.4 本书的仿真哲学:构建“数字孪生”的最小单元

为了避免空洞的理论和千篇一律的“倒立摆”、“水箱”示例,本书将围绕一个虚构但高度真实的场景展开:为下一代智能垂直农场设计并优化其核心环境控制系统。

这个系统构成一个“数字孪生”的最小但完整的单元,其复杂度足以挑战我们即将学习的每一项技术。

  • 仿真对象 (The Plant): 一个独立的植物生长舱。

  • Simulink 的职责 (物理环境建模):

    • 构建一个高保真的、非线性的环境动态模型。该模型是一个多输入多输出(MIMO)系统。
    • 输入:
      • u1: LED 光照强度 (W/m²)
      • u2: LED 光谱配比(红蓝光比例)
      • u3: 营养液滴灌速率 (mL/min)
      • u4: CO2 注入速率 (mg/s)
      • u5: 气流风扇转速 (RPM)
    • 状态:
      • x1: 舱内平均温度 (°C)
      • x2: 舱内平均湿度 (%RH)
      • x3: 舱内 CO2 浓度 (ppm)
      • x4: 基质含水量 (%)
    • 输出 (传感器读数):
      • y1, y2, y3, y4: 对应状态的带噪声测量值。
    • 建模挑战:
      • 各状态之间存在强耦合(如温度影响蒸发,从而影响湿度;CO2 浓度受气流影响)。
      • 系统存在非线性(如饱和效应)和时滞(如 CO2 扩散延迟)。
      • 需要模拟外部扰动(如舱门打开导致温湿度突变)。
  • Python 的职责 (智能决策大脑):

    • 高级控制器: 不再是简单的 PID 控制器。Python 程序需要实现一个多目标优化控制器。
      • 目标 A (最大化生长): 利用一个预训练好的高斯过程回归(Gaussian Process Regression)模型,根据当前环境状态 (y1, y2, y3, y4) 预测未来1小时的植物“生物量”增量。
      • 目标 B (最小化能耗): 计算 LED、风扇、CO2 注入等设备在给定控制输入 (u1..u5) 下的能量消耗。
      • 优化算法: 使用贝叶斯优化(Bayesian Optimization)或遗传算法(Genetic Algorithm)等黑盒优化算法,在每个决策时刻,求解一个能平衡目标A和目标B的最优控制输入 (u1..u5)
    • 异常检测与诊断:
      • 利用一个基于 LSTM(长短期记忆网络)的预训练模型,实时分析传感器时间序列数据 (y1, y2, y3, y4),以检测是否存在传感器漂移、设备故障或潜在的植物病害早期迹象。
    • 事件驱动逻辑:
      • 当检测到异常时,Python 程序需要发送一个“警报”事件,并切换到一种“安全”控制模式。

联合仿真的挑战:

  1. 数据流: Simulink 需要周期性地将传感器数据 (y1..y4) 发送给 Python。Python 需要将计算出的最优控制信号 (u1..u5) 和可能的警报信号 (alarm_flag) 发回给 Simulink。
  2. 时间同步: Simulink 是连续时间仿真的,而 Python 的优化计算可能需要花费几十毫秒甚至几秒。如何协调两者的时间步,确保数据交换的有效性?
  3. 系统启动: 如何优雅地初始化整个联合仿真系统?
  4. 性能: 如何最小化通信开销,以支持接近实时的控制回路?

这个案例完美地体现了 Simulink 和 Python 的边界与疆域。Simulink 擅长描述物理规律,而 Python 擅长处理数据、智能和复杂逻辑。任何一方都无法独立、高效地完成整个系统的建模与仿真。它们的“联合”,是解决问题的唯一途径。

在下一章,我们将深入到最底层的细节,探讨如何在这两个世界之间,用最原始的工具——套接字(Socket)——搭建起第一座通信的桥梁,从而真正理解联合仿真背后的“握手”协议和数据交换的本质。


第二章:通信的灵魂 - 揭秘底层握手协议与数据交换

市面上存在多种实现 Simulink-Python 联合仿真的高级工具和库,例如使用 MATLAB Engine API for Python,或在 Simulink 中嵌入 Python S-Function。这些工具极大地简化了开发流程,但同时也隐藏了底层的通信细节,使得开发者在遇到问题时(如性能瓶颈、数据同步错误、部署困难)往往束手无策。

本章的目的是撕开这些高级封装的“语法糖”,直面联合仿真的“硬核”本质——通信。我们将从第一性原理出发,探讨一个 Simulink 模型和一个独立的 Python 脚本,是如何通过网络进行“对话”的。我们将手动实现一个基于 TCP/IP 套接字的、最原始的联合仿真框架。

这个过程虽然繁琐,但其价值是无与伦比的。完成本章的学习后,你将能够:

  • 深刻理解 Simulink 仿真循环的内部机制。
  • 掌握 C S-Function 的生命周期回调函数的核心作用。
  • 用 Python 的 socket 库实现一个健壮的仿真服务器。
  • 理解数据序列化(Serialization)的本质,并手动实现一个简单高效的二进制数据包协议。
  • 诊断和解决联合仿真中最常见的时序和同步问题。

这不仅仅是一个练习,这是一个构建心智模型的過程。掌握了这些底层知识,任何上层工具对你来说都将是透明的。

2.1 Simulink Engine 的时钟心跳:仿真循环的内部窥探

要与 Simulink 进行外部通信,首先必须理解 Simulink 是如何“思考”的。Simulink 的仿真过程并非一个连续的流,而是一个由离散步骤组成的循环,即 仿真循环 (Simulation Loop)。即使对于连续系统,求解器也是通过一系列离散的计算来逼近连续行为的。

让我们以一个 定步长(Fixed-Step)求解器 为例,来剖析仿真循环的关键阶段。假设我们设置求解器为 ode3 (Bogacki-Shampine),步长为 h。在每个时间步 t_kt_{k+1} = t_k + h 的过程中,Simulink Engine 会执行一系列严谨的操作:

  1. Loop Start (t = t_k): 循环开始。
  2. Outputs: 计算所有模块在 t_k 时刻的输出。这是一个重要的阶段。对于一个给定的模块,其输出 y(t_k) 是其当前状态 x(t_k) 和输入 u(t_k) 的函数,即 y(t_k) = g(x(t_k), u(t_k))。引擎会按照模块的依赖关系(从没有输入的源模块开始,逐级计算)来确定计算顺序。
  3. Update: 计算所有模块的离散状态的更新。例如,一个离散积分器 x_d(k+1) = x_d(k) + u(k) * T_s 会在这个阶段执行。
  4. Derivatives: 计算所有模块的连续状态的导数。对于一个连续状态 x_c,引擎需要计算 dx_c/dtt_k 时刻的值。这是求解微分方程的核心步骤。dx_c/dt = f(x_c(t_k), u(t_k))
  5. Integrate: 求解器根据 Derivatives 阶段计算出的导数值,以及可能的前几个时间步的信息,来计算下一个时间点的连续状态 x_c(t_{k+1})。例如,ode3 求解器会在此阶段进行多次(3次)“试探性”的导数计算,以获得三阶精度。
  6. Loop End (t -> t_{k+1}): 时间推进到 t_{k+1}
  7. 回到步骤1,开始新的循环。

关键洞察: 从外部通信的角度看,仿真循环中最适合进行数据交换的“缝隙”在哪里?

  • Outputs 阶段之后,UpdateDerivatives 阶段之前 是一个黄金时机。
  • 为什么? 因为在这个时间点,Simulink 已经计算出了当前时刻 t_k 的所有输出 y_sim(t_k),这些数据可以被发送到 Python。同时,它即将开始计算下一个状态,正需要外部的控制输入 u_sim(t_k)
  • 流程可以设计为:
    1. Simulink 在 t_k 时刻运行到 Outputs 阶段结束。
    2. Simulink 暂停 仿真循环。
    3. Simulink 通过通信接口,将传感器数据 y_sim(t_k) 打包发送给 Python。
    4. Simulink 阻塞等待,直到从 Python 收到控制数据 u_sim(t_k)
    5. 收到数据后,Simulink 将 u_sim(t_k) 更新到相应模块的输入端口。
    6. Simulink 恢复 仿真循环,继续执行 UpdateDerivatives 等阶段。

这种“暂停-交换-恢复”的模式,是协同仿真的核心机制。而实现这个机制的关键,就是在 Simulink 模型中安插一个能够与外部世界对话,并且能够控制仿真循环暂停与恢复的“代理人”——这正是 S-Function 的使命。

2.2 S-Function 的生命周期:构建通信代理

S-Function (System-Function) 是 Simulink 提供的一个强大的用户自定义模块接口。它允许我们使用 C, C++, Fortran, Ada, 或者 MATLAB 语言来创建功能极其灵活的模块。对于需要与外部系统进行底层通信的场景,C S-Function 是不二之选,因为它能直接调用操作系统的网络库(如 Windows Sockets API 或 POSIX Sockets)。

一个 S-Function 并不只是一个单一的函数,而是一个遵循特定模板的 C 文件,它包含了一系列的回调函数(Callback Methods)。Simulink 引擎在仿真循环的不同阶段,会调用这些回调函数,从而让我们的自定义代码能够在精确的时间点介入仿真过程。

为了实现我们的通信代理,我们需要重点关注以下几个回调函数:

  • mdlInitializeSizes: 在仿真开始前,Simulink 会调用这个函数一次。我们在这里定义 S-Function 的基本属性,比如它有多少个输入端口、多少个输出端口,以及每个端口的维度、数据类型等。最重要的是,我们要在这里设置一个参数,告诉 Simulink 我们这个模块是特殊的,需要被区别对待。

  • mdlInitializeSampleTimes: 也是在仿真开始前调用。这里我们定义 S-Function 的“心跳”——它的采样时间。对于联合仿真,我们通常会设置一个离散的采样时间,这个采样时间就是我们之前提到的 通信步长 (Communication Step Size)。例如,如果我们设置采样时间为 0.01 秒,那么 Simulink 将确保每隔 0.01 秒,就会来调用我们的核心处理函数。

  • mdlStart: 在仿真时间 t=0 时,正式开始仿真循环之前,该函数被调用一次。这是进行 一次性初始化 的绝佳位置。对于我们的通信代理来说,所有的网络初始化工作,如创建套接字(Socket)、绑定(Bind)端口、连接(Connect)到 Python 服务器等,都应该在这里完成。

  • mdlOutputs: 这是 S-Function 的核心工作函数之一。每当 S-Function 的一个采样时间点到达时,Simulink 就会调用这个函数。我们的主要任务是:从 S-Function 的输入端口读取 Simulink 模型内部的信号(即传感器数据),并将这些数据通过网络发送给 Python。

  • mdlUpdate: 在 mdlOutputs 执行完毕后,如果 S-Function 有自己的状态需要更新,Simulink 就会调用这个函数。我们的核心任务是:阻塞式地 从网络接收来自 Python 的数据(即控制指令),然后将这些数据写入 S-Function 的输出端口,供 Simulink 模型中的其他模块使用。

    • “阻塞”是关键:通过在 mdlUpdate 中进行阻塞式的网络接收(recv),我们实际上“冻结”了 Simulink 的仿真循环。Simulink 引擎会一直停留在 Update 阶段,耐心等待 mdlUpdate 函数返回。直到 Python 服务器发来数据,recv 调用返回,mdlUpdate 函数执行完毕,Simulink 的时钟心跳才能继续跳动。这就完美地实现了我们之前设计的“暂停-交换-恢复”机制。
  • mdlTerminate: 当仿真结束时(无论正常结束、用户手动停止还是出错),这个函数会被调用一次。我们在这里执行清理工作,比如正常关闭网络连接、释放内存等,确保不留下任何“僵尸进程”或“内存泄漏”。

通过在 S-Function 的这几个生命周期回调中精心编排我们的网络通信代码,我们就能在 Simulink 这个封闭的王国里,打开一扇通往外部 Python 世界的大门。

2.3 Python 端的核心:从零构建一个 socket 仿真服务器

现在,让我们切换到 Python 的世界。Python 端需要扮演一个“服务器”的角色,它需要:

  1. 创建一个 TCP 服务器,并监听一个特定的端口,等待来自 Simulink S-Function(客户端)的连接。
  2. 接受连接后,进入一个主循环。
  3. 在循环的每一步:
    a. 等待并接收来自 Simulink 的数据包(传感器数据)。
    b. 对接收到的数据进行解码(反序列化)。
    c. 调用核心的智能算法(例如,我们的优化器或故障诊断模型)进行计算。
    d. 将计算结果(控制指令)编码(序列化)成一个数据包。
    e. 将这个数据包发送回 Simulink。
  4. 当 Simulink 结束仿真时,能够优雅地关闭连接。

我们将使用 Python 内置的 socket 库来实现这一切,不依赖任何第三方框架。

数据包协议设计 (Data Packet Protocol):

在网络通信中,数据是以字节流(Byte Stream)的形式传输的。我们必须事先和 Simulink S-Function 约定好一个严格的数据格式,否则双方将无法理解对方发送的字节是什么意思。这就是 应用层协议

为了兼顾效率和简单性,我们设计一个非常基础的二进制协议。假设在我们的垂直农场案例中:

  • Simulink 要发送 4 个 double 类型的传感器数据 (y1y4)。
  • Python 要返回 5 个 double 类型的控制指令 (u1u5) 和 1 个 int32 类型的警报标志。

一个 double 类型在 C 和 Python (NumPy) 中通常占用 8 个字节。一个 int32 占用 4 个字节。

  • Simulink -> Python 的数据包 (S2P_Packet):

    • 总长度: 4 * 8 = 32 字节。
    • 结构: [y1_bytes, y2_bytes, y3_bytes, y4_bytes]
  • Python -> Simulink 的数据包 (P2S_Packet):

    • 总长度: 5 * 8 + 4 = 44 字节。
    • 结构: [u1_bytes, u2_bytes, u3_bytes, u4_bytes, u5_bytes, alarm_flag_bytes]

我们将使用 Python 的 struct 模块来完成 float/intbytes 之间的转换(即序列化和反序列化)。struct 模块可以让我们像在 C 语言中一样,精确地控制字节的打包和解包。

Python 服务器代码实现 (vertical_farm_server.py):

# vertical_farm_server.py
# 一个用于与 Simulink 进行协同仿真的原生 TCP Socket 服务器

import socket
import struct
import numpy as np
import time

# 定义服务器配置
HOST = '127.0.0.1'  # 本地主机IP地址
PORT = 65432        # 监听的端口号 (选择一个大于1023的非著名端口)

# 定义我们与Simulink约定的数据包结构
# Simulink -> Python: 4个double类型的数据
S2P_PACKET_SIZE = 4 * 8  # 4个double,每个8字节
S2P_PACKET_FORMAT = ' # '<'表示小端字节序, 'd'表示double

# Python -> Simulink: 5个double和1个int32
P2S_PACKET_SIZE = 5 * 8 + 1 * 4 # 5个double,1个int
P2S_PACKET_FORMAT = ' # '<'表示小端字节序, 'd'是double, 'i'是int32

def intelligent_controller(sensor_data: np.ndarray) -> tuple[np.ndarray, int]:
    """
    这是一个智能控制器的存根(Stub)函数。
    在真实应用中,这里会包含复杂的AI/优化算法。
    为了演示,我们只实现一个简单的比例控制器和一个模拟的异常检测。

    参数:
        sensor_data (np.ndarray): 从Simulink接收到的传感器数据数组 [temp, humidity, co2, water_content]

    返回:
        tuple[np.ndarray, int]: 一个元组,包含:
            - control_signals (np.ndarray): 计算出的5个控制信号
            - alarm_flag (int): 警报标志 (0:正常, 1:异常)
    """
    print(f"  [Python Brain] 接收到传感器数据: Temp={
     sensor_data[0]:.2f}, Humid={
     sensor_data[1]:.2f}, CO2={
     sensor_data[2]:.2f}, Water={
     sensor_data[3]:.2f}")

    # --- 模拟控制算法 ---
    # 假设我们的目标是: Temp=25, Humid=60, CO2=800, Water=50
    targets = np.array([25.0, 60.0, 800.0, 50.0])
    errors = targets - sensor_data
    
    # 简单的比例控制
    # light_intensity, light_spectrum, nutrient_rate, co2_rate, fan_speed
    # 这里的控制逻辑是瞎编的,仅为演示数据流动
    control_signals = np.zeros(5)
    control_signals[0] = 500 + errors[0] * 10  # 根据温度误差调节光照强度
    control_signals[1] = 0.8 - errors[0] * 0.01 # 根据温度误差调节光谱
    control_signals[2] = 10 + errors[3] * 0.5   # 根据基质水分调节营养液
    control_signals[3] = 5 + errors[2] * 0.01   # 根据CO2浓度调节注入
    control_signals[4] = 3000 + errors[0] * 50  # 根据温度调节风扇

    # 限制控制信号范围
    control_signals = np.clip(control_signals, 
                              [0, 0.1, 0, 0, 1000], 
                              [1000, 1.0, 50, 20, 8000])

    # --- 模拟异常检测 ---
    alarm_flag = 0
    # 如果检测到温度过高,则触发警报
    if sensor_data[0] > 35.0:
        alarm_flag = 1
        print("  [Python Brain] 警报! 温度过高!")

    print(f"  [Python Brain] 计算出控制信号: {
     np.round(control_signals, 2)}")
    print(f"  [Python Brain] 警报标志: {
     alarm_flag}")
    
    return control_signals, alarm_flag


def run_simulation_server():
    """
    运行主仿真服务器函数
    """
    # AF_INET 表示使用 IPv4 地址族
    # SOCK_STREAM 表示使用 TCP 协议
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        # 绑定IP地址和端口
        s.bind((HOST, PORT))
        # 开始监听,允许最多1个连接排队
        s.listen(1)
        print(f"[服务器] 正在监听 {
     HOST}:{
     PORT} ...")
        
        # 阻塞程序,等待客户端(Simulink)的连接
        conn, addr = s.accept()
        # conn是新的socket对象,用于与客户端通信
        # addr是客户端的地址
        
        with conn:
            print(f"[服务器] 已连接,客户端地址: {
     addr}")
            simulation_step = 0
            while True:
                # 1. 从Simulink接收数据
                # recv()会阻塞,直到接收到指定大小的数据
                data_from_simulink = conn.recv(S2P_PACKET_SIZE)
                
                # 如果接收到的数据为空,表示客户端已关闭连接
                if not data_from_simulink:
                    print("[服务器] Simulink客户端已断开连接。")
                    break
                
                # 检查接收到的数据大小是否正确
                if len(data_from_simulink) != S2P_PACKET_SIZE:
                    print(f"[服务器] 错误:接收到的数据包大小不正确!期望 {
     S2P_PACKET_SIZE} 字节,实际 {
     len(data_from_simulink)} 字节。")
                    break

                print(f"\n--- 仿真步 {
     simulation_step} ---")

                # 2. 解码/反序列化数据
                # 使用我们之前定义的格式 '
                try:
                    sensor_values = struct.unpack(S2P_PACKET_FORMAT, data_from_simulink)
                    sensor_values_np = np.array(sensor_values)
                except struct.error as e:
                    print(f"[服务器] 错误:数据解包失败!{
     e}")
                    break

                # 3. 调用核心智能算法
                # 模拟计算耗时
                time.sleep(0.005) # 模拟5ms的计算延迟
                control_output, alarm = intelligent_controller(sensor_values_np)

                # 4. 编码/序列化返回数据
                # 将控制信号和警报标志打包成字节流
                data_to_simulink = struct.pack(P2S_PACKET_FORMAT, 
                                               control_output[0],
                                               control_output[1],
                                               control_output[2],
                                               control_output[3],
                                               control_output[4],
                                               alarm)

                # 5. 将数据发送回Simulink
                conn.sendall(data_to_simulink)
                print("[服务器] 已将控制信号发送回Simulink。")
                
                simulation_step += 1

    print("[服务器] 服务器已关闭。")

if __name__ == '__main__':
    run_simulation_server()

这段 Python 代码做了什么?

  1. 全局常量定义: 我们在代码开头清晰地定义了 HOST, PORT, 以及两个方向的数据包格式和大小 (S2P_..., P2S_...)。这种做法极大地提高了代码的可读性和可维护性。'struct 模块的格式字符串,'<' 指定了字节序为小端(Little-Endian),这对于跨平台通信至关重要,因为 x86/x64 架构的 CPU 使用小端序。'd' 代表一个8字节的 double'i' 代表一个4字节的 int
  2. intelligent_controller 函数: 这是我们“智能大脑”的占位符。它接收一个 NumPy 数组(传感器数据),并返回一个 NumPy 数组(控制信号)和一个整数(警报标志)。目前它只实现了一个非常简单的逻辑,但在后续章节中,这里将被替换为真正的高级算法。
  3. run_simulation_server 函数: 这是服务器的主体。
    • socket.socket(...): 创建一个 TCP 套接字。
    • s.bind(...): 将套接字绑定到本地的 IP 地址和端口号上,让服务器“有家可归”。
    • s.listen(1): 让服务器进入监听状态,准备接受连接。参数 1 表示内核为此套接字维护的、已完成三次握手但尚未被 accept 的连接队列的最大长度。
    • s.accept(): 这是一个阻塞函数。程序会在这里停下,直到一个客户端(我们的 S-Function)成功连接上来。连接成功后,它返回一个新的套接字对象 conn(专门用于与这个客户端通信)和客户端的地址 addr
    • while True:: 进入主服务循环。
    • conn.recv(S2P_PACKET_SIZE): 这是循环中的第一个阻塞点。服务器在这里等待接收来自 Simulink 的数据。我们明确要求接收 S2P_PACKET_SIZE (32) 个字节。如果网络缓冲区中的数据少于32字节,recv 会一直等待。这是确保我们收到一个完整数据包的关键。
    • if not data_from_simulink:: 这是一个重要的检查。如果 recv 返回一个空字节串,它是一个明确的信号,表示对方(Simulink)已经关闭了连接(通过调用 closeshutdown)。这时服务器应该优雅地退出循环。
    • struct.unpack(...): 将接收到的32字节原始数据,按照 ' 格式,解析成一个包含4个浮点数的元组。
    • struct.pack(...): 在调用控制器计算出结果后,使用此函数将5个 double 和1个 int 按照 ' 格式,打包成一个44字节的字节串,准备发送。
    • conn.sendall(...): 将打包好的数据发送回 Simulink。sendall 会确保所有数据都被发送出去,这比 send 更可靠。

现在,我们已经拥有了一个功能完备、健壮的 Python 仿真服务器。它像一个耐心的伙伴,静静地等待着来自 Simulink 的每一次“心跳”,并在收到信号后迅速做出响应。下一步,也是最硬核的一步,就是去 Simulink 的世界里,创建一个能够与这个服务器“对话”的 C S-Function。

2.4 底层之核:从零构建 C S-Function 通信客户端

现在我们进入最激动人心的部分:编写 C S-Function。这需要一些 C 语言和编译环境的知识。你需要一个支持的 C/C++ 编译器(在 Windows 上通常是 Visual Studio Community Edition 自带的 MSVC,在 Linux 上是 GCC)。

我们的 S-Function py_cosim_sfcn.c 需要完成以下任务:

  1. mdlStart 中,作为 TCP 客户端,连接到我们刚刚创建的 Python 服务器。
  2. mdlOutputs 中,从 S-Function 的输入端口读取数据,并将其打包成我们约定的 S2P_Packet 格式,通过网络发送出去。
  3. mdlUpdate 中,阻塞式地接收来自 Python 服务器的 P2S_Packet,解包后,将数据写入 S-Function 的输出端口。
  4. mdlTerminate 中,关闭网络连接。

代码实现 (py_cosim_sfcn.c):
注意: 在 Windows 和 Linux/macOS 上,套接字编程的头文件和一些函数名略有不同。下面的代码将使用预处理器宏 (#if defined(_WIN32)) 来处理这种跨平台差异。

/*
 * py_cosim_sfcn.c
 *
 * 一个用于与外部Python服务器进行TCP/IP协同仿真的C S-Function。
 * 
 * 功能:
 * - 在仿真开始时,作为客户端连接到指定的Python服务器。
 * - 在每个采样时间点:
 *   1. 从输入端口读取Simulink数据。
 *   2. 将数据打包并通过TCP socket发送给Python。
 *   3. 阻塞等待,接收来自Python的计算结果。
 *   4. 解包结果,并将其写入输出端口。
 * - 在仿真结束时,关闭连接。
 */

#define S_FUNCTION_NAME  py_cosim_sfcn
#define S_FUNCTION_LEVEL 2

#include "simstruc.h" // Simulink S-Function 必须包含的头文件

// --- 平台相关的网络库包含 ---
#if defined(_WIN32)
    // Windows Sockets (Winsock)
    #include 
    #pragma comment(lib, "ws2_32.lib") // 链接Winsock库
    typedef SOCKET socket_t; // 定义一个通用的socket类型别名
#else
    // POSIX Sockets (Linux, macOS)
    #include 
    #include 
    #include 
    #include 
    #include 
    #define INVALID_SOCKET -1
    #define SOCKET_ERROR -1
    typedef int socket_t; // 定义一个通用的socket类型别名
#endif

// --- S-Function 参数定义 ---
// 我们将服务器IP和端口号作为S-Function的参数,使其更灵活
#define P_HOST_IDX 0 // 第一个参数:服务器IP地址 (mxArray of char)
#define P_PORT_IDX 1 // 第二个参数:服务器端口号 (real_T)
#define N_PARAMS 2   // 总参数数量

// --- 自定义数据包结构定义 ---
// 和Python服务器端严格对应
#define S2P_NUM_DOUBLES 4 // Simulink -> Python 的double数量
#define P2S_NUM_DOUBLES 5 // Python -> Simulink 的double数量
#define P2S_NUM_INTS    1 // Python -> Simulink 的int32数量

// --- 用于存储socket句柄的工作向量 ---
// 使用PWork向量来存储非Simulink管理的对象指针或句柄
#define PWORK_SOCKET_IDX 0
#define NUM_PWORK        1

/*
 * 封装一个健壮的recv函数,确保接收到指定数量的字节
 */
int recv_all(socket_t sock, char *buf, int len) {
   
    int total_received = 0;
    while (total_received < len) {
   
        int bytes_received = recv(sock, buf + total_received, len - total_received, 0);
        if (bytes_received == SOCKET_ERROR) {
   
            #if defined(_WIN32)
                ssPrintf("recv failed with error: %d\n", WSAGetLastError());
            #else
                ssPrintf("recv failed with error: %s\n", strerror(errno));
            #endif
            return -1; // 返回错误
        }
        if (bytes_received == 0) {
   
            // 连接被对方关闭
            ssPrintf("Connection closed by peer during recv.\n");
            return 0;
        }
        total_received += bytes_received;
    }
    return total_received;
}


/*
 * mdlInitializeSizes: 定义S-Function的输入输出端口等属性
 */
static void mdlInitializeSizes(SimStruct *S) {
   
    ssSetNumSFcnParams(S, N_PARAMS);  // 设置参数数量为2
    if (ssGetNumSFcnParams(S) != ssGetSFcnParamsCount(S)) {
   
        return; // 如果参数数量不匹配,则提前返回
    }

    // 设置输入端口
    if (!ssSetNumInputPorts(S, 1)) return;
    ssSetInputPortWidth(S, 0, S2P_NUM_DOUBLES); // 输入端口宽度为4
    ssSetInputPortDataType(S, 0, SS_DOUBLE);    // 数据类型为double
    ssSetInputPortRequiredContiguous(S, 0, true); // 要求输入数据内存连续,便于直接内存拷贝
    ssSetInputPortDirectFeedThrough(S, 0, false); // 输入不直接影响当前时间步的输出

    // 设置输出端口
    if (!ssSetNumOutputPorts(S, 2)) return;
    // 第一个输出端口:5个double类型的控制信号
    ssSetOutputPortWidth(S, 0, P2S_NUM_DOUBLES);
    ssSetOutputPortDataType(S, 0, SS_DOUBLE);
    // 第二个输出端口:1个int32类型的警报信号
    ssSetOutputPortWidth(S, 1, P2S_NUM_INTS);
    ssSetOutputPortDataType(S, 1, SS_INT32);

    ssSetNumSampleTimes(S, 1); // 只有一个采样时间
    
    // 设置PWork向量,用于存储socket句柄
    ssSetNumPWork(S, NUM_PWORK);
    
    // 其他选项
    ssSetOptions(S, SS_OPTION_EXCEPTION_FREE_CODE);
}


/*
 * mdlInitializeSampleTimes: 设置S-Function的采样时间
 */
static void mdlInitializeSampleTimes(SimStruct *S) {
   
    // 我们从模块对话框中获取采样时间,这样更灵活
    // 第一个参数是采样时间,第二个参数是偏移量
    ssSetSampleTime(S, 0, INHERITED_SAMPLE_TIME); 
    ssSetOffsetTime(S, 0, 0.0);
}


/*
 * mdlStart: 仿真开始时的初始化函数
 */
#define MDL_START
static void mdlStart(SimStruct *S) {
   
    socket_t sock = INVALID_SOCKET;
    struct sockaddr_in serv_addr;
    char host_str[256];
    
    // --- Winsock 初始化 (仅Windows需要) ---
    #if defined(_WIN32)
        WSADATA wsaData;
        int iResult = WSAStartup(MAKEWORD(2, 2), &wsaData);
        if (iResult != 0) {
   
            ssSetErrorStatus(S, "WSAStartup failed");
            return;
        }
    #endif

    // --- 创建 Socket ---
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == INVALID_SOCKET) {
   
        ssSetErrorStatus(S, "Socket creation error");
        return;
    }
    
    // --- 获取S-Function参数 ---
    mxGetString(ssGetSFcnParam(S, P_HOST_IDX), host_str, sizeof(host_str)-1);
    const real_T port = (real_T) *mxGetPr(ssGetSFcnParam(S, P_PORT_IDX));
    
    ssPrintf("Co-Sim S-Function: Connecting to %s:%d\n", host_str, (int)port);

    // --- 设置服务器地址结构 ---
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons((uint16_t)port); // 将端口号转为网络字节序
    
    // 将IP地址字符串转为网络格式
    #if defined(_WIN32)
        serv_addr.sin_addr.s_addr = inet_addr(host_str);
    #else
        if(inet_pton(AF_INET, host_str, &serv_addr.sin_addr) <= 0) {
   
            ssSetErrorStatus(S, "Invalid address/ Address not supported");
            return;
        }
    #endif

    // --- 连接到服务器 ---
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
   
        #if defined(_WIN32)
            closesocket(sock);
            WSACleanup();
        #else
            close(sock);
        #endif
        ssSetErrorStatus(S, "Connection Failed");
        return;
    }

    ssPrintf("Co-Sim S-Function: Connected successfully.\n");
    
    // --- 将socket句柄存入PWork向量中,以便其他回调函数访问 ---
    ssGetPWork(S)[PWORK_SOCKET_IDX] = (void *)sock;
}


/*
 * mdlOutputs: 计算并发送输出
 */
static void mdlOutputs(SimStruct *S, int_T tid) {
   
    // 从PWork中取回socket句柄
    socket_t sock = (socket_t)(ssGetPWork(S)[PWORK_SOCKET_IDX]);
    
    // 获取输入端口的指针
    const real_T *u = ssGetInputPortRealSignal(S, 0);
    
    // 准备发送的数据包
    // 由于我们设置了输入端口是内存连续的,可以直接发送u指向的内存区域
    char send_buf[S2P_NUM_DOUBLES * sizeof(double)];
    memcpy(send_buf, u, sizeof(send_buf));
    
    // 发送数据
    if (send(sock, send_buf, sizeof(send_buf), 0) < 0) {
   
        ssSetErrorStatus(S, "Send failed");
        return;
    }
}


/*
 * mdlUpdate: 接收数据并更新S-Function状态/输出
 */
#define MDL_UPDATE
static void mdlUpdate(SimStruct *S, int_T tid) {
   
    // 从PWork中取回socket句柄
    socket_t sock = (socket_t)(ssGetPWork(S)[PWORK_SOCKET_IDX]);
    
    // 获取输出端口的指针
    real_T *y_control = ssGetOutputPortRealSignal(S, 0);
    int32_T *y_alarm = (int32_T *)ssGetOutputPortSignal(S, 1);
    
    // 准备接收缓冲区
    const int recv_buf_size = P2S_NUM_DOUBLES * sizeof(double) + P2S_NUM_INTS * sizeof(int32_T);
    char recv_buf[recv_buf_size];
    
    // 阻塞式接收数据,确保收到完整数据包
    int bytes_received = recv_all(sock, recv_buf, recv_buf_size);
    if (bytes_received <= 0) {
   
        ssSetErrorStatus(S, "Receive failed or connection closed by peer.");
        return;
    }
    
    // 解包数据
    // 将接收到的字节流拷贝到输出端口对应的内存位置
    memcpy(y_control, recv_buf, P2S_NUM_DOUBLES * sizeof(double));
    memcpy(y_alarm, recv_buf + P2S_NUM_DOUBLES * sizeof(double), P2S_NUM_INTS * sizeof(int32_T));
}


/*
 * mdlTerminate: 仿真结束时的清理函数
 */
static void mdlTerminate(SimStruct *S) {
   
    // 从PWork中取回socket句柄
    socket_t sock = (socket_t)(ssGetPWork(S)[PWORK_SOCKET_IDX]);
    
    // 只有在mdlStart成功创建了socket后才进行清理
    if (sock != (socket_t)INVALID_SOCKET && ssGetPWork(S) != NULL) {
   
        ssPrintf("Co-Sim S-Function: Closing socket.\n");
        // 关闭socket
        #if defined(_WIN32)
            closesocket(sock);
            WSACleanup(); // 清理Winsock环境
        #else
            close(sock);
        #endif
    }
}


#ifdef MATLAB_MEX_FILE
#include "simulink.c"
#else
#include "cg_sfun.h"
#endif

如何编译和使用这个 S-Function?

  1. 保存代码: 将上述代码保存为 py_cosim_sfcn.c
  2. 打开 MATLAB: 在 MATLAB 命令行中,导航到 py_cosim_sfcn.c 所在的目录。
  3. 编译: 执行 mex 命令。
    • 在 Windows (使用 Visual Studio 编译器): mex py_cosim_sfcn.c
    • 在 Linux: mex py_cosim_sfcn.c
    • 如果 mex -setup 没有配置好,你需要先运行它选择一个编译器。
  4. 生成文件: 编译成功后,会生成一个与你的操作系统和 MATLAB 版本对应的二进制文件,例如 py_cosim_sfcn.mexw64 (64位 Windows)。这就是你的 S-Function 模块。
  5. 在 Simulink 中使用:
    • 在 Simulink 模型库中,找到 “User-Defined Functions” 下的 “S-Function” 模块,并将其拖拽到你的模型中。
    • 双击该模块,在 “S-function name” 字段中输入 py_cosim_sfcn
    • 在 “S-function parameters” 字段中,输入 '127.0.0.1', 65432。注意字符串使用单引号。
    • 在模块的 “Solver” 面板中,设置 “Sample time” 为你想要的通信步长,例如 0.01
    • 将你的物理模型的4个传感器输出连接到 S-Function 的输入端口。
    • 将 S-Function 的第一个输出端口(5个控制信号)和第二个输出端口(1个警报信号)连接到你模型中对应的执行器和逻辑模块上。

代码深度解析:

  • 平台兼容性: 使用 #if defined(_WIN32) 是处理跨平台系统调用的标准做法。它确保了同一份 C 源代码无需修改即可在 Windows 和 Linux 上编译。
  • recv_all 辅助函数: 这是一个至关重要的健壮性设计。标准的 recv 函数不保证一次调用就能接收到你要求的所有字节,它可能只返回部分数据。recv_all 通过一个 while 循环,持续调用 recv 直到接收到完整的数据包,或者发生错误/连接关闭。这可以防止因为网络分片导致的数据解析错误,是工业级网络编程的必备实践。
  • PWork 向量: Simulink 不知道什么是 socket_t。我们不能将这个句柄直接存储在 S-Function 的普通成员中。PWork 是一个通用的指针数组,专门用来存储指向外部(非 Simulink)数据结构的指针或句柄。我们在 mdlStart 中创建 socket 并将其句柄存入 PWork,然后在 mdlOutputs, mdlUpdate, mdlTerminate 中再取出来使用。
  • memcpy 的高效性:mdlOutputsmdlUpdate 中,我们使用了 memcpy 来进行数据的打包和解包。因为我们提前约定了严格的二进制数据布局,并且要求 Simulink 的输入/输出端口数据是内存连续的 (RequiredContiguous),所以我们可以避免逐个 double 进行转换,而是直接进行大块内存的复制。对于大数据量的交换,这种方式的效率远高于逐个元素操作。
  • 错误处理: 在每个可能失败的系统调用(socket, connect, send, recv)之后,都进行了严格的错误检查,并通过 ssSetErrorStatus(S, "...") 来通知 Simulink 仿真引擎。这会导致仿真立即停止并报告错误,而不是继续运行导致更隐蔽的问题。
3.1 范式之变:从“协同仿真”到“远程调用”

我们必须首先厘清一个核心的观念转变。卷一中我们实现的 TCP/IP 方案,是一种典型的 “对等协同仿真” (Peer-to-Peer Co-simulation) 架构。

  • 架构特征:Simulink 进程和 Python 进程是两个独立的、平等的实体。它们各自拥有自己的主循环(Simulink 的仿真循环和 Python 的服务器循环)。仿真的时间同步是通过一个双方约定的“握手”协议来协调的。谁是“服务器”、谁是“客户端”只决定了连接的发起方,而在仿真过程中,它们的地位是平等的。

而 MATLAB Engine API for Python 实现的,是一种 “主从远程调用” (Master-Slave Remote Invocation / Remote Procedure Call, RPC) 架构。

  • 架构特征:Python 程序是绝对的 “主控方” (Master)。它负责启动和关闭一个后台的 MATLAB 进程(“从属方” Slave)。Python 脚本中不存在一个与 Simulink 对等的、独立的循环。相反,Python 在其自身的逻辑流程中,可以随时“命令” MATLAB 进程去执行任何 MATLAB 命令——无论是解一个矩阵方程,画一个图,还是运行一段 Simulink 仿真。Simulink 的仿真过程完全被 Python 的调用所驱动和控制。

这个范式转变带来了深远的影响:

特性比较 对等协同仿真 (卷一 Socket 方案) 主从远程调用 (MATLAB Engine API)
控制流 双主循环,通过阻塞式 I/O 同步 Python 单主循环,命令式调用 MATLAB
时间推进 Simulink 内部求解器驱动,在通信点暂停 Python 脚本显式控制,可命令 Simulink 跑一小步或完整跑完
数据交换 显式的、自定义的二进制序列化/反序列化 引擎自动处理,Python 类型与 MATLAB 类型之间“无感”转换
灵活性 极高。可替换任何通信中间件 (ZeroMQ, gRPC),可连接任何语言实现的伙伴进程 较低。Python 必须与 MATLAB/Simulink 绑定
开发效率 低。需要编写底层 C 代码和网络代码 高。纯 Python 编程,接口简洁
部署 相对简单。目标机只需 Python 环境和 C 编译器,无需 MATLAB 许可证 复杂。目标机必须安装完整版的 MATLAB 及对应许可证

理解这种范式差异是至关重要的。如果你想做的,是在一个大规模的参数寻优、机器学习模型训练、或复杂决策流程中,把 Simulink 模型作为一个可以被反复调用的“高保真函数”,那么“主从远程调用”范式是你的不二之选。本章将要深入探索的,正是这种强大的能力。

3.2 引擎的内燃机:架构与内部工作机制探秘

调用 import matlab.engine 时,我们究竟引入了什么?它并非一个简单的网络封装库,而是一套复杂的、由 MathWorks 精心设计的跨进程通信(Inter-Process Communication, IPC)机制。

  1. 进程的启动与连接:
    当你首次在 Python 中执行 eng = matlab.engine.start_matlab() 时,会发生以下一系列事件:

    • Python 库会去查找 MATLAB 的安装路径。
    • 它会以一种特殊模式启动一个新的 MATLAB 进程。这个 MATLAB 进程不会显示图形用户界面(GUI),而是作为一个后台服务运行。
    • 在启动时,MATLAB 会加载一个名为 libmwmatlabengine 的核心库,这个库负责建立与 Python 进程通信的 IPC 通道。
    • 这个 IPC 通道并不是我们之前使用的原始 TCP Socket,而是一种更高效、更可靠的本地 IPC 机制,比如在 Windows 上可能使用“命名管道 (Named Pipes)”,在 Linux/macOS 上可能使用“Unix 域套接字 (Unix Domain Sockets)”。这些本地 IPC 机制避免了网络协议栈的开销,数据传输速度更快。
    • Python 端的 matlab.engine 模块与这个后台的 MATLAB 进程建立连接,并返回一个代表着这个连接的引擎对象 eng。之后所有通过 eng 的操作,都会被序列化后通过这个 IPC 通道发送给 MATLAB 进程执行。
  2. 共享 MATLAB 会话 (Shared MATLAB Session):
    MATLAB Engine API 还支持连接到一个已经存在的、用户手动打开的 MATLAB 桌面会话。

    • 你需要在 MATLAB 命令行中执行 matlab.engine.shareEngine。这将使当前的 MATLAB 会话进入“共享模式”,并开始监听来自 Python 的连接请求。
    • 然后在 Python 中,你可以使用 eng = matlab.engine.connect_matlab() 来连接到这个已经存在的会话。
    • 优点: 这种方式非常适合交互式开发和调试。你可以在 Python 中发送命令,然后立即在 MATLAB 的桌面环境中看到变量的变化、图形的绘制,或者打开 Simulink 模型查看状态。
    • 缺点: 这种模式不够自动化,不适合用于需要自动启动和关闭仿真的生产脚本。
  3. 数据类型封送拆收 (Marshalling and Unmarshalling):
    这是 MATLAB Engine API 最具“魔力”的部分。当你把一个 Python 对象(如 list)传递给一个 MATLAB 函数时,引擎会自动将其转换为对应的 MATLAB 类型(如 cell array)。反之亦然。这个过程称为“封送 (Marshalling)”。

    引擎内部维护了一个复杂的类型映射表和转换逻辑。例如:

    • Python float -> MATLAB double
    • Python list of numbers -> MATLAB 1xN double array
    • Python numpy.ndarray -> MATLAB matrix (保留维度、数据类型和复数信息)
    • Python dict -> MATLAB scalar struct
    • Python str -> MATLAB char array

    这种自动转换极大地简化了编程,但也隐藏了性能开销。每次跨进程传递数据,都涉及到:

    • 序列化 (Serialization): 在 Python 端将对象转换为一种中间二进制格式。
    • IPC 传输: 将二进制数据通过管道或套接字发送到 MATLAB 进程。
    • 反序列化 (Deserialization): 在 MATLAB 端将二进制数据重新构建成 MATLAB 的内存对象。

    对于小数据量,这个开销可以忽略不计。但如果你的联合仿真需要在每个时间步交换巨大的矩阵或复杂的数据结构,这个自动封送的开销可能会成为显著的性能瓶颈,甚至比我们卷一中手动优化的 memcpy 方案要慢。我们将在本章的性能分析部分用实验数据来验证这一点。

3.3 环境搭建的“陷阱”与“正道”

虽然官方文档说只需 pip install matlabengine,但在实际操作中,配置环境是许多初学者遇到的第一个“拦路虎”。下面我们详细剖析可能遇到的问题和正确的解决之道。

  1. 版本兼容性:天字第一号规则
    MATLAB Engine API 对 Python 版本、MATLAB 版本和 matlabengine 包的版本有严格的对应关系。 混用不兼容的版本是导致安装和运行失败的最常见原因。

    • 查询官方兼容表: 在开始之前,务必访问 MathWorks 官网,查找标题为 “MATLAB API for Python Version Compatibility” 的页面。例如,MATLAB R2023b 可能支持 Python 3.9, 3.10, 3.11,但不支持 3.12。
    • 最佳实践: 创建一个专门用于联合仿真的 Python 虚拟环境,并安装一个兼容的 Python 版本。这可以避免与你系统中其他项目产生版本冲突。
      # 使用 conda 创建一个名为 'cosim' 的环境,并指定 python 版本
      conda create -n cosim python=3.10
      
      # 激活环境
      conda activate cosim
      
  2. 安装 matlabengine 包的正确姿势:
    不要直接在激活的环境里 pip install matlabengine!这个命令安装的是一个空的占位符包。正确的安装方法是,进入到你的 MATLAB 安装路径下,找到对应的 Python 安装脚本并执行它。

    • 定位安装脚本:
      • 在 MATLAB 安装目录下,找到 extern/engines/python 这个子目录。例如:
      • Windows: C:\Program Files\MATLAB\R2023b\extern\engines\python
      • Linux: /usr/local/MATLAB/R2023b/extern/engines/python
      • macOS: /Applications/MATLAB_R2023b.app/extern/engines/python
    • 执行安装:
      • 打开你的终端或命令行工具,确保你已经激活了之前创建的 conda 虚拟环境
      • cd 到上述路径。
      • 执行安装命令:
      # 确保是在你的虚拟环境 (cosim) 中执行
      python setup.py install
      
      • 这个脚本会将正确的 matlabengine 库文件安装到你的虚拟环境的 site-packages 目录中,并建立所有必要的链接。
  3. 环境变量与路径问题:
    有时,即使安装成功,运行 import matlab.engine 仍然可能失败,提示找不到 MATLAB 的库文件。这通常是 PATH (Windows) 或 LD_LIBRARY_PATH (Linux) 环境变量的问题。

    • Windows: 需要确保 MATLAB 安装路径下的 bin/win64 目录在系统的 PATH 环境变量中。例如:C:\Program Files\MATLAB\R2023b\bin\win64
    • Linux: 需要确保 MATLAB 安装路径下的 bin/glnxa64sys/os/glnxa64 目录在 LD_LIBRARY_PATH 环境变量中。
      # 可以将此行添加到你的 ~/.bashrc 或 ~/.zshrc 文件中
      export LD_LIBRARY_PATH=/usr/local/MATLAB/R2023b/bin/glnxa64:/usr/local/MATLAB/R2023b/sys/os/glnxa64:$LD_LIBRARY_PATH
      
  4. 故障排查清单:

    • ModuleNotFoundError: No module named 'matlabengine': 你没有在 MATLAB 路径下运行 python setup.py install,或者你没有在你期望的 Python 环境中运行它。
    • ImportError: ...SystemError: ...: 最常见的原因是 Python 与 MATLAB 版本不兼容。请再次核对官方兼容表。
    • 启动引擎时挂起或超时: 可能是防火墙阻止了 IPC 通信,或者 MATLAB 许可证存在问题。尝试以管理员/sudo 权限运行 Python 脚本,或者检查 MATLAB 许可证管理器状态。尝试连接到共享会话 (connect_matlab) 是否成功,这有助于定位问题。

遵循以上步骤,可以解决 99% 的环境配置问题,为后续的开发扫清障碍。

3.4 Python 主控脚本的十八般武艺:API 详解

现在,环境已经就绪,让我们深入探索 matlab.engine API 的核心功能。我们将通过一系列代码片段,逐一展示其强大的能力。

1. 引擎的启动与关闭

# python_master_script.py
import matlab.engine
import time

print("正在准备启动 MATLAB 引擎...")
# 启动一个新的后台 MATLAB 进程
# start_matlab() 是一个阻塞操作,会等待 MATLAB 完全启动后才返回
eng = matlab.engine.start_matlab()
print("MATLAB 引擎已成功启动。")
print(f"MATLAB 版本: {
     eng.version()}") # 调用 MATLAB 的 version 函数

# 使用完引擎后,务必手动关闭,以释放 MATLAB 进程和许可证
print("准备关闭 MATLAB 引擎...")
eng.quit()
print("MATLAB 引擎已关闭。")

# --- 异步启动 ---
print("\n演示异步启动 MATLAB 引擎...")
# 使用 async=True,start_matlab 会立即返回一个 Future 对象,程序不会被阻塞
future = matlab.engine.start_matlab(async=True)
print("已发出启动命令,Python 脚本可以继续执行其他任务。")

# 在这里可以做一些其他初始化工作...
time.sleep(2) 
print("...其他任务执行中...")

# 当你需要使用引擎时,调用 result() 方法等待启动完成
print("现在需要使用引擎,等待其启动完成...")
eng_async = future.result()
print("异步启动的 MATLAB 引擎已就绪。")
print(f"MATLAB 工作区变量: {
     eng_async.who()}") # who 是一个MATLAB命令,列出工作区所有变量

eng_async.quit()
print("异步引擎已关闭。")
  • 代码解释:
    • matlab.engine.start_matlab(): 这是启动引擎的标准方法。它会创建一个全新的、独立的 MATLAB 后台进程。
    • eng.version(): eng 对象可以被看作是 MATLAB 命令行的代理。任何你能在 MATLAB 命令行里调用的函数,几乎都可以通过 eng.函数名() 的方式在 Python 中调用。
    • eng.quit(): 至关重要的一步。如果你忘记调用它,那个后台的 MATLAB 进程会一直存在,占用系统资源和一份 MATLAB 许可证,直到你的 Python 主脚本完全退出。
    • start_matlab(async=True): 这是一个高级用法。当 MATLAB 启动比较慢时,使用异步启动可以让你的 Python 程序不被卡住,可以并行执行一些不依赖 MATLAB 的初始化任务,提升程序启动效率。
    • future.result(): Future 对象是 Python concurrent.futures 模块中的概念,代表一个未来某个时刻才会完成的操作。调用 .result() 会阻塞当前线程,直到这个操作(这里是 MATLAB 启动)完成,并返回最终结果(eng 对象)。

2. 数据类型的“无缝”转换

这是引擎的核心魅力所在。让我们通过一个详尽的例子来观察这种自动转换。

# python_master_script.py (续)
import matlab.engine
import numpy as np

eng = matlab.engine.start_matlab()

# --- Python to MATLAB ---
print("\n--- Python 类型到 MATLAB 类型的转换 ---")

# 标量
py_float = 10.5
py_int = 20
py_complex = 3.14 + 2.71j
eng.workspace['mat_double'] = py_float   # 在 MATLAB 工作区创建一个名为 mat_double 的变量
eng.workspace['mat_int_as_double'] = py_int # Python int 会被转为 MATLAB double
eng.workspace['mat_complex'] = py_complex  # 复数也支持

# 列表和Numpy数组
py_list = [1.0, 2.0, 3.0, 4.0]
py_np_array_1d = np.array([5.0, 6.0, 7.0])
py_np_array_2d = np.arange(1, 7).reshape(2, 3) # 创建一个2x3的矩阵
eng.workspace['mat_row_vector'] = py_list   # Python list 会被转为 MATLAB 1xN 行向量
eng.workspace['mat_numpy_1d'] = py_np_array_1d
eng.workspace['mat_numpy_2d'] = py_np_array_2d

# 字典
py_dict = {
   'name': 'Plant A', 'temp_sensor_id': 123, 'is_active': True}
eng.workspace['mat_struct'] = py_dict # Python dict 会被转为 MATLAB 标量结构体 (scalar struct)

# 在 MATLAB 端显示变量信息
print("在 MATLAB 中执行 'whos' 命令:")
# 使用 eval 函数可以执行任意 MATLAB 代码字符串,并获取其文本输出
output = eng.eval('whos', nargout=1)
print(output)

# --- MATLAB to Python ---
print("\n--- MATLAB 类型到 Python 类型的转换 ---")

# 从 MATLAB 工作区取回变量
ret_double = eng.workspace['mat_double']
ret_complex = eng.workspace['mat_complex']
print(f"取回的 double: {
     ret_double}, 类型: {
     type(ret_double)}") # 会变回 Python float

# MATLAB 矩阵会被转为 numpy.ndarray
ret_numpy_2d = eng.workspace['mat_numpy_2d']
print(f"取回的矩阵:\n{
     ret_numpy_2d}")
print(f"类型: {
     type(ret_numpy_2d)}") # 会变回 numpy.ndarray
print(f"数据类型: {
     ret_numpy_2d.dtype}") # 数据类型也会被保留

# MATLAB 结构体会被转为 Python 字典
ret_struct = eng.workspace['mat_struct']
print(f"取回的结构体: {
     ret_struct}, 类型: {
     type(ret_struct)}") # 会变回 Python dict

# 特别注意:MATLAB 1xN 或 Nx1 的向量会被转为一个特殊的 MATLAB 数组类型
ret_row_vector = eng.workspace['mat_row_vector']
print(f"取回的行向量: {
     ret_row_vector}, 类型: {
     type(ret_row_vector)}")
# 类型是 
# 它表现得像一个列表,但不是真正的 Python list
# 要将其转换为纯 Python 类型,需要手动处理
py_list_from_matlab = ret_row_vector[0] # 取出内部的列表
print(f"转换为 Python list: {
     py_list_from_matlab}, 类型: {
     type(py_list_from_matlab)}")

eng.quit()
  • 代码解释:
    • eng.workspace: 这是一个神奇的类字典对象,它直接映射到后台 MATLAB 进程的工作区。你可以像操作 Python 字典一样,用 [] 来读取或写入变量。这是在 Python 和 MATLAB 之间传递数据最直接的方式。
    • eng.eval('...'): 这个函数可以执行任意的 MATLAB 代码字符串。它非常灵活,但通常不推荐用它来构建复杂的逻辑,因为字符串拼接容易出错且难以维护。它最适合执行一些简单的命令,比如 clear, whos, load 等。
    • nargout=1: 这是一个至关重要的参数!eval 和其他函数调用默认 nargout=0,意味着它们不会从 MATLAB 返回任何输出。如果你希望捕获 MATLAB 函数的返回值(对于 eval 来说是命令行的文本输出),你必须明确指定 nargout 的数量。
    • matlab.double 类型:注意从 MATLAB 取回向量时得到的特殊类型。这是一个需要注意的细节。虽然可以直接对其进行迭代,但在很多需要原生 listnumpy.ndarray 的 Python 库(如 matplotlib)中使用它之前,最好先进行显式转换。对于二维或更高维的数组,引擎会自动返回 numpy.ndarray,这更加方便。

3. 调用 MATLAB 函数和运行 Simulink 模型

这是我们最终的目标。Python 如何命令 Simulink 运行起来?

# python_master_script.py (续)
import matlab.engine
import numpy as np
import os

# 确保我们的Simulink模型在MATLAB的路径下
# 假设模型文件 'vertical_farm_plant.slx' 与Python脚本在同一目录
current_path = os.getcwd()

eng = matlab.engine.start_matlab()

# 将当前脚本所在的路径添加到MATLAB的搜索路径中
# addpath是MATLAB的函数,用于添加路径
eng.addpath(current_path, nargout=0) # nargout=0 因为 addpath 没有返回值

# --- 方法一:使用 'sim' 命令(简单直接) ---
print("\n--- 方法一:使用 'sim' 命令运行仿真 ---")

# 在运行前,可以通过 set_param 命令设置模型中的参数
# 'vertical_farm_plant/Control Inputs/Light Intensity' 是模块的完整路径
# 'Value' 是 Constant 模块的参数名
eng.set_param('vertical_farm_plant/Control Inputs/Light Intensity', 'Value', '600', nargout=0)
eng.set_param('vertical_farm_plant/Control Inputs/Fan Speed', 'Value', '4500', nargout=0)

# 使用 sim 命令运行模型,并指定仿真停止时间
# sim 函数返回一个 Simulink.SimulationOutput 对象
print("运行仿真...")
# 将模型名称作为字符串传递
# 后面可以跟成对的参数名-参数值对,用于覆盖模型配置
sim_out = eng.sim('vertical_farm_plant', 'StopTime', '10.0') 
print("仿真完成。")

# sim_out 是一个 MATLAB 对象,在 Python 中表现为 matlab.object
print(f"返回的对象类型: {
     type(sim_out)}")

# 可以像在 MATLAB 中一样,通过点号访问其属性
# sim_out.yout 是一个常见的属性,包含了 To Workspace 模块输出的数据
sensor_data_timeseries = sim_out.yout
# 访问数据和时间
sensor_data = sensor_data_timeseries.Data
sensor_time = sensor_data_timeseries.Time

print(f"传感器输出数据的前5行:\n{
     sensor_data[0:5]}")
print(f"对应的时间点:\n{
     sensor_time[0:5]}")

# --- 方法二:使用 SimulationInput 对象(结构化,更强大) ---
print("\n--- 方法二:使用 SimulationInput 对象进行更精细的控制 ---")

# 1. 在MATLAB中创建一个SimulationInput对象
model_name = 'vertical_farm_plant'
eng.workspace['sim_in'] = eng.Simulink.SimulationInput(model_name)

# 2. 在Python中通过工作区访问和配置这个对象
# 设置仿真时间
eng.eval("sim_in = sim_in.setVariable('StopTime', '20.0');", nargout=0)

# 设置模型参数 (等同于 set_param)
eng.eval("sim_in = sim_in.setBlockParameter('vertical_farm_plant/Control Inputs/Light Intensity', 'Value', '750');", nargout=0)
eng.eval("sim_in = sim_in.setBlockParameter('vertical_farm_plant/Control Inputs/Fan Speed', 'Value', '5000');", nargout=0)

# 甚至可以设置外部输入(例如,替代 From Workspace 模块)
# 假设我们有一个输入端口叫 'ExternalDisturbance'
# t = np.array([[0], [10], [10.01], [20]])
# u = np.array([[0], [0], [5], [5]]) # 在10秒时施加一个值为5的阶跃扰动
# eng.workspace['t'] = matlab.double(t.tolist())
# eng.workspace['u'] = matlab.double(u.tolist())
# eng.eval("sim_in = sim_in.setExternalInput([t, u]);", nargout=0)

print("使用 SimulationInput 对象运行仿真...")
sim_out_2 = eng.sim(eng.workspace['sim_in'])
print("仿真完成。")

# 处理返回结果与方法一相同...

eng.quit()

  • 代码解释:
    • eng.addpath(current_path, nargout=0): 这是一个非常重要的步骤。后台的 MATLAB 进程并不知道你的 Python 脚本在哪里,因此也不知道你的 .slx 模型文件在哪里。addpath 告诉 MATLAB:“请把这个文件夹也加入到你的搜索范围里”,这样它才能找到模型。
    • eng.set_param(...): 这是在运行仿真前,动态修改模型参数的标准方法。第一个参数是模块的完整路径,格式为 model_name/block1_name/block2_name。第二个参数是你想修改的参数的名称(注意:这个名称必须和模块参数对话框中的名称完全一致,区分大小写)。第三个参数是参数值,通常以字符串形式传递。
    • eng.sim('model_name', 'Param1', 'Value1', ...): 这是最简单的仿真调用方式。第一个参数是模型名,后面可以跟任意数量的“参数名-值”对,这些参数会临时覆盖模型本身的配置(比如 StopTime)。仿真结束后,模型本身的配置不会被改变。
    • Simulink.SimulationOutput: sim 函数返回的是一个 MATLAB 的 Simulink.SimulationOutput 对象。这是一个结构化的容器,里面包含了仿真的所有结果,如 yout (To Workspace 模块的数据)、tout (时间向量)、状态 xout 等。在 Python 中,我们可以通过 . 语法访问这些属性,引擎会自动处理数据转换。
    • Simulink.SimulationInput 对象: 这是进行复杂仿真任务的最佳实践。它允许你将所有仿真配置(模型参数、变量、外部输入、仿真配置等)打包成一个独立的对象。
      • 优点:
        • 结构化和可读性: 代码更清晰,所有配置都集中管理。
        • 可重用性: 你可以创建多个不同的 SimulationInput 对象,用于不同的仿真场景,然后在一个循环中依次运行它们。
        • 功能更强: 它是唯一能够以编程方式提供外部输入信号setExternalInput)和设置初始状态setInitialState)的官方途径。这对于进行闭环控制仿真至关重要。
      • 用法: 我们通过 eng.eval 来调用 SimulationInput 对象的方法。注意,像 setVariable 这样的方法会返回一个新的、更新后的 SimulationInput 对象,所以我们需要写成 sim_in = sim_in.setVariable(...) 的形式来更新工作区中的变量。
3.5 案例重构:基于 MATLAB Engine 的“智能垂直农场”

现在,我们将运用所学知识,重构卷一中的“智能垂直农场”案例。这次,我们将抛弃复杂的 C S-Function 和 Socket 通信,转而使用一个纯 Python 脚本来作为主控制器,实现逐个时间步的闭环控制。

架构设计:

  1. Simulink 模型 (vertical_farm_plant_engine.slx):

    • 输入: 不再使用 S-Function。我们将使用五个 Constant 模块作为控制输入(光照强度、光谱、营养液速率等)。我们将从 Python 中通过 set_param 实时更新这些 Constant 模块的值。
    • 输出: 仍然使用一个 To Workspace 模块来捕获四个传感器(温度、湿度、CO2、水分)的输出。我们将配置它输出为 Timeseries 格式。
    • 模型配置:
      • 求解器设置为定步长(如 Fixed-step, auto)。
      • 仿真停止时间 StopTime 将由 Python 脚本控制。
  2. Python 主控脚本 (run_farm_simulation_engine.py):

    • 主循环: Python 脚本将包含一个 for 循环,代表仿真的总步数。
    • 在每一循环步:
      a. 从上一步的仿真结果中,提取出当前时刻的传感器数据。
      b. 将传感器数据送入我们之前定义的 intelligent_controller 函数,计算出新的控制指令。
      c. 使用 eng.set_param 将五个新的控制指令值写入 Simulink 模型中对应的五个 Constant 模块。
      d. 调用 eng.sim,设置仿真时间为从 current_timecurrent_time + step_size这是实现单步推进的关键。
      e. 从返回的 SimulationOutput 对象中提取新的传感器数据,并存储历史记录。
      f. 更新 current_time,进入下一次循环。

Simulink 模型准备 (vertical_farm_plant_engine.slx):

  • 你需要创建一个新的 Simulink 模型或修改旧模型。
  • 删除掉卷一中创建的 py_cosim_sfcn S-Function 模块。
  • 从 Simulink Library Browser 的 Sources 中,拖入5个 Constant 模块。将它们分别命名并放在一个 Subsystem 中,名为 Control Inputs。模块的路径将是 vertical_farm_plant_engine/Control Inputs/Light Intensity 等。
  • Sinks 中,拖入一个 To Workspace 模块。将其连接到你的物理模型输出的 Mux 信号上。双击它,设置:
    • Variable name: sim_yout
    • Save format: Timeseries
  • Modeling -> Model Settings (Ctrl+E) 中:
    • Solver -> Type: Fixed-step
    • Solver -> Solver: autoode1 即可
    • Solver -> Fixed-step size: 0.01 (这将是我们的控制周期)

Python 主控脚本代码 (run_farm_simulation_engine.py):

# run_farm_simulation_engine.py
# 使用 MATLAB Engine API 实现垂直农场闭环控制仿真的主控脚本

import matlab.engine
import numpy as np
import time
import os
import matplotlib.pyplot as plt

# --- 智能控制器 (与卷一完全相同) ---
def intelligent_controller(sensor_data: np.ndarray) -> tuple[np.ndarray, int]:
    """
    智能控制器存根函数。
    输入: 传感器数据 [temp, humidity, co2, water_content]
    输出: 控制信号 [light, spectrum, nutrient, co2_rate, fan], 警报标志
    """
    # 简单的比例控制逻辑,仅为演示
    targets = np.array([25.0, 60.0, 800.0, 50.0])
    errors = targets - sensor_data
    control_signals = np.zeros(5)
    control_signals[0] = 500 + errors[0] * 10
    control_signals[1] = 0.8 - errors[0] * 0.01
    control_signals[2] = 10 + errors[3] * 0.5
    control_signals[3] = 5 + errors[2] * 0.01
    control_signals[4] = 3000 + errors[0] * 50
    control_signals = np.clip(control_signals, [0, 0.1, 0, 0, 1000], [1000, 1.0, 50, 20, 8000])
    alarm_flag = 1 if sensor_data[0] > 35.0 else 0
    return control_signals, alarm_flag

# --- 主仿真函数 ---
def run_simulation():
    # --- 1. 初始化 ---
    print("正在启动 MATLAB 引擎...")
    eng = matlab.engine.start_matlab() # 启动 MATLAB 引擎
    print("引擎启动成功。")

    model_name = 'vertical_farm_plant_engine' # Simulink 模型文件的名称 (不含.slx)
    
    # 将当前路径添加到 MATLAB 搜索路径
    eng.addpath(os.getcwd(), nargout=0)

    # 定义仿真参数
    T_sim_total = 60.0  # 总仿真时长 (秒)
    T_step = 0.5        # Python 控制器的决策周期 (秒)
    num_steps = int(T_sim_total / T_step) # 计算总步数

    # 初始化历史数据记录
    history_time = np.zeros(num_steps + 1) # 初始化时间记录数组
    history_sensors = np.zeros((num_steps + 1, 4)) # 初始化传感器数据记录数组,4个传感器
    history_controls = np.zeros((num_steps, 5))   # 初始化控制信号记录数组,5个控制信号

    # 初始化系统初始状态
    # 假设系统从一个已知的初始状态开始
    # [temp, humidity, co2, water_content]
    current_sensor_data = np.array([20.0, 70.0, 400.0, 60.0]) 
    history_sensors[0, :] = current_sensor_data # 记录初始状态

    print(f"仿真配置: 总时长={
     T_sim_total}s, 控制步长={
     T_step}s, 总步数={
     num_steps}")

    # --- 2. 仿真主循环 ---
    start_time = time.time() # 记录仿真开始的真实时间
    for i in range(num_steps):
        current_time = i * T_step # 计算当前仿真时间
        print(f"\n--- 仿真步 {
     i+1}/{
     num_steps}, 仿真时间: {
     current_time:.2f}s ---")

        # a. Python 大脑进行决策
        control_signals, alarm = intelligent_controller(current_sensor_data)
        history_controls[i, :] = control_signals # 记录本次计算出的控制信号
        print(f"  [Python Brain] 传感器读数: {
     np.round(current_sensor_data, 2)}")
        print(f"  [Python Brain] 计算控制信号: {
     np.round(control_signals, 2)}")
        if alarm:
            print("  [Python Brain] 触发警报!")
        
        # b. 将控制信号设置到 Simulink 模型中
        # 使用 set_param 实时更新 Constant 模块的值
        # 注意参数值需要转换为字符串格式
        eng.set_param(f'{
     model_name}/Control Inputs/Light Intensity', 'Value', str(control_signals[0]), nargout=0)
        eng.set_param(f'{
     model_name}/Control Inputs/Light Spectrum', 'Value', str(control_signals[1]), nargout=0)
        eng.set_param(f'{
     model_name}/Control Inputs/Nutrient Rate', 'Value', str(control_signals[2]), nargout=0)
        eng.set_param(f'{
     model_name}/Control Inputs/CO2 Rate', 'Value', str(control_signals[3]), nargout=0)
        eng.set_param(f'{
     model_name}/Control Inputs/Fan Speed', 'Value', str(control_signals[4]), nargout=0)
        print(f"  [Engine] 已将控制信号更新至 Simulink 模型。")

        # c. 命令 Simulink 运行一个步长
        # 这是实现逐 K 步控制的核心技巧
        sim_start_time_str = str(current_time)      # 本次仿真周期的开始时间
        sim_stop_time_str = str(current_time + T_step) # 本次仿真周期的结束时间
        
        # 使用 sim 命令,并指定 'SaveOutput' 和 'OutputSaveName' 以便获取输出
        # 'LoadInitialState' 和 'InitialState' 用于设置模型的初始状态,确保连续性
        eng.workspace['last_xout'] = eng.workspace.get('xFinal', None) # 获取上一步的最终状态
        
        # 运行仿真
        sim_out = eng.sim(model_name, 
                          'StopTime', sim_stop_time_str,
                          'LoadInitialState', 'on' if 'last_xout' in eng.workspace else 'off',
                          'InitialState', eng.workspace.get('last_xout', '[]'),
                          'SaveFinalState', 'on',
                          'FinalStateName', 'xFinal',
                          'SaveOutput', 'on',
                          'OutputSaveName', 'sim_yout')
                          
        print(f"  [Engine] Simulink 已完成从 {
     sim_start_time_str}s 到 {
     sim_stop_time_str}s 的仿真。")
        
        # d. 从仿真结果中提取新的传感器数据
        # sim_out 返回的是一个 MATLAB 对象,包含了本次运行的结果
        # 我们需要从这个结果中提取出最后一个时间点的数据作为下一步的输入
        output_timeseries = sim_out.get('sim_yout') # 使用get方法获取工作区变量
        new_sensor_data = output_timeseries.Data[

你可能感兴趣的:(【Python】simulink与python联合仿真)