手把手教你用C#做疫情传播仿真

在上篇文章中,我介绍了用 C#做的疫情传播仿真程序的使用和配置,演示了其运行效果,但没有着重讲其中的代码。

今天我将抽丝剥茧,手把手分析程序的架构,以及妙趣横生的细节。

首先来回顾一下运行效果:

注意看,程序中的信息,包含信息统计、城市居民展示和医院展示三个部分,其中居民按状态的不同,显示为不同的颜色。

本文将先从程序员的角度,说说程序中的实现细节,细节中会聊一聊与与 Java版的不同,最后进行总结。

细节介绍

细节介绍一 · 从“人”说起

居民类如下所示:

struct Person
{public PersonStatus Status;public Vector2 Position;public float EstimateDays;public float Direction;public static Person Create(float citySize){// ...}public void Draw(DeviceContext ctx, XResource x){// ...}public void MoveAroundInCity(float dt, float citySize){// ...}
}
enum PersonStatus
{Healthy, // 健康InfectedInShadow, // 被感染,处于潜伏期Illness, // 发病InHospital, // 发病并进入医院Cured, // 治愈Dead, //死亡
}

一个城市将会模拟 5000个居民,因此在设计这个类的时候,应该尽可能地考虑性能、节约内存。

所以,状态最好越少越好,在设计这个类的时候,我谨慎地保留了状态 Status、当前位置 Position、用于做状态机的 EstimateDays和移动方向 Direction这四个状态。

细节介绍二 - 居民的状态变更流

居民状态扭转过程如下所示:

 (有传染性,传染给健康人)????   ⬆       ⬆????   ⬆       ⬆
健康 ➡ 潜伏期 ➡ 发病 ➡ 入院隔离 ➡ 治愈↘     ↙↘ ↙死亡

其中, 健康到 被感染的验证除了状态检测外,还要由居民之间的距离决定。而是否戴口罩,又会影响其判断距离,这些逻辑用代码表示如下:

const float InffectRate = 0.8f; // 靠得够近时,被携带者感染的机率
static bool WearMask = false; // 是否戴口罩
// 要靠多近,才会触发感染验证
static float SafeDistance() => WearMask ? 1.5f : 3.5f;
void StepDay()
{// ...// healthy -> infectedList<int> newlyInffectedIds = new List<int>();newlyInffectedIds = healthyIds.AsParallel().Where(x =>{foreach (var infectorId in infectorIds){if (Vector2.DistanceSquared(Persons[x].Position, Persons[infectorId].Position) <= SafeDistance() * SafeDistance())return true;}return false;}).ToList();foreach (int personId in newlyInffectedIds){Infect(personId);}
}

EstimateDays字段用于控制潜伏期发病到去医院的等待时间治愈时间,这个字段用得较为巧妙。正常可能需要三个字段,但这三种状态之间,不存在状态共享,因此可以使用一个共享的字段来代替。

比如, infected->illness状态扭转的代码表述如下:

void StepDay()
{for (var i = 0; i < Persons.Length; ++i){// ... 其它代码// infected -> illnessif (Persons[i].Status == PersonStatus.InfectedInShadow){--Persons[i].EstimateDays;if (Persons[i].EstimateDays <= 0){Persons[i].Status = PersonStatus.Illness;Persons[i].EstimateDays = GenerateToHospitalDays();}continue;}}// ... 其它代码
}

注意,代码中总会使用 EstimateDays,来判断是否要进入下一个状态,而进入下一个状态后,便会重新指定新的 EstimateDays。通过这样的状态共享,便可为 Person类节省许多状态。

细节介绍3 - 性能优化

注意上文中的代码,它原本可能会是一个 50005000的大循环,而每帧的时间仅仅只有 1/60=13.33ms

经过反复思考,我使用了三种方法来优化。

优化1 · 索引与缓存

首先是在城市类 City中,我使用了一个索引:

class City
{public Person[] Persons;private SortedSet<int> infectorIds = new SortedSet<int>();private SortedSet<int> healthyIds = new SortedSet<int>();// ... 其它代码
}

该索引维护了两个索引 infectorIds和 healthyIds,保存好这两个索引后,这个双层循环检测性能可以从 50005000降低到 020002000,最优情况是初期和未期,数据规模趋近于 0,最差情况在中期,数据规模趋近于 20002000,总之会比简单的双层循环快很多。

注意:索引是有明显缺点的,索引的本质是缓存,缓存的本质是状态,状态的属性之一,就是 bug,多一份索引,就需要多加一处维护索引的位置,就多加了一层“写 bug”的风险。另外索引过多,可能会影响性能。

我会尽我一切努力,不给程序引入额外状态。除非我有一个无法拒绝的理由。

优化2 · 多线程

这算是 .NET的福利吧。

如代码所示,我使用了 PLINQ,这是从 .NET4.0推出的新玩意,只需一条简单的 AsParallel(),就可以让代码几乎不变,就能享受多核 CPU带来的性能红利,我完全不需要处理同步等机制。

优化3 · 使用值类型

也如代码所示,我特意为 Person类选择了值类型( struct),它的优点在本程序中体现在两处:

一是在于创建时,无需分配堆内存,要知道内存分配需要请求操作系统(就像浏览器请求服务器那样)非常缓慢;

二是值类型数据的值,在内存中是连续的。这对 CPU缓存是个天大的好消息。无论是否是现代 CPU,对连续型的内存访问,性能总是最高的,在一性能测试中,连续内存与非连续内存的 CPU访问速度差,高达 50倍之大。

注意: Java中没提供类似于 struct这样的关键字,无法自定义值类型。但通过一定技巧,如创建基元类型数组,也能实现高性能的连续内存访问。

我之前写过一篇文章《.NET中的值类型与引用类型》,包含了详情说明(包含缺点与优化、使用场景等)和性能测试。

细节介绍四 - 时间控制

我尝试写过很多游戏和动态模拟器,我认为时间控制的优劣,最能体现出一个模拟器/游戏制作者的用心。一般程序员都喜欢将垂直同步事件当作游戏的心脏,这样最简单,用代码表述如下(已简化):

void Render()
{float dt = RenderTimer.LastFrameTimeInSecond;Update(dt);Draw(ctx);SwapChain.Present(1, 0);
}

这样的好处是逻辑可能比较简单,可以在大脑中脑补每秒 60帧,然后按 60帧设置参数,想事情。

这样一来,更新逻辑 Update(dt)可能就会和垂直同步事件强绑定。要知道有些投影仪可能只有 50帧,而某些显示器,有 144帧;然后就是它也和垂直同步选项强绑定,一旦关闭垂直同步, Update逻辑可能就会过快而导致程序运行不正常。

我的做法是将这些逻辑稍作封装,代码中的配置,只与真实世界中的时间相关,而与垂直同步选项无关:

const float SecondsPerDay = 0.3f; // 模拟器的秒数,对应真实一天
class City
{float dayAccumulate = 0;public void Update(float dt){// step movefor (var i = 0; i < Persons.Length; ++i){Persons[i].MoveAroundInCity(dt, CitySize);}// step statusdayAccumulate += dt;day += (dt / SecondsPerDay);while (dayAccumulate >= SecondsPerDay){StepDay();dayAccumulate -= SecondsPerDay;}}
}

注意我使用了一个 SecondsPerDay,来控制模拟器的运行速度,将这个值调大或调小,不影响运行的最终结果。

我还使用了一个 dayAccumulate值,用于做按“”更新判断,这样的话,无论函数调用频率如何,调用 StepDay()时都会确保相隔“一整天”。

细节介绍五 - 缩放管理

和时间管理一样,我认为窗口大小与缩放控制也很重要,否则程序只能以一种固定的分辨率、 DPI来运行。我使用的是我自己写的“准”游戏引擎 FlysEngine,它基于 Direct2D,可以通过矩阵变换轻松地管理好程序缩放:

protected override void OnDraw(DeviceContext ctx)
{ctx.Clear(Color.DarkGray);float minEdge = Math.Min(ClientSize.Width / 2, ClientSize.Height / 2);float scale = minEdge / 540; // relative coordinatectx.Transform =Matrix3x2.Scaling(scale) *Matrix3x2.Translation(ClientSize.Width / 2, ClientSize.Height / 2);City.Draw(ctx, XResource);
}

注意我定义了一个“魔法值”—— 540,它是 FHD1920x1080中,短边 1080的一半。

这样一来,有两个好处。

首先,我程序后面所有代码,都可以按照 1920x1080的“相对值”进行设计。无论客户的桌面分辨率是 4kUHD还是 1366x768,都会以相同的比例做缩放。

其次我还将坐标原点设为屏幕的正中心,这样也更加简化了我的后续代码,比如在控制 Person的出生点时,我可以通过极坐标系直接生成:

struct Person
{public static Person Create(float citySize){float phi = random.NextFloat(0, MathUtil.TwoPi);float r = random.NextFloat(0, citySize);var p = new Person { Status = PersonStatus.Healthy };p.Position.X = MathF.Sin(phi) * r;p.Position.Y = -MathF.Cos(phi) * r;p.Direction = random.NextFloat(0, MathF.PI * 2);return p;}// 其它代码
}

总结

本文从五个细节聊了我的【.NET疫情传播程序】的代码,其实这些代码不光应用在这个程序中,也应用到了我写过的许多小游戏和模拟器,都非常重要。

所有这些代码都已经上传到我的 Github:https://github.com/sdcb/2019-ncp-simulation,各位可以自由 starfork/提 issuePR

手把手教你用C#做疫情传播仿真相关推荐

  1. python代码示例图形-纯干货:手把手教你用Python做数据可视化(附代码)

    原标题:纯干货:手把手教你用Python做数据可视化(附代码) 导读:制作提供信息的可视化(有时称为绘图)是数据分析中的最重要任务之一.可视化可能是探索过程的一部分,例如,帮助识别异常值或所需的数据转 ...

  2. python画图代码大全-纯干货:手把手教你用Python做数据可视化(附代码)

    原标题:纯干货:手把手教你用Python做数据可视化(附代码) 导读:制作提供信息的可视化(有时称为绘图)是数据分析中的最重要任务之一.可视化可能是探索过程的一部分,例如,帮助识别异常值或所需的数据转 ...

  3. 影音服务器nas硬盘,手把手教您用win10做NAS:搭配emby,VM虚拟群晖,直通硬盘!打造家庭影音媒体服务器!...

    手把手教您用win10做NAS:搭配emby,VM虚拟群晖,直通硬盘!打造家庭影音媒体服务器! 2020-02-23 20:14:28 1551点赞 10291收藏 1129评论 创作立场声明:Win ...

  4. 【手把手教你用Matlab做双目摄像头标定】Ubuntu环境

    [手把手教你用Matlab做双目摄像头标定] Ubuntu20.04环境 准备工作 你需要一个标定板 你需要一个双目摄像头 获取双目摄像头的设备号 跑起来看看 分割图像并完成拍照 使用Matlab进行 ...

  5. 【2023-Pytorch-检测教程】手把手教你使用YOLOV5做麦穗计数

    小麦是世界上种植地域最广.面积最大及产量最多的粮食作物,2021年世界小麦使用量达到7.54亿吨.小麦产量的及时预估对作物生产.粮食价格及粮食安全产生重大影响,单位面积穗数是小麦产量预估研究中的难点及 ...

  6. 【量化】手把手教你用 Python 做股票入门分析(一)

    作者:悠悠做神仙 来源: 恒生LIGHT云社区 前面量化入门系列,给大家分享了一些编程语言以及数据源--> 量化交易入门系列1:编程语言与数据源 ,很多读者私信我,希望可以手把手的教一下如何进行 ...

  7. garch预测 python_【2019年度合辑】手把手教你用Python做股票量化分析

    引言 不知不觉,2019年已接近尾声,Python金融量化公众号也有一年零两个月.公众号自设立以来,专注于分享Python在金融量化领域的应用,发布了四十余篇原创文章,超过两万人关注.这一路走来,有过 ...

  8. 手把手教你用Jieba做中文分词

    导读:近年来,随着NLP技术日益成熟,开源实现的分词工具越来越多,如Ansj.HanLP.盘古分词等.本文我们选取了Jieba进行介绍. 作者:杜振东 涂铭 来源:大数据DT(ID:hzdashuju ...

  9. 手把手教你用Python做个可视化的“剪刀石头布”小游戏

    点击上方"Python爬虫与数据挖掘",进行关注 回复"书籍"即可获赠Python从入门到进阶共10本电子书 今 日 鸡 汤 众里寻他千百度.蓦然回首,那人却在 ...

最新文章

  1. hdu-1003 or 最大子序列和(四种解题方法)
  2. python 设计模式 原型模式_python设计模式–原型模式
  3. 2017.4.6AM
  4. 深入理解JVM文章合集
  5. mysql之 double write 浅析
  6. 路由器OSPF协议配置命令一
  7. 自动提示_EXCEL2013版突然打不开,自动修复提示1907错误
  8. Linux下安装libiconv
  9. HttpSession基础
  10. 使用Canvas绘制简单工程符号
  11. BCD码中的8421码、2421码、5421码和余3码
  12. uchome持久XSS(2.0版本测试通过)
  13. Altium designer—STM32F103ZET6最小系统原理图
  14. 2014级学生程序设计学习大数据
  15. k8s节点NotReady问题定位
  16. Visual Servo Control Part I: Basic Approaches
  17. 微信小程序显示html内容
  18. 带时区时间日期 ZonedDateTime
  19. java作业题exercise1
  20. linux中.emp结尾的文件,EMP文件扩展名 - 什么是.emp以及如何打开? - ReviverSoft

热门文章

  1. System.Xml名称空间下的支持DOM的类型
  2. os x 启动引导_什么是OS X的启动板以及它如何工作?
  3. Centos6.8 安装spark-2.3.1 以及 scala-2.12.2
  4. 梦回编程- 由LD_LIBRARY_PATH引发JNI的理解
  5. JSTL分割字符 fn:split()
  6. c/c++ code JSON
  7. 开源Math.NET基础数学类库使用(04)C#解析Matrix Marke数据格式
  8. 【转】java io 总结(图)
  9. 我做的百度飞桨PaddleOCR .NET调用库
  10. 一行代码完成定时任务调度,基于Quartz的UI可视化操作组件 GZY.Quartz.MUI