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


手把手教你用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 -> infected
    List<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 -> illness
        if (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 - 性能優化

注意上文中的代碼,它原本可能會是一個5000x5000的大循環,而每幀的時間僅僅只有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>();

    // ... 其它代碼
}

該索引維護了兩個索引infectorIdshealthyIds,保存好這兩個索引后,這個雙層循環檢測性能可以從5000x5000降低到0-2000x2000,最優情況是初期和未期,數據規模趨近於0,最差情況在中期,數據規模趨近於2000x2000,總之會比簡單的雙層循環快很多。

注意:索引是有明顯缺點的,索引的本質是緩存,緩存的本質是狀態,狀態的屬性之一,就是bug,多一份索引,就需要多加一處維護索引的位置,就多加了一層“寫bug”的風險。另外索引過多,可能會影響性能。

我會盡我一切努力,不給程序引入額外狀態。除非我有一個無法拒絕的理由。

優化2 · 多線程

這算是.NET的福利吧。

如代碼所示,我使用了PLINQ,這是從.NET 4.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 move
		for (var i = 0; i < Persons.Length; ++i)
		{
			Persons[i].MoveAroundInCity(dt, CitySize);
		}

		// step status
		dayAccumulate += 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 coordinate
    ctx.Transform =
        Matrix3x2.Scaling(scale) *
        Matrix3x2.Translation(ClientSize.Width / 2, ClientSize.Height / 2);
    City.Draw(ctx, XResource);
}

注意我定義了一個“魔法值”——540,它是FHD 1920x1080中,短邊1080的一半。

這樣一來,有兩個好處。

首先,我程序后面所有代碼,都可以按照1920x1080的“相對值”進行設計。無論客戶的桌面分辨率是4k UHD還是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疫情傳播程序】的代碼,其實這些代碼不光應用在這個程序中,也應用到了我寫過的許多小游戲和模擬器,都非常重要。

所有這些代碼都已經上傳到我的Githubhttps://github.com/sdcb/2019-ncp-simulation,各位可以自由star/fork/提issue/PR

喜歡的朋友請關注我的微信公眾號【.NET騷操作】:

DotNet騷操作


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM