【Android/Kotlin】Location.getAltitude()はMSLを返さない:楕円体高補正の実装ガイド

はじめに

ITQ GNSSロガーでPixel 8(Android)とiPhone 12/13 Pro(iOS)を同時測定したところ、標高値に一貫した+35m前後の差が全測定環境で観測された。

測定環境Pixel 8 標高平均iPhone 標高平均オフセット
京急電車 川崎→鶴見43.8 m4.8 m+39.0 m
東海道線 横浜→川崎24.9 m5.9 m+19.0 m
東海道線 川崎→新橋49.4 m9.4 m+40.0 m
歩行 京急川崎→鶴見40.0 m5.3 m+34.7 m
東扇島東公園(開放空間)37.5 m5.3 m+32.2 m

同じ場所・同じタイミングで平均35m以上の差がある。

川崎エリアのジオイド高(EGM96)は約36m。この数字と見事に一致する。原因はAPIの設計差にある。

楕円体高とMSL高度:GPSが実際に計測しているもの

GNSSが直接計測するのは「WGS84楕円体面からの距離」= 楕円体高(Ellipsoidal Height / HAE) だ。

MSL高度(h) = 楕円体高(H) − ジオイド高(N)

MSL高度(h) = 楕円体高(H) − ジオイド高(N)
  • 楕円体高(H):GNSSが直接測定する値。WGS84楕円体面からの距離。
  • ジオイド高(N):楕円体面からジオイド面(≈平均海面)までの距離。日本では約20〜50m。
  • MSL高度(h):「海抜〇m」のこと。地形図・登山・気象・航空で使われる「高さ」の基準。

川崎エリアでは N ≈ 36m なので、楕円体高から36を引くとMSL高度になる。Pixel 8の値からiPhoneの値を引いた差がほぼ36mになっていたのはこれが理由だ。

NMEA GGA文とジオイド高フィールド

GPS受信機のNMEA GGA文には実はジオイド高フィールドが含まれている。

$GPGGA,123519,3547.891,N,13948.219,E,1,08,0.9,45.6,M,36.3,M,,*47 ↑MSL高度 ↑ジオイド高

$GPGGA,123519,3547.891,N,13948.219,E,1,08,0.9,45.6,M,36.3,M,,*47
                                              ↑MSL高度  ↑ジオイド高

フィールド9が「アンテナ高度(MSL)」、フィールド11が「ジオイド高」。GPSチップはジオイド高を知っているのに、AndroidがAPIレベルで隠していた。Android 14でようやく公開された。

iOSとAndroidの設計の違い

項目iOS(Core Location)Android(Location API)
altitude / getAltitude()MSL高度(ジオイド補正済み)WGS84楕円体高(補正なし)
楕円体高を取得ellipsoidalAltitude(iOS 15+)getAltitude()
MSL高度を取得altitude(初期から)getMslAltitudeMeters()API 34+のみ
ジオイドモデルCore Locationが内部適用開発者が実装する必要あり

iOSはCore Locationが内部でジオイド補正を行い、altitudeはMSL高度を返す。Androidは楕円体高をそのまま返す。どちらが正しいかという話ではなく、APIの契約が異なる

⚠️ GeomagneticFieldはジオイド補正とは無関係android.hardware.GeomagneticFieldは磁気偏角(磁北と真北のズレ)を計算するクラスであり、ジオイド高(標高補正)とは直接無関係。ジオイド計算と地磁気計算はどちらも球面調和関数を用いるため混同されることがあるが、標高補正に使えるAPIではない。

解決策

① Android 14(API 34)の新API

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { if (location.hasMslAltitude()) { val mslAltitude = location.mslAltitudeMeters val accuracy = location.mslAltitudeAccuracyMeters // 1σ精度 } }

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    if (location.hasMslAltitude()) {
        val mslAltitude = location.mslAltitudeMeters
        val accuracy = location.mslAltitudeAccuracyMeters // 1σ精度
    }
}

⚠️ hasMslAltitude()false を返すことがある:API 34でも、GPSチップがMSL高度の提供に非対応の場合は hasMslAltitude()false を返す。必ずフォールバックを実装すること。

② EGM96ジオイドモデルによるオフライン補正

EGM96の15分グリッドデータ(721×1441点、約1.8MB)をassetsに同梱してオフラインでジオイド高を計算する。

EGM96Geoid.kt

import android.content.Context import java.nio.ByteBuffer import java.nio.ByteOrder

object EGM96Geoid { private const val NROWS = 721 // 90°S〜90°N、0.25°刻み private const val NCOLS = 1441 // 0°〜360°、0.25°刻み private const val STEP = 0.25

private var data: ShortArray? = null

fun init(context: Context) {
    if (data != null) return
    val bytes = context.assets.open("egm96\_15min.bin").readBytes()
    val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG\_ENDIAN)
    val shorts = ShortArray(NROWS \* NCOLS)
    buf.asShortBuffer().get(shorts)
    data = shorts
}

/\*\*
 \* EGM96ジオイド高を返す(単位: m)
 \* lat: 緯度 \[-90, 90\]、lon: 経度 \[-180, 180\]
 \*/
fun getGeoidHeight(lat: Double, lon: Double): Double {
    val d = data ?: error("EGM96Geoid: call init() first")
    val normLon = if (lon < 0) lon + 360.0 else lon
    val row = (90.0 - lat) / STEP
    val col = normLon / STEP
    val r0 = row.toInt().coerceIn(0, NROWS - 2)
    val c0 = col.toInt() % NCOLS
    val dr = row - r0
    val dc = col - c0.toDouble()
    val c1 = (c0 + 1) % NCOLS
    fun g(r: Int, c: Int) = d\[r \* NCOLS + c\] / 100.0  // 格納値はcm
    return g(r0, c0) \* (1 - dr) \* (1 - dc) +
           g(r0, c1) \* (1 - dr) \* dc +
           g(r0 + 1, c0) \* dr \* (1 - dc) +
           g(r0 + 1, c1) \* dr \* dc
}

}

import android.content.Context
import java.nio.ByteBuffer
import java.nio.ByteOrder

object EGM96Geoid {
    private const val NROWS = 721   // 90°S〜90°N、0.25°刻み
    private const val NCOLS = 1441  // 0°〜360°、0.25°刻み
    private const val STEP = 0.25

    private var data: ShortArray? = null

    fun init(context: Context) {
        if (data != null) return
        val bytes = context.assets.open("egm96_15min.bin").readBytes()
        val buf = ByteBuffer.wrap(bytes).order(ByteOrder.BIG_ENDIAN)
        val shorts = ShortArray(NROWS * NCOLS)
        buf.asShortBuffer().get(shorts)
        data = shorts
    }

    /**
     * EGM96ジオイド高を返す(単位: m)
     * lat: 緯度 [-90, 90]、lon: 経度 [-180, 180]
     */
    fun getGeoidHeight(lat: Double, lon: Double): Double {
        val d = data ?: error("EGM96Geoid: call init() first")
        val normLon = if (lon < 0) lon + 360.0 else lon
        val row = (90.0 - lat) / STEP
        val col = normLon / STEP
        val r0 = row.toInt().coerceIn(0, NROWS - 2)
        val c0 = col.toInt() % NCOLS
        val dr = row - r0
        val dc = col - c0.toDouble()
        val c1 = (c0 + 1) % NCOLS
        fun g(r: Int, c: Int) = d[r * NCOLS + c] / 100.0  // 格納値はcm
        return g(r0, c0) * (1 - dr) * (1 - dc) +
               g(r0, c1) * (1 - dr) * dc +
               g(r0 + 1, c0) * dr * (1 - dc) +
               g(r0 + 1, c1) * dr * dc
    }
}

AltitudeUtils.kt

import android.location.Location import android.os.Build

object AltitudeUtils {

enum class Source { API34, EGM96, UNAVAILABLE }

data class MslAltitudeResult(
    val altitudeMeters: Double,
    val source: Source
)

fun getMslAltitude(location: Location): MslAltitudeResult? {
    // ① Android 14+: システム提供のMSL高度を優先
    if (Build.VERSION.SDK\_INT >= Build.VERSION\_CODES.UPSIDE\_DOWN\_CAKE
        && location.hasMslAltitude()) {
        return MslAltitudeResult(location.mslAltitudeMeters, Source.API34)
    }
    // ② EGM96ジオイドモデルでフォールバック
    if (location.hasAltitude()) {
        val geoidHeight = EGM96Geoid.getGeoidHeight(
            location.latitude,
            location.longitude
        )
        return MslAltitudeResult(
            location.altitude - geoidHeight,
            Source.EGM96
        )
    }
    return null
}

}

import android.location.Location
import android.os.Build

object AltitudeUtils {

    enum class Source { API34, EGM96, UNAVAILABLE }

    data class MslAltitudeResult(
        val altitudeMeters: Double,
        val source: Source
    )

    fun getMslAltitude(location: Location): MslAltitudeResult? {
        // ① Android 14+: システム提供のMSL高度を優先
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
            && location.hasMslAltitude()) {
            return MslAltitudeResult(location.mslAltitudeMeters, Source.API34)
        }
        // ② EGM96ジオイドモデルでフォールバック
        if (location.hasAltitude()) {
            val geoidHeight = EGM96Geoid.getGeoidHeight(
                location.latitude,
                location.longitude
            )
            return MslAltitudeResult(
                location.altitude - geoidHeight,
                Source.EGM96
            )
        }
        return null
    }
}

使用例(GnssService内)

class GnssService : Service() {

override fun onCreate() {
    super.onCreate()
    EGM96Geoid.init(applicationContext)  // 起動時に1回だけ
}

private val locationCallback = object : LocationCallback() {
    override fun onLocationResult(result: LocationResult) {
        result.locations.forEach { location ->
            val mslResult = AltitudeUtils.getMslAltitude(location)
            val mslStr = mslResult?.let {
                "%.1f m \[%s\]".format(it.altitudeMeters, it.source)
            } ?: "N/A"
            Log.d("GNSS", "MSL高度: $mslStr")
        }
    }
}

}

class GnssService : Service() {

    override fun onCreate() {
        super.onCreate()
        EGM96Geoid.init(applicationContext)  // 起動時に1回だけ
    }

    private val locationCallback = object : LocationCallback() {
        override fun onLocationResult(result: LocationResult) {
            result.locations.forEach { location ->
                val mslResult = AltitudeUtils.getMslAltitude(location)
                val mslStr = mslResult?.let {
                    "%.1f m [%s]".format(it.altitudeMeters, it.source)
                } ?: "N/A"
                Log.d("GNSS", "MSL高度: $mslStr")
            }
        }
    }
}

③ 外部API(参考・非推奨)

国土地理院標高APIやGoogle Maps Elevation API。オフライン不可・リクエストコスト・レイテンシありのため、リアルタイムGNSSロガーには不向き。「地面の標高」を返す点にも注意(GPS高度とは異なる概念)。

補正効果の検証

EGM96補正前後をiPhoneの実測値と比較(川崎エリア、N ≈ 36m):

測定環境補正前(楕円体高)補正後(MSL)iPhone(MSL)補正後差分
歩行 川崎→鶴見40.0 m4.0 m5.3 m−1.3 m
東扇島東公園A37.7 m1.7 m6.0 m−4.3 m
東扇島東公園B37.4 m1.4 m4.6 m−3.2 m

補正後はiPhoneの値と数m以内に収まっており、EGM96(精度±1〜3m)の適用として妥当な結果。

まとめ

iOS altitudeAndroid getAltitude()Android getMslAltitudeMeters()(API 34+)
返す値MSL高度WGS84楕円体高MSL高度
補正Core Location内部処理なしGPSチップ提供
利用可能条件常に常にAPI 34 + チップ対応

Androidで正しい標高を取得するには:

  1. API 34+なら hasMslAltitude() を確認して getMslAltitudeMeters() を使う
  2. それ以外はEGM96ジオイドモデルで getAltitude() - geoidHeight を計算する
  3. hasMslAltitude()false のケースに必ずフォールバックを用意する

参考リンク

← ITQ Lab トップに戻る