浙江大学淋巴医疗系统架构设计

淋巴测量软件设计

项目介绍

本项目旨在支持浙江大学医学教授在淋巴检测设备上的研发与实现

工期

image-20240818201144615

架构设计

整体采用单体架构,多模块开发;基于ruoyi框架用户模块,系统模块,通用模块以及最核心的诊断模块

诊断模块是项目的核心实现,其又可以分为诊断模块、设备模块、患者信息模块、扫描详情模块

系统设计

image-20240818200908499

设计参考

image-20240712095033823

重点词语:qualitative part 定性的部分;volume vs. time diagram 体积与时间关系图

1
定义java方法监听串口,串口会每20ms发送modbus格式的数据并以\n对每个指令进行分隔,要求对每个完整的指令进行处理计算,当读取到的数据末尾不是\n则留到下次读取,确保发过来的每条指令都能完整收到并处理

数据库设计

image-20240819104321167

字典表(ruoyi自带)

系统参数表(ruoyi自带)

医生表(ruoyi自带)

采用若依自带的user表,医生表要求登录可以看到自己负责的病人并做诊断,需要包含账号密码用于登录

检查结果表(dgn_result)

字段 含义 数据类型 默认值 是否可空 约束
id 诊断结果编号 bigint(20) 主键
patient_code 病人id bigint(20)
doctor_id 负责的医生id bigint(20)
diagnostic_status 诊断状态(对应字典:异常/正常等) bigint(20)
diagnostic_describe 诊断描述 varchar(512)
diagnostic_part 诊断部位(对应字典:左右臂等) bigint(20)
figure_id 扫描图形id bigint(20)
create_time 创建时间 datetime
update_time 更新时间 datetime
frequency 采集次数 int
create_by 创建人 varchar(64)
update_by 更新人 varchar(64)
patient_name 病人name varchar(10)
device 设备号 int

扫描详情(dgn_detail)

不确定前端数据渲染方案,听甲方需求是后端返回长宽信息交给前端做渲染,可能会使用influxDB/mongoDB,具体方案待明确

字段 含义
figure_id 图形id
volume 体积
total_length 总长度
img 采集模型的图像
create_time 创建时间 datetime
update_time 更新时间 datetime
create_by 创建人
update_by 更新人

使用时间序列数据库(如InfluxDB或TimescaleDB)

优点:

  1. 高性能写入:时间序列数据库特别优化了时间序列数据的写入和查询,非常适合高频率的数据记录。
  2. 压缩与存储优化:这类数据库通常内置数据压缩机制,可以有效减少存储空间需求。
  3. 预聚合功能:对于历史数据的统计分析,时间序列数据库能够预先计算聚合结果,加速查询速度。

患者表(patient_info)

字段 含义 数据类型 默认值 是否可空 约束
id 诊断结果编号 bigint(20) 主键
patient_code 病人id bigint(20)
doctor_id 负责的医生id bigint(20)
diagnostic_status 诊断状态(对应字典:异常/正常等) bigint(20)
diagnostic_describe 诊断描述 varchar(512)
diagnostic_part 诊断部位(对应字典:左右臂等) bigint(20)
figure_id 扫描图形id bigint(20)
create_time 创建时间 datetime
update_time 更新时间 datetime
frequency 采集次数 int
create_by 创建人 varchar(64)
update_by 更新人 varchar(64)
patient_name 病人name varchar(10)
device 设备号 int

设备详情 (dgn_devcie)

字段 含义 数据类型 默认值 是否可空 约束
id 诊断结果编号 bigint(20) 主键
patient_code 病人id bigint(20)
doctor_id 负责的医生id bigint(20)
diagnostic_status 诊断状态(对应字典:异常/正常等) bigint(20)
diagnostic_describe 诊断描述 varchar(512)
diagnostic_part 诊断部位(对应字典:左右臂等) bigint(20)
figure_id 扫描图形id bigint(20)
create_time 创建时间 datetime
update_time 更新时间 datetime
frequency 采集次数 int
create_by 创建人 varchar(64)
update_by 更新人 varchar(64)
patient_name 病人name varchar(10)
device 设备号 int

设计模式选择

工厂+策略模式

获取光栅数据有两种方案:上报、轮询。他们分别都有优缺点,适应不同的场景,医生通过选项来指定对应的模式来执行最终的扫描操作

  • 上报:数据处理快,但是无法保障数据的完整性,精度一般(适用于简单的形体扫描)
  • 轮询:数据处理慢,能保障数据的完整性,能自主设定轮询频率,精度相对就高(适用于更加精细的使用场景)

使用该模式的好处:

  • 工厂模式:可以避免if-else,优化代码结构
  • 策略模式:用于对应两种数据获取方案
  1. 定义策略
1
2
3
public interface RasterDataProcessStrategy {
void rasterDataProcess(SerialPort serialPort, int conveyorAddress, int[] rasterAddress,double stepLength,int frequency);
}

策略的两个实现:

  • 轮询策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@Component("RasterDataPoll")
public class RasterDataPollStrategy implements RasterDataProcessStrategy {


@Override
public void rasterDataProcess(SerialPort serialPort, int conveyorAddress, int[] rasterAddress,double stepLength ,int frequency) {
// 机器站点一般都不会大于127,这里强转为byte
ModBusCommandUtil.sendWriteCommand(serialPort, ModBusCommandConstant.FORWARD.getCommand((byte) conveyorAddress));
sleep(100);
ModBusCommandUtil.sendWriteCommand(serialPort, ModBusCommandConstant.RUN_CONVEYOR.getCommand((byte) conveyorAddress));
sleep(100);
// 读取串口判断命令是否正确被接收执行
ModBusCommandUtil.sendWriteCommand(serialPort, ModBusCommandConstant.RUN_RASTER.getCommand((byte) rasterAddress[0]));
ModBusCommandUtil.sendWriteCommand(serialPort, ModBusCommandConstant.RUN_RASTER.getCommand((byte) rasterAddress[1]));
// 判断传送带是否运行(轮询5次)
byte[] bytes = ModBusCommandUtil.sendReadCommand(serialPort, ModBusCommandConstant.RUN_CONVEYOR_STATUS.getCommand((byte) conveyorAddress));
int status = ModBusCommandUtil.toUnsignedInt(bytes);
int count = 1;
while (count<=5){
if (status == 1){
new Thread(new RasterDataTask(serialPort,conveyorAddress,rasterAddress,stepLength,frequency)).start();
return;
}
sleep(100);
bytes = ModBusCommandUtil.sendReadCommand(serialPort, ModBusCommandConstant.RUN_CONVEYOR_STATUS.getCommand((byte) conveyorAddress));
status = ModBusCommandUtil.toUnsignedInt(bytes);
count++;
}
throw new RuntimeException("传送带未启动,请检查设备地址是否正确!");
}

private static class RasterDataTask implements Runnable{

private final int conveyorAddress;
private final int[] rasterAddress;
private final SerialPort serialPort;
private final double stepLength;
private final int frequency;

public RasterDataTask(SerialPort serialPort,int conveyorAddress, int[] rasterAddress, double stepLength ,int frequency) {
this.conveyorAddress = conveyorAddress;
this.rasterAddress = rasterAddress;
this.serialPort = serialPort;
this.stepLength = stepLength;
this.frequency = frequency;
}

/**
* 当传送带正确启动后执行光栅数据轮询操作
* TODO 多线异步优化
*/
@Override
public void run() {
int orderNum = 0;
IDetailService detailService = SpringUtils.getBean(IDetailService.class);
WebSocketController webSocketController = SpringUtils.getBean(WebSocketController.class);
// 将detail对象保存在List集合中,等到传送带运动结束再执行批量插入操作
List<Detail> details = new LinkedList<>();
double totalVolume = 0;
while (true){
/*
* 轮询光栅数据,获取光栅长度
* */
// 判断传送带是否停止
byte[] bytes = ModBusCommandUtil.sendReadCommand(serialPort, ModBusCommandConstant.RUN_CONVEYOR_STATUS.getCommand((byte) conveyorAddress));
int status = ModBusCommandUtil.toUnsignedInt(bytes);
if (status == 0){
break;
}
byte[] raster0 = ModBusCommandUtil.sendReadCommand(serialPort, ModBusCommandConstant.READ_RASTER.getCommand((byte) rasterAddress[0]));
byte[] raster1 = ModBusCommandUtil.sendReadCommand(serialPort, ModBusCommandConstant.READ_RASTER.getCommand((byte) rasterAddress[1]));
double rasterLength0 = ModBusCommandUtil.toUnsignedInt(raster0)*2.5;
double rasterLength1 = ModBusCommandUtil.toUnsignedInt(raster1)*2.5;
// 封装长宽数据保存到数据库
Detail detail = new Detail();
detail.setOrderNum(orderNum);
detail.setA(rasterLength0);
detail.setB(rasterLength1);
detail.setVolume(rasterLength0*rasterLength1);
details.add(detail);
orderNum++;
double curVolume = rasterLength0 * rasterLength1 * stepLength;
totalVolume += curVolume;
// WebSocket上传a,b数据给前端 (WebSocket or UDP传输 or Netty)
webSocketController.sendVolumeData(rasterLength0,rasterLength1,curVolume);
sleep(frequency);
}
// 异步发送总扫描体积给前端
// 执行批量插入操作
detailService.saveBatch(details);
}
}
}

  • 上报策略
1
2
3
4
5
6
7
8
9
10
11
12
@Component("RasterDataListener")
public class RasterDataListenerStrategy implements RasterDataProcessStrategy {
@Override
public void rasterDataProcess(SerialPort serialPort, int conveyorAddress, int[] rasterAddress,double stepLength,int frequency) {
// 将设备注册监听类
try {
MySerialPortEventListener listener = new MySerialPortEventListener(serialPort, 1,rasterAddress,conveyorAddress);
}catch (Exception e){
e.printStackTrace();
}
}
}
  1. 策略工厂类,用于生成策略
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Service
public class RasterDataProcessFactory {

@Autowired
Map<String, RasterDataProcessStrategy> map = new ConcurrentHashMap<>();

/*
* 根据设备类型选择适配器
* */
public RasterDataProcessStrategy getRasterDataProcessAdapter(String deviceType){
RasterDataProcessStrategy rasterDataProcessAdapter = map.get(deviceType);
if (rasterDataProcessAdapter == null){
throw new RuntimeException("设备类型错误");
}
return rasterDataProcessAdapter;
}
}
  1. 实际使用
1
2
3
4
5
if (serialPort != null){
//适配器模式优化
RasterDataProcessStrategy rasterDataPollStrategy = rasterDataProcessFactory.getRasterDataProcessAdapter("RasterDataPoll");
rasterDataPollStrategy.rasterDataProcess(serialPort, conveyorAddress, rasterAddress,stepLength, frequency);
}

命令模式

  • 应用场景:当你的系统需要将请求封装成对象,以便使用不同的请求、队列请求或者记录请求日志时,可以使用命令模式。特别适用于有固定指令集的上下位机通信。
  • 实现思路:定义一个命令接口,所有的具体指令类都继承该接口。这样,每个指令都是一个对象,可以被单独处理和执行。这使得添加新的指令变得容易,只需实现命令接口即可。

观察者模式

  • 应用场景:当上位机需要监听下位机的状态变化时,可以使用观察者模式。
  • 实现思路:下位机作为主题,上位机作为观察者。当下位机状态发生改变时,主动通知所有注册的观察者,这样可以实现松耦合的通信机制。

适配器模式

多个方法解决同一个问题

  • 目标接口(RasterDataListenerAdapter,RasterDataPollAdapter:需要适配的标准接口。
  • 源对象(source):需要被适配的不兼容对象,这里是serialPort的数据。
  • 适配器对象(RasterDataProcessAdapter:充当中间转换角色,该对象将源对象转换成目标接口。

单例模式

  • 应用场景:当需要在整个系统中共享唯一的通信实例时,可以使用单例模式。
  • 实现思路:确保一个类只有一个实例,并提供一个全局访问点。

结合以上设计模式,可以构建出既灵活又强大的上下位机通信框架。在实际开发中,根据具体需求选择合适的模式组合,可以使代码结构更加清晰,易于理解和扩展。

技术选型

mybatis-plus

1
2
3
4
5
6
<!-- MyBatis Plus 核心库 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3</version>
</dependency>

jSerialComm

一个很好的Java串口通信工具

com.fazecast.jSerialComm (jSerialComm 2.11.0 API)

Spring WebSocket

后端传输给前端的长宽数据用于实时展示,其基数之大上报速率之快,http通信每一次响应需要四次招手三次挥手无法满足要求性能要求,故采用全双工协议WebSocket做长宽数据上报

JNA

采用Java调用C语言来计算ModBus指令的CRC16校验码

模块设计

信息录入

预处理

串口参数配置

波特率,串口号,校验位,数据位,停止位

设备参数

采集次数,步进长度,轮询频率,最大运行距离,原点位置

扫描

数据处理

  • 数据保存在MySQL中
  • 数据实时返回给前端

基于WebSocket,每条数据上报,前端保存在容器,在数据上报完成之后,或者数据达一半时,执行渲染操作

诊断

基础知识

ModBus

RS485和Modbus还傻傻分不清?看了本文你就清楚了_485协议和modbus协议-CSDN博客

学习使用Modbus RTU模式,首要就是要知道报文格式。其中:

  • 地址位(Address):一个8位的二进制数,用于标识从设备的地址。主设备通过地址位来选择要通信的从设备。

  • 功能码(Function Code):一个8位的二进制数,用于指示主设备要执行的操作类型,如读取数据、写入数据等。

  • 数据位(Data):根据功能码的不同,数据位可以包含读取或写入的数据。

  • 校验位(Checksum):一个16位的循环冗余校验(CRC)码,用于检测数据在传输过程中是否发生错误。

image-20240704213948867

ModBus调试

如果是从机:

  • Rx为接收到的主机消息帧
  • Tx为发送到的主机数据

RS485

RS485是一种物理层通信标准,用于在串行通信中传输数据。它定义了电气特性、信号传输方式和连接方式等。RS485通信可以支持多个设备通过同一总线进行通信,其中一个设备作为主设备发送指令,其他设备作为从设备接收指令。

指令发送与响应

image-20240729143627700

寄存器

寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算结果。其实寄存器就是一种常用的时序逻辑电路,但这种时序逻辑电路只包含存储电路。寄存器的存储电路是由锁存器或触发器构成的,因为一个锁存器或触发器能存储1位二进制数,所以由N个锁存器或触发器可以构成N位寄存器。寄存器是中央处理器内的组成部分。寄存器是有限存储容量的高速存储部件,它们可用来暂存指令、数据和位址

CRC校验

CRC校验原理及步骤-CSDN博客

CRC即循环冗余校验码:是数据通信领域中最常用的一种查错校验码,其特征是信息字段和校验字段的长度可以任意选定。循环冗余检查(CRC)是一种数据传输检错功能,对数据进行多项式计算,并将得到的结果附在帧的后面,接收设备也执行类似的算法,以保证数据传输的正确性和完整性。

若依

新建自己的模块

若依框架-(一)自定义模块添加 - 晨光静默 - 博客园 (cnblogs.com)

  1. 在父目录下新建新模块

image-20240712164737929

  1. 在父类项目中添加自己的模块
1
2
3
4
5
6
<!-- 诊断模块-->
<dependency>
<groupId>com.ruoyi</groupId>
<artifactId>ruoyi-diagnosis</artifactId>
<version>${ruoyi.version}</version>
</dependency>
1
2
3
4
5
6
7
8
9
<modules>
<module>ruoyi-admin</module>
<module>ruoyi-framework</module>
<module>ruoyi-system</module>
<module>ruoyi-quartz</module>
<module>ruoyi-generator</module>
<module>ruoyi-common</module>
<module>ruoyi-diagnosis</module>
</modules>
  1. 在admin模块添加自己的模块,并在自己模块引入common

新建菜单

若依分离版手把手教你新建菜单,配置路由,自动生成前后端代码_若依菜单配置-CSDN博客

集成Mybatis-Plus

若依集成MybatisPlus步骤_若依引入mybatis-plus-CSDN博客

Java

JNA

Java 之 JNA(调用第三方库)-CSDN博客

![Java 之 JNA(调用第三方库)-CSDN博客 - image](../image/Java 之 JNA(调用第三方库)-CSDN博客 - image.png)

JNA(Java Native Access):提供一组Java工具类用于在运行期间动态访问系统本地库(native library:如Window的dll)而不需要编写任何Native/JNI代码。开发人员只要在一个java接口中描述目标native library的函数与结构,JNA将自动实现Java接口到native function的映射。

java调用c
  1. 写一个c程序使用MinGw工具打包成dll或so动态链接文件

windows

  • gcc -m64 -shared -o //该指令只适合C语言

linux

  • gcc -m64 -fPIC -shared -o
1
2
3
4
5
extern "C" {
int sum(int x, int y) {
return x + y;
}
}
  1. 引入依赖,实现Library接口
1
2
3
4
5
<dependency>
<groupId>com.sun.jna</groupId>
<artifactId>jna</artifactId>
<version>3.0.9</version>
</dependency>
1
2
3
4
5
6
7
8
public interface Sum extends Library {

//动态库接口
int sum(int x, int y);

//创建动态库实例 dllPath = src/main/resources/sum.dll
Sum INSTANCE = (Sum) Native.loadLibrary(dllPath, Sum.class);
}
  1. java调用cpp程序
1
Sum.INSTANCE.sum(1,2)

前端

vue项目引入外部js客服页

public/index.html中加入

1
2
3
4
5
<script
async
defer
src="http://120.77.201.84:8080/api/application/embed?protocol=http&host=120.77.201.84:8080&token=40dc856f3373d0da">
</script>

指令截取

响应:

  • 读指令(7字节):返回数据载荷(数据位为1字节)
  • 写指令(8字节):返回指令对比是否与发送指令相同来判断是否写操作成功

CompletableFuture

CompletableFuture提交任务whenComplete

难点

实验表明:

对与同一个串口来说,SerialPort无论多个对象只是指定一个串口资源,当被某个SerialPort对象开启占用端口时,其他对象无法开启,除非执行:serialPort.closePort();

这就表明,在连接过期之时,必须执行close关闭资源操作,才能保证下一个连接能进行

使用心跳检测+Redis过期回调技术,保障串口通信资源正常关闭以及刷新设备在线状态

redis过期回调功能实现-CSDN博客

联调问题

java发送串口指令的方案可行性

  • 读指令是否成功
  • 写指令是否截取数据成功
  • 错误的原因