はじめに
ITQ GNSSロガーでPixel 8(Android)とiPhone 12/13 Pro(iOS)を同時測定したところ、標高値に一貫した+35m前後の差が全測定環境で観測された。
| 測定環境 | Pixel 8 標高平均 | iPhone 標高平均 | オフセット |
|---|---|---|---|
| 京急電車 川崎→鶴見 | 43.8 m | 4.8 m | +39.0 m |
| 東海道線 横浜→川崎 | 24.9 m | 5.9 m | +19.0 m |
| 東海道線 川崎→新橋 | 49.4 m | 9.4 m | +40.0 m |
| 歩行 京急川崎→鶴見 | 40.0 m | 5.3 m | +34.7 m |
| 東扇島東公園(開放空間) | 37.5 m | 5.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 m | 4.0 m | 5.3 m | −1.3 m |
| 東扇島東公園A | 37.7 m | 1.7 m | 6.0 m | −4.3 m |
| 東扇島東公園B | 37.4 m | 1.4 m | 4.6 m | −3.2 m |
補正後はiPhoneの値と数m以内に収まっており、EGM96(精度±1〜3m)の適用として妥当な結果。
まとめ
iOS altitude | Android getAltitude() | Android getMslAltitudeMeters()(API 34+) | |
|---|---|---|---|
| 返す値 | MSL高度 | WGS84楕円体高 | MSL高度 |
| 補正 | Core Location内部処理 | なし | GPSチップ提供 |
| 利用可能条件 | 常に | 常に | API 34 + チップ対応 |
Androidで正しい標高を取得するには:
- API 34+なら
hasMslAltitude()を確認してgetMslAltitudeMeters()を使う - それ以外はEGM96ジオイドモデルで
getAltitude() - geoidHeightを計算する hasMslAltitude()がfalseのケースに必ずフォールバックを用意する