手把手教你用C#做疫情传播仿真
手把手教你用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 - 性能優化
注意上文中的代碼,它原本可能會是一個?5000x?5000的大循環,而每幀的時間僅僅只有?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,保存好這兩個索引后,這個雙層循環檢測性能可以從?5000x?5000降低到?0-?2000x?2000,最優情況是初期和未期,數據規模趨近于?0,最差情況在中期,數據規模趨近于?2000x?2000,總之會比簡單的雙層循環快很多。
注意:索引是有明顯缺點的,索引的本質是緩存,緩存的本質是狀態,狀態的屬性之一,就是?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,各位可以自由?star/?fork/提?issue/?PR。
總結
以上是生活随笔為你收集整理的手把手教你用C#做疫情传播仿真的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 前端 JS/TS 调用 ASP.NET
- 下一篇: 软硬件协同编程 - C#玩转CPU高速缓