Simulink 的核心优势在于其强大的微分方程求解器和对连续时间系统、离散时间系统的精确描述能力。其基于“信号流”和“框图”的建模范式,使得工程师可以直观地构建与物理现实高度对应的数学模型。然而,这种优势也带来了其天然的局限性:
基于时间的驱动核心 (Time-Based Core Engine):
Simulink 的“心脏”是一个时间驱动的仿真引擎(Simulation Engine)。无论是定步长(Fixed-Step)还是变步长(Variable-Step)求解器,其核心逻辑都是“推进时间,计算状态,更新输出”。这种机制对于描述物理系统的演化规律(如电路的电压变化、机械臂的运动轨迹)是完美的。但是,当系统逻辑由“外部事件”而非“时间流逝”驱动时,Simulink 的表达就变得非常笨拙。
asyncio
库或 SimPy
这样的离散事件仿真框架,在处理此类问题时则显得游刃有余。算法实现的复杂性与局限性:
虽然可以通过 MATLAB Function Block 或 S-Function(使用 C/C++ 或 MATLAB 语言)在 Simulink 中实现复杂的算法,但其开发效率、调试难度和可移植性远低于 Python。
SciPy.optimize
、CVXPY
或 Gurobi
等强大的优化库来求解。如果要在 S-Function 中用 C++ 实现同样的功能,你需要手动实现复杂的数值优化算法,或者链接庞大的第三方 C++ 库,这将是一场工程噩梦。AI/ML 模型集成的“重”与“慢”:
MathWorks 正在积极地通过 Deep Learning Toolbox 和 Statistics and Machine Learning Toolbox 等工具箱来弥合与 AI 生态的差距。你确实可以将一个预训练好的 ONNX
模型或 TensorFlow
模型导入 Simulink 中进行推理。但这个过程存在几个根本性问题:
pandas
, scikit-learn
, PyTorch
, TensorFlow
)。将最终的模型“导入”到 Simulink 中,仅仅是整个链条的最后一环。当需要进行在线学习(Online Learning)或模型的持续再训练时,这种导入模式就显得力不从心。arXiv
上发表时,其 PyTorch
或 TensorFlow
的实现版本通常在几天甚至几小时内就会出现在 GitHub 上。而等待 MathWorks 官方工具箱支持这个新模型,可能需要数月甚至数年的时间。与 Simulink 的“专精”不同,Python 的核心优势在于其“广博”的生态系统和无与伦比的“胶水语言”特性。
数据科学与机器学习的全栈能力:
Python 拥有从数据读取 (pandas
)、数据清洗、数值计算 (numpy
, scipy
)、机器学习 (scikit-learn
)、深度学习 (PyTorch
, TensorFlow
, JAX
) 到数据可视化 (matplotlib
, seaborn
, plotly
) 的完整工具链。这使得 Python 成为现代 AI 算法开发与验证的“母语”。
高级决策与运筹优化:
复杂的决策逻辑,如路径规划(A* 算法)、资源调度(整数规划)、博弈论分析等,在 Python 中有大量成熟的库(如 NetworkX
, PuLP
, OR-Tools
)可供直接使用。这些问题本质上是符号计算和图论的范畴,用 Simulink 的信号流来表达是极其困难的。
通信与集成的便捷性:
Python 提供了极其丰富的网络编程、数据库连接、API 调用(RESTful, gRPC)和消息队列(RabbitMQ, ZeroMQ, Kafka)库。这使得 Python 程序可以轻松地与外部世界进行交互,无论是连接一个真实的硬件设备,还是拉取一个云端的数据库,或是订阅一个实时的数据流。
强化学习(Reinforcement Learning)的天然平台:
强化学习是“智能体(Agent)”与“环境(Environment)”交互学习的范式。Simulink 模型是构建高保真“环境”的绝佳工具(例如,模拟一辆自动驾驶汽车的动力学和传感器)。而“智能体”的大脑,即策略网络和价值网络的训练与决策,几乎无一例外地是在 Python 框架(如 Stable-Baselines3
, Ray RLlib
)中实现的。联合仿真是连接 RL 的“大脑”与“身体”的最自然方式。
在探讨具体技术之前,必须厘清两个极易混淆的概念:模型交换(Model Exchange)和协同仿真(Co-simulation)。这两种技术都属于功能模型接口(Functional Mock-up Interface, FMI)标准的一部分,但其哲学思想和应用场景截然不同。
模型交换 (Model Exchange):
(dx/dt = f(x, u, t))
和输出方程 (y = g(x, u, t))
,但 不包含 求解器。另一个主控仿真工具(Master)导入这个 FMU,并使用 自己的 求解器来对这个 FMU 的状态进行积分和求解。协同仿真 (Co-simulation):
if-else
的 Python 脚本。y_sim
依赖于 Python 的输入 u_sim
,而 Python 的输出 y_py
又在同一个时间步内依赖于 y_sim
,这就形成了一个代数环,会导致仿真死锁。本书的核心焦点是协同仿真 (Co-simulation),因为它是连接 Simulink 物理世界和 Python 智能世界的更通用、更灵活的桥梁。我们将要探讨的,正是如何在 Simulink 和 Python 这两个独立的“黑盒”之间,构建起高效、稳定、可靠的数据交换通道,并利用这个通道去解决真实世界中的复杂问题。
为了避免空洞的理论和千篇一律的“倒立摆”、“水箱”示例,本书将围绕一个虚构但高度真实的场景展开:为下一代智能垂直农场设计并优化其核心环境控制系统。
这个系统构成一个“数字孪生”的最小但完整的单元,其复杂度足以挑战我们即将学习的每一项技术。
仿真对象 (The Plant): 一个独立的植物生长舱。
Simulink 的职责 (物理环境建模):
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
: 对应状态的带噪声测量值。Python 的职责 (智能决策大脑):
(y1, y2, y3, y4)
预测未来1小时的植物“生物量”增量。(u1..u5)
下的能量消耗。(u1..u5)
。(y1, y2, y3, y4)
,以检测是否存在传感器漂移、设备故障或潜在的植物病害早期迹象。联合仿真的挑战:
(y1..y4)
发送给 Python。Python 需要将计算出的最优控制信号 (u1..u5)
和可能的警报信号 (alarm_flag)
发回给 Simulink。这个案例完美地体现了 Simulink 和 Python 的边界与疆域。Simulink 擅长描述物理规律,而 Python 擅长处理数据、智能和复杂逻辑。任何一方都无法独立、高效地完成整个系统的建模与仿真。它们的“联合”,是解决问题的唯一途径。
在下一章,我们将深入到最底层的细节,探讨如何在这两个世界之间,用最原始的工具——套接字(Socket)——搭建起第一座通信的桥梁,从而真正理解联合仿真背后的“握手”协议和数据交换的本质。
市面上存在多种实现 Simulink-Python 联合仿真的高级工具和库,例如使用 MATLAB Engine API for Python,或在 Simulink 中嵌入 Python S-Function。这些工具极大地简化了开发流程,但同时也隐藏了底层的通信细节,使得开发者在遇到问题时(如性能瓶颈、数据同步错误、部署困难)往往束手无策。
本章的目的是撕开这些高级封装的“语法糖”,直面联合仿真的“硬核”本质——通信。我们将从第一性原理出发,探讨一个 Simulink 模型和一个独立的 Python 脚本,是如何通过网络进行“对话”的。我们将手动实现一个基于 TCP/IP 套接字的、最原始的联合仿真框架。
这个过程虽然繁琐,但其价值是无与伦比的。完成本章的学习后,你将能够:
socket
库实现一个健壮的仿真服务器。这不仅仅是一个练习,这是一个构建心智模型的過程。掌握了这些底层知识,任何上层工具对你来说都将是透明的。
要与 Simulink 进行外部通信,首先必须理解 Simulink 是如何“思考”的。Simulink 的仿真过程并非一个连续的流,而是一个由离散步骤组成的循环,即 仿真循环 (Simulation Loop)。即使对于连续系统,求解器也是通过一系列离散的计算来逼近连续行为的。
让我们以一个 定步长(Fixed-Step)求解器 为例,来剖析仿真循环的关键阶段。假设我们设置求解器为 ode3 (Bogacki-Shampine)
,步长为 h
。在每个时间步 t_k
到 t_{k+1} = t_k + h
的过程中,Simulink Engine 会执行一系列严谨的操作:
t_k
时刻的输出。这是一个重要的阶段。对于一个给定的模块,其输出 y(t_k)
是其当前状态 x(t_k)
和输入 u(t_k)
的函数,即 y(t_k) = g(x(t_k), u(t_k))
。引擎会按照模块的依赖关系(从没有输入的源模块开始,逐级计算)来确定计算顺序。x_d(k+1) = x_d(k) + u(k) * T_s
会在这个阶段执行。x_c
,引擎需要计算 dx_c/dt
在 t_k
时刻的值。这是求解微分方程的核心步骤。dx_c/dt = f(x_c(t_k), u(t_k))
。Derivatives
阶段计算出的导数值,以及可能的前几个时间步的信息,来计算下一个时间点的连续状态 x_c(t_{k+1})
。例如,ode3
求解器会在此阶段进行多次(3次)“试探性”的导数计算,以获得三阶精度。t_{k+1}
。关键洞察: 从外部通信的角度看,仿真循环中最适合进行数据交换的“缝隙”在哪里?
Outputs
阶段之后,Update
和 Derivatives
阶段之前 是一个黄金时机。t_k
的所有输出 y_sim(t_k)
,这些数据可以被发送到 Python。同时,它即将开始计算下一个状态,正需要外部的控制输入 u_sim(t_k)
。t_k
时刻运行到 Outputs
阶段结束。y_sim(t_k)
打包发送给 Python。u_sim(t_k)
。u_sim(t_k)
更新到相应模块的输入端口。Update
和 Derivatives
等阶段。这种“暂停-交换-恢复”的模式,是协同仿真的核心机制。而实现这个机制的关键,就是在 Simulink 模型中安插一个能够与外部世界对话,并且能够控制仿真循环暂停与恢复的“代理人”——这正是 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 世界的大门。
socket
仿真服务器现在,让我们切换到 Python 的世界。Python 端需要扮演一个“服务器”的角色,它需要:
我们将使用 Python 内置的 socket
库来实现这一切,不依赖任何第三方框架。
数据包协议设计 (Data Packet Protocol):
在网络通信中,数据是以字节流(Byte Stream)的形式传输的。我们必须事先和 Simulink S-Function 约定好一个严格的数据格式,否则双方将无法理解对方发送的字节是什么意思。这就是 应用层协议。
为了兼顾效率和简单性,我们设计一个非常基础的二进制协议。假设在我们的垂直农场案例中:
double
类型的传感器数据 (y1
到 y4
)。double
类型的控制指令 (u1
到 u5
) 和 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/int
和 bytes
之间的转换(即序列化和反序列化)。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 代码做了什么?
HOST
, PORT
, 以及两个方向的数据包格式和大小 (S2P_...
, P2S_...
)。这种做法极大地提高了代码的可读性和可维护性。' 是 struct
模块的格式字符串,'<'
指定了字节序为小端(Little-Endian),这对于跨平台通信至关重要,因为 x86/x64 架构的 CPU 使用小端序。'd'
代表一个8字节的 double
,'i'
代表一个4字节的 int
。
intelligent_controller
函数: 这是我们“智能大脑”的占位符。它接收一个 NumPy 数组(传感器数据),并返回一个 NumPy 数组(控制信号)和一个整数(警报标志)。目前它只实现了一个非常简单的逻辑,但在后续章节中,这里将被替换为真正的高级算法。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)已经关闭了连接(通过调用 close
或 shutdown
)。这时服务器应该优雅地退出循环。struct.unpack(...)
: 将接收到的32字节原始数据,按照 ' 格式,解析成一个包含4个浮点数的元组。
struct.pack(...)
: 在调用控制器计算出结果后,使用此函数将5个 double
和1个 int
按照 ' 格式,打包成一个44字节的字节串,准备发送。
conn.sendall(...)
: 将打包好的数据发送回 Simulink。sendall
会确保所有数据都被发送出去,这比 send
更可靠。现在,我们已经拥有了一个功能完备、健壮的 Python 仿真服务器。它像一个耐心的伙伴,静静地等待着来自 Simulink 的每一次“心跳”,并在收到信号后迅速做出响应。下一步,也是最硬核的一步,就是去 Simulink 的世界里,创建一个能够与这个服务器“对话”的 C S-Function。
现在我们进入最激动人心的部分:编写 C S-Function。这需要一些 C 语言和编译环境的知识。你需要一个支持的 C/C++ 编译器(在 Windows 上通常是 Visual Studio Community Edition 自带的 MSVC,在 Linux 上是 GCC)。
我们的 S-Function py_cosim_sfcn.c
需要完成以下任务:
mdlStart
中,作为 TCP 客户端,连接到我们刚刚创建的 Python 服务器。mdlOutputs
中,从 S-Function 的输入端口读取数据,并将其打包成我们约定的 S2P_Packet
格式,通过网络发送出去。mdlUpdate
中,阻塞式地接收来自 Python 服务器的 P2S_Packet
,解包后,将数据写入 S-Function 的输出端口。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?
py_cosim_sfcn.c
。py_cosim_sfcn.c
所在的目录。mex
命令。
mex py_cosim_sfcn.c
mex py_cosim_sfcn.c
mex -setup
没有配置好,你需要先运行它选择一个编译器。py_cosim_sfcn.mexw64
(64位 Windows)。这就是你的 S-Function 模块。py_cosim_sfcn
。'127.0.0.1', 65432
。注意字符串使用单引号。0.01
。代码深度解析:
#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
的高效性: 在 mdlOutputs
和 mdlUpdate
中,我们使用了 memcpy
来进行数据的打包和解包。因为我们提前约定了严格的二进制数据布局,并且要求 Simulink 的输入/输出端口数据是内存连续的 (RequiredContiguous
),所以我们可以避免逐个 double
进行转换,而是直接进行大块内存的复制。对于大数据量的交换,这种方式的效率远高于逐个元素操作。socket
, connect
, send
, recv
)之后,都进行了严格的错误检查,并通过 ssSetErrorStatus(S, "...")
来通知 Simulink 仿真引擎。这会导致仿真立即停止并报告错误,而不是继续运行导致更隐蔽的问题。我们必须首先厘清一个核心的观念转变。卷一中我们实现的 TCP/IP 方案,是一种典型的 “对等协同仿真” (Peer-to-Peer Co-simulation) 架构。
而 MATLAB Engine API for Python 实现的,是一种 “主从远程调用” (Master-Slave Remote Invocation / Remote Procedure Call, RPC) 架构。
这个范式转变带来了深远的影响:
特性比较 | 对等协同仿真 (卷一 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 模型作为一个可以被反复调用的“高保真函数”,那么“主从远程调用”范式是你的不二之选。本章将要深入探索的,正是这种强大的能力。
调用 import matlab.engine
时,我们究竟引入了什么?它并非一个简单的网络封装库,而是一套复杂的、由 MathWorks 精心设计的跨进程通信(Inter-Process Communication, IPC)机制。
进程的启动与连接:
当你首次在 Python 中执行 eng = matlab.engine.start_matlab()
时,会发生以下一系列事件:
libmwmatlabengine
的核心库,这个库负责建立与 Python 进程通信的 IPC 通道。matlab.engine
模块与这个后台的 MATLAB 进程建立连接,并返回一个代表着这个连接的引擎对象 eng
。之后所有通过 eng
的操作,都会被序列化后通过这个 IPC 通道发送给 MATLAB 进程执行。共享 MATLAB 会话 (Shared MATLAB Session):
MATLAB Engine API 还支持连接到一个已经存在的、用户手动打开的 MATLAB 桌面会话。
matlab.engine.shareEngine
。这将使当前的 MATLAB 会话进入“共享模式”,并开始监听来自 Python 的连接请求。eng = matlab.engine.connect_matlab()
来连接到这个已经存在的会话。数据类型封送拆收 (Marshalling and Unmarshalling):
这是 MATLAB Engine API 最具“魔力”的部分。当你把一个 Python 对象(如 list
)传递给一个 MATLAB 函数时,引擎会自动将其转换为对应的 MATLAB 类型(如 cell array
)。反之亦然。这个过程称为“封送 (Marshalling)”。
引擎内部维护了一个复杂的类型映射表和转换逻辑。例如:
float
-> MATLAB double
list
of numbers -> MATLAB 1xN double array
numpy.ndarray
-> MATLAB matrix
(保留维度、数据类型和复数信息)dict
-> MATLAB scalar struct
str
-> MATLAB char array
这种自动转换极大地简化了编程,但也隐藏了性能开销。每次跨进程传递数据,都涉及到:
对于小数据量,这个开销可以忽略不计。但如果你的联合仿真需要在每个时间步交换巨大的矩阵或复杂的数据结构,这个自动封送的开销可能会成为显著的性能瓶颈,甚至比我们卷一中手动优化的 memcpy
方案要慢。我们将在本章的性能分析部分用实验数据来验证这一点。
虽然官方文档说只需 pip install matlabengine
,但在实际操作中,配置环境是许多初学者遇到的第一个“拦路虎”。下面我们详细剖析可能遇到的问题和正确的解决之道。
版本兼容性:天字第一号规则
MATLAB Engine API 对 Python 版本、MATLAB 版本和 matlabengine
包的版本有严格的对应关系。 混用不兼容的版本是导致安装和运行失败的最常见原因。
# 使用 conda 创建一个名为 'cosim' 的环境,并指定 python 版本
conda create -n cosim python=3.10
# 激活环境
conda activate cosim
安装 matlabengine
包的正确姿势:
不要直接在激活的环境里 pip install matlabengine
!这个命令安装的是一个空的占位符包。正确的安装方法是,进入到你的 MATLAB 安装路径下,找到对应的 Python 安装脚本并执行它。
extern/engines/python
这个子目录。例如:C:\Program Files\MATLAB\R2023b\extern\engines\python
/usr/local/MATLAB/R2023b/extern/engines/python
/Applications/MATLAB_R2023b.app/extern/engines/python
cd
到上述路径。# 确保是在你的虚拟环境 (cosim) 中执行
python setup.py install
matlabengine
库文件安装到你的虚拟环境的 site-packages
目录中,并建立所有必要的链接。环境变量与路径问题:
有时,即使安装成功,运行 import matlab.engine
仍然可能失败,提示找不到 MATLAB 的库文件。这通常是 PATH
(Windows) 或 LD_LIBRARY_PATH
(Linux) 环境变量的问题。
bin/win64
目录在系统的 PATH
环境变量中。例如:C:\Program Files\MATLAB\R2023b\bin\win64
。bin/glnxa64
和 sys/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
故障排查清单:
ModuleNotFoundError: No module named 'matlabengine'
: 你没有在 MATLAB 路径下运行 python setup.py install
,或者你没有在你期望的 Python 环境中运行它。ImportError: ...
或 SystemError: ...
: 最常见的原因是 Python 与 MATLAB 版本不兼容。请再次核对官方兼容表。connect_matlab
) 是否成功,这有助于定位问题。遵循以上步骤,可以解决 99% 的环境配置问题,为后续的开发扫清障碍。
现在,环境已经就绪,让我们深入探索 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 取回向量时得到的特殊类型。这是一个需要注意的细节。虽然可以直接对其进行迭代,但在很多需要原生 list
或 numpy.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(...)
的形式来更新工作区中的变量。现在,我们将运用所学知识,重构卷一中的“智能垂直农场”案例。这次,我们将抛弃复杂的 C S-Function 和 Socket 通信,转而使用一个纯 Python 脚本来作为主控制器,实现逐个时间步的闭环控制。
架构设计:
Simulink 模型 (vertical_farm_plant_engine.slx
):
Constant
模块作为控制输入(光照强度、光谱、营养液速率等)。我们将从 Python 中通过 set_param
实时更新这些 Constant
模块的值。To Workspace
模块来捕获四个传感器(温度、湿度、CO2、水分)的输出。我们将配置它输出为 Timeseries
格式。Fixed-step
, auto
)。StopTime
将由 Python 脚本控制。Python 主控脚本 (run_farm_simulation_engine.py
):
for
循环,代表仿真的总步数。intelligent_controller
函数,计算出新的控制指令。eng.set_param
将五个新的控制指令值写入 Simulink 模型中对应的五个 Constant
模块。eng.sim
,设置仿真时间为从 current_time
到 current_time + step_size
。这是实现单步推进的关键。SimulationOutput
对象中提取新的传感器数据,并存储历史记录。current_time
,进入下一次循环。Simulink 模型准备 (vertical_farm_plant_engine.slx
):
py_cosim_sfcn
S-Function 模块。Sources
中,拖入5个 Constant
模块。将它们分别命名并放在一个 Subsystem 中,名为 Control Inputs
。模块的路径将是 vertical_farm_plant_engine/Control Inputs/Light Intensity
等。Sinks
中,拖入一个 To Workspace
模块。将其连接到你的物理模型输出的 Mux 信号上。双击它,设置:
sim_yout
Timeseries
Modeling
-> Model Settings
(Ctrl+E) 中:
Solver
-> Type
: Fixed-step
Solver
-> Solver
: auto
或 ode1
即可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[