緊急地震速報を複数対応したはなし

2024年12月12日書いた!

この記事は防災アプリ Advent Calendar 2023 - 18日目です。
前後日、大物方に挟まれてる!頑張んないと!

MGNTとは

福岡県福岡市で地震観測をしていて、プログラミングによりソフトウェアやサイトを作っています。FukuokaMGNTCam

タイトル回収

ということで今回は、複数の緊急地震速報(以下EEW)の処理について書きます。調べても簡単には出てこず、非常に悩みました。
半分備忘録的な感じなので、地震の用語が頻繁に使用されます。

PointMonitor まずはの成果物

EEWの予報円が2つの地震分あるじゃないですか。複数あったらP波S波分かりやすいですね!S波の内部を塗りつぶすことで、主要動であることを主張できますね。

その前にEEWの複数って?

そのままの意味です。EEWが短時間に複数発生した状態のことをいいます。大きな地震の後とか、この現象ありがちだと思います。

考え方

graph TD; API-->EventIDが違う?; EventIDが違う?-->同じ EventIDが違う?-->違う 同じ-->複数地震としてList追加; 違う-->該当情報を更新; 複数地震としてList追加-->すべての緊急地震速報のList; 該当情報を更新-->すべての緊急地震速報のList-->順番に一つずつ緊急地震速報を表示-->一定時間を超えた情報は削除;

という感じです。


WinFormsで書いてみる

先ほどのイメージ図にあったEventIDというのは、
緊急地震速報が電文として発表される際に含まれる情報で、一つの緊急地震速報に対し固有のEventIDを持ちます。
これを利用し、既存の情報に対し、新しく入った情報のEventIDが一致すれば、既存情報の更新とします。
逆に、既存の情報に対し、新しく入った情報のEventIDが一致しなければ、新しい緊急地震速報として追加します。


緊急地震速報の分別

まずは土台となるコードです。

List eventList = new List(); //すべての緊急地震速報を格納するList
public static EarthquakeEvent viewInfo; //表示する際に使う緊急地震速報データ
public class EarthquakeEvent
{
    public string EventID { get; set; } //EventID
    public string Title { get; set; } //緊急地震速報の予報か警報か
    //その他使用したいデータを追加する。
}
                            

そして、緊急地震速報のデータを追加する際には


EarthquakeEvent existingEvent = eventList.FirstOrDefault(e => e.EventID == eventID);
//eventList内で、eventIDと一致する既存のEarthquakeEventオブジェクトの検索

EarthquakeEvent newEvent = null;
if (existingEvent != null)//既存イベントが見つかったら、既存の更新として上書き
{
    existingEvent.Title = Json["Title"]["String"].ToString();
}
else //見つからないときは、nullを返すので、新規として判定し追加
{
    newEvent = new EarthquakeEvent
    {
        EventID = eventID,
        Title = Json["Title"]["String"].ToString()
    };
}
                        

となります。
一応簡単な説明をつけているので、意味は分かりやすくなっていると思います。
ここで複数地震と既存地震の情報の判別をして、追加・更新を行うことは出来ました。


終了判定を付ける

ですが、追加はできても削除する機能はありません。
これでは永久的に緊急地震速報を追加することになってしまいます。
なので、一定時間ごとに処理ができる場所に以下のコードを置いてみます。


if (eventList.Count > 0) //緊急地震速報のデータがある
{
    DateTime now = DateTime.Now;
    eventList.RemoveAll(eventItem =>
    {
        DateTime eventTime = DateTime.ParseExact(eventItem.EventID←最終報受信時刻でもよい, "yyyyMMddHHmmss", null);
        return (now - eventTime).TotalSeconds >= 180;
    });

    if (eventList.Count > 0)
    {
        viewInfo = eventList[0]; //表示する緊急地震速報

        if (eventList.Count > 1) //複数の緊急地震速報の場合
        {
             SwichTime.Start();
        }
    }
    else
    {
        SwichTime.Stop();
    }
}
else //緊急地震速報がない
{
    SwichTime.Stop();
}

「←最終報受信時刻でもよい」については、本番で削除してかまいません。目立たせるためにおいておいてあります。


一定ごとに表示を切り替える

ここでのSwichTime Timerは、viewInfoで表示する情報の切り替えを行うためのタイマーです。
つまり、3秒ごとに複数の緊急地震速報の表示を切り替えるためのタイマーです。

次に、viewInfoで表示する情報を周期的に切り替えるコードです。



private int currentEventIndex = 0;

private void Swich_Tick(object sender, EventArgs e) //3秒ごとに実行するタイマー
{
    try
    {
        if (eventList.Count > 0)
        {
            int currentIndexDisplay = currentEventIndex + 1;
            currentEventIndex = (currentEventIndex + 1) % eventList.Count;
            viewInfo = eventList[currentEventIndex];
        }
        else
        {
            viewInfo = null;
        }
        //表示するメソッドを実行
        //例えば描画メソッドが「Mapping()」の場合は
        //Mapping();
        //を置く。
    }
    catch (Exception ex)
    {
    }
}
                            

eventList(すべての緊急地震速報)の格納している緊急地震速報の数を調べ、
3秒ごとに増やしたりしています。
例えば、3つの緊急地震速報が同時にあった場合、
1つ目→2つ目→3つ目 という感じに順番を変えています。

最後に、切り替えられたりしているviewInfoから情報を取り出す方法については、


var eventID = viewInfo.EventID;
var title = viewInfo.Title;     
                                    

とするだけで、データが入っている限りはデータを取り出すことができます。
データが表示されない場合は、文字を表示するlabelなどで、


label1.Text = eventList.Count.ToString();
                                        

で現在eventList内に存在するデータ(EEW)の「数」を調べてみてください。
(テスト表示の場合、eventIDや最終受信時刻が指定時間を超えたためにデータ消去の可能性があります。)

動作例: 地図も震央緯度経度がわかれば簡単に描画できます。

PointMonitor PointMonitor

取得情報を変えて自動取得しています。同時描画(画像の場合だと、北海道と千葉が地図内にまとめて描画)も考えたのですが、地図の狭さ的にこうするしかありませんでした。


P波S波を描画する


発生時刻と現在時刻の差

ここまで無事に行けたら幸いです。おまけとして、P波とS波の描画をやっていきます。
もちろん、複数対応というテーマですので、波も複数描画をしていきます。

まず、「情報が更新された際に処理する所」で、以下コードを置きます。


Task[] tasks = eventList.Select(earthquakeEvent => Task.Run(() => //eventListの各要素について、非同期処理を行うTaskを生成してらのTaskを要素とする配列を作る
{
    double counts = 0; //カウントを一度0に初期化
    DateTime dt = DateTime.Now;
    var tm = dt.AddSeconds(-2);
    var time = tm.ToString("yyyyMMddHHmmss");
    string endTimestring = time;

    DateTime originTime = DateTime.ParseExact(earthquakeEvent.Origin, "yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture); //"yyyy-MM-dd HH:mm:ss"は、どの形式であるかを取得元を確認してください。このタイプのミスが多いです
    DateTime endTime = DateTime.ParseExact(endTimestring, "yyyyMMddHHmmss", null);
    TimeSpan difference = endTime - originTime; //現在時刻から発生時刻の差を求める

    counts = difference.TotalSeconds; //coutnsにその差を入れる
    TravelTimeTable newEntry = new TravelTimeTable //新しいTraveltimeTableを作り、その中にeventListから取得した情報の設定
    {
        Depth = int.Parse(earthquakeEvent.Depth),
        EventID = earthquakeEvent.EventID
    };

    lock (lockObject)//マルチスレッド環境の競合を防ぐ
    {
        var existingEntry = PSwavein.FirstOrDefault(entry => entry.EventID == earthquakeEvent.EventID); //PSwaveinから同じeventIDを持つ既存のエントリを取得

        if (existingEntry != null) //既存エントリが存在する場合はそれを更新する
        {
            existingEntry.Depth = newEntry.Depth;
            existingEntry.Counts = counts;
        }
        else //新規の情報だったら、新しく追加する
        {
            newEntry.Counts = counts;
            PSwavein.Add(newEntry);
        }
    }
})).ToArray(); 

//以下はメソッドの外に置きます
private object lockObject = new object();
List<.TravelTimeTable> PSwavein = new List<.TravelTimeTable>(); //<.TravelTimeTable>は、記事を書く際にどうしてもこうしないと書けませんでした

public class TravelTimeTable
{
    public double P { get; set; }
    public double S { get; set; }
    public int Depth { get; set; }
    public int Distance { get; set; }
    public string EventID { get; set; }
    public double Counts { get; set; }
}

このコードは、P波S波を描画するうえで、地震発生からの時間をDepthやCountsに入れます。
深さの存在意義については、震源の深さによって波が大幅に変わるからです。

深さが190km違うだけで、このようにP波S波の広がり方が変わります。深発地震について
さらに、択捉島付近の例では、地震発生から30秒後からを表示しています。
P波といっても、地上までに距離があるので、そこまでを動画にしても意味はないでしょう。


P波S波を求める

続いて、P波S波の直径距離を求めていきます。これは、走時表をもとに時間と震源の深さからP波とS波の大きさを求めてみよう (ぶっくさん 様)を使用して求めています。


private void timer1_Tick(object sender, EventArgs e) //予報円の描画頻度に関わります
{
    計算();
    //おススメ 1秒/s=1000ms, 0.5秒/s=500ms, 0.25秒/s=250ms, 0.125秒/s=125ms, 0.1秒/s=100ms
}

private void 計算()
{
    foreach (var item in PSwavein)//PSwaveinに入れた情報(EEW)の数の分だけ計算します
    {
        item.Counts += 0.89 / 10.0;
        //10.0の変更について timer1でおススメの通りに設定した場合、次のように入力してください
        //1秒/s=1.0 0.5秒/s=2.0 0.25秒/s=4.0 0.125秒/s=8.0 0.1秒/s=10.0 つまり1000(ms)を秒の1000倍で割った値になります。1000(ms)/1000x(m/s)

        //TravelTimeTableConverter.GetValueについては、「走時表をもとに時間と震源の深さからP波とS波の大きさを求めてみよう (ぶっくさん 様)」を使用しています
        (double pwave, double swave) = TravelTimeTableConverter.GetValue((item.Depth), item.Counts);

        pwave = Math.Round(pwave, 4); //四捨五入し、小数点第4位に丸める
        swave = Math.Round(swave, 4);
        item.P = pwave; //PSwaveinにP波とS波の計算結果を入れる
        item.S = swave;
    }
    try
    {
        //P波S波を描画するメソッドを置く 「メソッド名();」
    }
    catch { }
}
                        

ここまでこれば、P波S波の直径距離を求めることができました。


P波S波を描画する

最後に、求めた直径距離を使用し、予報円を描画していきます。描画速度は、「メソッド名();」と置いた部分で実行します。


private readonly object graphicsLock = new object();
private void メソッド名()
{
    Bitmap canvas = new Bitmap(pictureBox.Width, pictureBox.Height);
    using (Graphics g = Graphics.FromImage(canvas))
    {
        lock (graphicsLock)
        {
            g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;

            try
            {
                {
                    //eventListとPSwaveinを合体しそれぞれの要素を2つにマッピングし新しい匿名型のシーケンスを作る
                    var zippedLists = eventList.Zip(PSwavein, (eventItem, psWaveEntryItem) => new { EventItem = eventItem, PsWaveEntryItem = psWaveEntryItem });
                    //情報を入れる
                    foreach (var item in zippedLists)
                    {
                        double radius = item.PsWaveEntryItem.S / 2; // 半径 km 
                        double Pradius = item.PsWaveEntryItem.P / 2; // 半径 km

                        // 緯度経度から画面座標を求める
                        double px = ((double.Parse(item.EventItem.EpiLon) * 0.79 - QCenterLon) * QZoom) + QXcenter;
                        double py = ((QCenterLat - double.Parse(item.EventItem.EpiLat)) * QZoom) + (QYcenter);

                        //描画に直径として使う2つの要素を格納
                        float diameter = (float)(radius * QZoom / 2.4 / 10.0); // 直径(px)
                        float Pdiameter = (float)(Pradius * QZoom / 2.4 / 10.0); // 直径(px)

                        //描画
                        Pen pen = new Pen(Color.FromArgb(230, 100, 100), 2);
                        Pen penfo = new Pen(Color.FromArgb(230, 150, 100), 2);
                        Pen Ppen = new Pen(Color.FromArgb(100, 150, 230), 2);
                        {
                            g.FillEllipse(new SolidBrush(Color.FromArgb(20, 230, 100, 100)), (float)(px - diameter / 2f), (float)(py - diameter / 1.9f), diameter, diameter / 1.0f); // 塗りつぶし
                            g.DrawEllipse(pen, (float)(px - diameter / 2f), (float)(py - diameter / 1.9f), diameter, diameter / 1.0f); // 線の描画
                            g.DrawEllipse(Ppen, (float)(px - Pdiameter / 2f), (float)(py - Pdiameter / 1.9f), Pdiameter, Pdiameter / 1.0f); // 線の描画
                        }
                    }
                }
            }
            catch { }
        }
        //Graphicsオブジェクトの解放
        g.Dispose();
    }
    pictureBox.Image = canvas;
}
                        

記事のミスがない限りは、これでP波S波の描画は出来ました。地図自体の描画については、
WinFormsでGeojsonを描画しようを一部改造し使用しました。
(緯度経度の始点のみで使用するように改造)