WEARの「コーデ予報」を支える観測地点特定アルゴリズム

WEAR の「コーデ予報」を支える観測地点特定アルゴリズム

はじめに

こんにちは、WEARバックエンド部バックエンドブロックの伊藤です。普段は弊社サービスであるWEARのバックエンド開発・保守を担当しています。

WEARでは、天気予報データを活用してその日の天気に合わせたコーディネートを提案する「コーデ予報」機能を提供しています。リリース当初はコーデ予報の地域を一覧から選んで設定する必要がありましたが、2025年1月にユーザーの位置情報をもとにコーデ予報の地点を自動設定する機能をリリースしました。

本記事では、ユーザーの現在地から最寄りのコーデ予報地点を取得するために使用したアルゴリズムの詳細をご紹介します。

目次

コーデ予報とは?

コーデ予報とは、気温や降水確率などの天気情報をもとに、その日のコーディネートを提案する機能です。WEARでは、ウェザーニューズが提供するWxTechの高解像度かつ高精度な「1kmメッシュ体感予報」を活用し、地域ごとの体感指数を取得しています。

体感指数とは、ウェザーニューズに寄せられたユーザーの体感報告と、天気・気温・湿度・風速などの気象データを分析し、「暑い」「寒い」「ちょうどよい」などの人の体感を10ランクに分類した指数です。この体感指数をもとに、「半袖」「長袖」などの袖丈やアイテムカテゴリーのおすすめを判別し、気温や体感に応じたアイテムやコーディネートを提案しています。

weather_coordinates

背景・課題

コーデ予報では、どの地域の天気情報を取得し、コーディネートを提案するかを設定します。リリース当初は、WEARアプリ内の地域設定で、地域一覧から手動で選択する必要がありました。設定できる地域は、各都道府県の代表的な都市を中心に103地点あります。

weather_municipalities

しかし、103地点の中から最寄りの地域を探すのは手間がかかり、直感的に判断するのも難しいという課題がありました。特に、自分の住んでいる地域が候補に含まれていない場合、どの地点を選べばよいのか迷ってしまうこともありました。その結果、地域設定するユーザーが限られ、十分に活用されないケースもありました。

そこで、位置情報を利用して最寄りの地域を自動設定する機能を導入し、よりスムーズにコーデ予報を活用できるようにしました。

ユーザーの位置情報から最寄りの地点をどのように特定するか?

103地点の位置情報はウェザーニューズから取得し、ActiveYamlで管理しています。

class WeatherMunicipality < ActiveYaml::Base
  set_filename 'weather_municipalities'

  field :id, type: :integer
  field :name, type: :string
  field :latitude, type: :float
  field :longitude, type: :float
end
# weather_municipalities.yml
- id: 1
  name: 札幌市中央区
  latitude: 43.062638
  longitude: 141.353921
- id: 2
  name: 函館市
  latitude: 41.768702
  longitude: 140.728938
...

また、ユーザーの位置情報はデータベースに保持せず、リクエストごとに取得します。

WEARではSQL Serverを使用しているため、各地点の位置情報をGEOGRAPHY型でデータベースに保持することで、location.STDistanceを用いて地点間の距離を計算できます。

しかし、今回はActiveYamlで各地点の位置情報を管理しているため、Rubyのみを使用してユーザーの位置情報と各地点との距離を計算するアルゴリズムを実装しました。

1.ユーザーの位置情報を基に検索範囲を絞る

ユーザーの現在地から最寄りの地点を探す際、すべての地点との距離を計算するのは非効率です。そこで、まずはユーザーの端末の位置情報をもとに所在する都道府県を特定し、その都道府県内の地点との距離のみを計算する方法を検討しました。

しかし、この方法では、県境付近のユーザーが隣接する県の地点のほうが近い場合でも、都道府県内の地点が優先されて正確に最寄りの地点を特定できない可能性があります。

次に、緯度経度をもとにエリアを均等に分割する「格子メッシュ」を導入し、各地点をあらかじめ格子メッシュに割り当てておく方法を検討しました。ユーザーの位置情報が属する格子メッシュを判定し、その格子メッシュ内の地点との距離のみを計算する仕組みです。

mesh

しかし、この方法でも、格子メッシュの境界付近にいる場合は最寄りの地点を正しく選べない可能性があるため、最適とは言えません。

そこで、バウンディングボックスを導入することにしました。バウンディングボックスとは、対象を囲む最小の矩形(四角形)を指します。通常、最小緯度・最小経度・最大緯度・最大経度の4点で定義されます。

今回は、ユーザーの現在地を中心に、指定した半径(km)の範囲をカバーする緯度経度の範囲をバウンディングボックスとして設定します。

bounding_box

バウンディングボックスの計算方法は以下の通りです。

(1)緯度の変化量

Δlat=distance_kmEARTH_RADIUS×180π

(2)経度の変化量

Δlon=distance_kmEARTH_RADIUS×cos(latitude)×180π

(3)バウンディングボックス

min_lat=latitudeΔlat
max_lat=latitude+Δlat
min_lon=longitudeΔlon
max_lon=longitude+Δlon

ここで、

  • distance_km:ユーザーの現在地からの半径(km)
  • EARTH_RADIUS:地球の平均半径(6371.0km)

Rubyで実装すると、次のようになります。

# 地球の平均半径(km)
EARTH_RADIUS = 6371.0

# @param latitude [Float] 緯度
# @param longitude [Float] 経度
# @param distance_km [Float] 半径(km)
# @return [Hash] 矩形範囲
def bounding_box(latitude:, longitude:, distance_km:)
  delta_lat = distance_km / EARTH_RADIUS * (180 / Math::PI)
  delta_lon = distance_km / (EARTH_RADIUS * Math.cos(latitude * Math::PI / 180)) * (180 / Math::PI)

  {
    min_lat: latitude - delta_lat,
    max_lat: latitude + delta_lat,
    min_lon: longitude - delta_lon,
    max_lon: longitude + delta_lon
  }
end

バウンディングボックス内の地点は、緯度経度の範囲内にある地点を取得することで絞り込めます。以下のように実装できます。

# @param latitude [Float] ユーザーの緯度
# @param longitude [Float] ユーザーの経度
# @param search_radius_km [Float] 検索半径(km)

# バウンディングボックスを定義
box = bounding_box(latitude: latitude, longitude: longitude, distance_km: search_radius_km)

# バウンディングボックス内の地点を取得
weather_municipalities = all.select do |weather_municipality|
  weather_municipality.latitude.between?(box[:min_lat], box[:max_lat]) &&
    weather_municipality.longitude.between?(box[:min_lon], box[:max_lon])
end

この方法により、ユーザーの現在地を中心に指定した半径(km)以内の地点を効率的に絞り込むことができ、県境などに関係なく正確な最寄り地点を特定できます。

また、バウンディングボックス内に地点がない場合は、半径を拡大して再検索することで最寄りの地点を取得できます。地点の数や分布に応じて適切な初期半径を設定し、徐々に拡大することで、効率的に最寄りの地点を見つけることができます。

WEARでは、初期半径を50kmに設定し、50km刻みで半径を広げながら検索し、最大2000kmまで拡張できるようにしました。

2.範囲内の各地点との距離を計算

次に、バウンディングボックス内の各地点とユーザーの位置情報との距離を計算します。

地球上の2点間の距離は、球面上の2点間の最短距離を計算するための数学的公式である「ハーバサインの公式」を使用して求めます。ハーバサインの公式は、以下のように表されます。

(1)a(角度差の二乗和)

a=sin2(Δlat2)+cos(latitude1)×cos(latitude2)×sin2(Δlon2)

(2)c(大円距離の角度)

c=2×atan2(a,1a)

(3)d(実際の地球上の距離)

d=EARTH_RADIUS×c

ここで、

  • Δlat:地点1と地点2の緯度の差(ラジアン単位)
  • Δlon:地点1と地点2の経度の差(ラジアン単位)
  • latitude1:地点1の緯度(ラジアン単位)
  • latitude2:地点2の緯度(ラジアン単位)
  • EARTH_RADIUS:地球の平均半径(6371.0km)

上記をRubyで実装すると、次のようになります。

# 地球の平均半径(km)
EARTH_RADIUS = 6371.0

# @param lat1 [Float] 地点1の緯度(ラジアン単位)
# @param lon1 [Float] 地点1の経度(ラジアン単位)
# @param lat2 [Float] 地点2の緯度(ラジアン単位)
# @param lon2 [Float] 地点2の経度(ラジアン単位)
# @return [Float] 2点間の距離(km)
def haversine_distance(lat1:, lon1:, lat2:, lon2:)
  delta_lat = lat2 - lat1
  delta_lon = lon2 - lon1

  a = (Math.sin(delta_lat / 2)**2) + (Math.cos(lat1) * Math.cos(lat2) * (Math.sin(delta_lon / 2)**2))
  c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

  EARTH_RADIUS * c
end

3.最も近い観測地点を特定

バウンディングボックス内の各地点とユーザーの位置情報との距離をすべて計算し、最小となる観測地点を特定します。

weather_municipalities.min_by do |weather_municipality|
  haversine_distance(
    lat1: latitude * Math::PI / 180,
    lon1: longitude * Math::PI / 180,
    lat2: weather_municipality.latitude * Math::PI / 180,
    lon2: weather_municipality.longitude * Math::PI / 180
  )
end

4. 全体のアルゴリズム

上記の手順をまとめると、次のようになります。

class WeatherMunicipality < ActiveYaml::Base
  set_filename 'weather_municipalities'

  field :id, type: :integer
  field :name, type: :string
  field :latitude, type: :float
  field :longitude, type: :float

  EARTH_RADIUS = 6371.0

  class << self
    # @note 指定した緯度経度に最も近い地点を取得する(検索半径を徐々に広げて地点を探し、max_radius_kmに達した場合はnilを返す)
    # @param latitude [Float] ユーザーの緯度
    # @param longitude [Float] ユーザーの経度
    # @param initial_radius_km [Integer] 検索半径の初期値(km)
    # @param max_radius_km [Integer] 検索半径の最大値(km)
    # @param radius_step_km [Integer] 検索半径のステップ(km)
    # @return [WeatherMunicipality, nil] 最も近い地点
    def nearest(latitude:, longitude:, initial_radius_km: 50, max_radius_km: 2000, radius_step_km: 50)
      search_radius_km = initial_radius_km

      loop do
        # バウンディングボックスを定義
        box = bounding_box(latitude: latitude, longitude: longitude, distance_km: search_radius_km)

        # バウンディングボックス内の地点を取得
        weather_municipalities = all.select do |weather_municipality|
          weather_municipality.latitude.between?(box[:min_lat], box[:max_lat]) &&
            weather_municipality.longitude.between?(box[:min_lon], box[:max_lon])
        end

        if weather_municipalities.any?
          # 地点が1つだけの場合はその地点を返す
          return weather_municipalities.first if weather_municipalities.size == 1

          # 2つ以上の地点がある場合はハーバサインの公式で距離を計算し、最も近い地点を返す
          return weather_municipalities.min_by do |weather_municipality|
            haversine_distance(
              lat1: latitude * Math::PI / 180,
              lon1: longitude * Math::PI / 180,
              lat2: weather_municipality.latitude * Math::PI / 180,
              lon2: weather_municipality.longitude * Math::PI / 180
            )
          end
        end

        # バウンディングボックス内に地点がない場合は、検索半径を広げて再度検索
        search_radius_km += radius_step_km

        # 検索半径が最大値に達した場合は終了
        break if search_radius_km > max_radius_km
      end

      nil
    end

    private

    # @note 緯度経度から指定した距離の矩形範囲を求める
    # @param latitude [Float] 緯度
    # @param longitude [Float] 経度
    # @param distance_km [Float] 半径(km)
    # @return [Hash] 矩形範囲
    def bounding_box(latitude:, longitude:, distance_km:)
      delta_lat = distance_km / EARTH_RADIUS * (180 / Math::PI)
      delta_lon = distance_km / (EARTH_RADIUS * Math.cos(latitude * Math::PI / 180)) * (180 / Math::PI)

      {
        min_lat: latitude - delta_lat,
        max_lat: latitude + delta_lat,
        min_lon: longitude - delta_lon,
        max_lon: longitude + delta_lon
      }
    end

    # @note 2点間の距離をハーバサインの公式で求める
    # @param lat1 [Float] 地点1の緯度(ラジアン単位)
    # @param lon1 [Float] 地点1の経度(ラジアン単位)
    # @param lat2 [Float] 地点2の緯度(ラジアン単位)
    # @param lon2 [Float] 地点2の経度(ラジアン単位)
    # @return [Float] 2点間の距離(km)
    def haversine_distance(lat1:, lon1:, lat2:, lon2:)
      delta_lat = lat2 - lat1
      delta_lon = lon2 - lon1

      a = (Math.sin(delta_lat / 2)**2) + (Math.cos(lat1) * Math.cos(lat2) * (Math.sin(delta_lon / 2)**2))
      c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))

      EARTH_RADIUS * c
    end
  end
end

動作確認

上記のアルゴリズムを実装し、バウンディングボックス内に6地点が含まれるケースで、ユーザーの現在地が千葉県・松戸市(緯度:35.750169、経度:139.91816)の場合を検証しました。

example

この場合、最寄りの地点として東京都・千代田区が返却されることを期待します。

以下が実行結果です。

❯ rails c
[1] pry(main)> require 'benchmark'
=> false
[2] pry(main)>
[3] pry(main)> # 千葉県松戸市の緯度経度
[4] pry(main)> lat,lon = 35.750169,139.91816
=> [35.750169, 139.91816]
[5] pry(main)> nearest = nil
=> nil
[6] pry(main)>
[7] pry(main)> time = Benchmark.realtime do
[7] pry(main)*   nearest = WeatherMunicipality.nearest(lat, lon)
[7] pry(main)* end
=> 0.00025700032711029053
[8] pry(main)>
[9] pry(main)> puts "最寄りの地点: #{nearest.region.region_name} #{nearest.name}\t実行時間: #{time} seconds"
最寄りの地点: 東京都 千代田区   実行時間: 0.00025700032711029053 seconds
=> nil

想定通り、最寄りの地点として東京都・千代田区が返却されました。実行時間は0.000257秒と高速に処理されています。

リリース後

上記のアルゴリズムを導入し、2025年1月にユーザーの位置情報から最寄りのコーデ予報地点を選択できる機能を無事にリリースしました。

地域設定のUX向上により、地域登録数がリリース前の6〜7倍に増加し、大きな成果を得られました。

また、APIのレイテンシは平均20ms程度と高速に処理されており、リソース使用状況も安定しているため、ユーザーがストレスなく利用できる状態を維持しています。

latency

まとめ

本記事では、WEARのコーデ予報機能において、ユーザーの位置情報から最寄りの地点を取得するアルゴリズムについて紹介しました。

まず、地点間の距離を効率よく計算するために、バウンディングボックスを使って候補を絞り、その後、ハーバサインの公式で正確な距離を算出しました。この方法により、高速かつ正確に最寄りの地点を特定できました。

WEARでの実装例を紹介しましたが、データの保持方法や地点数、サービスの特性に応じて最適なアルゴリズムは異なります。WEARの事例が参考になれば幸いです。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co

カテゴリー