30 December 2025, Tuesday

ephem Python packageを用いた、標高による日出没時刻のズレに関する考察

Pythonで日出・日没の時刻を計算させていて、ふと思ったのは山の上での時刻も同じだろうか...。そもそも、タワマンの標高100m以上の場所にある自宅の時刻も同じなのだろうか。

大気屈折を考慮しない、幾何学的 伏角

地球の半径をR(m), 観測地点の標高をh(m)とすると、水平線は「水平」より下に見えることになり、水平位置からの角度を伏角(θ)が存在する。

20251230-dip-wo-refrection.png

20251230-woref-eq01.png

20251230-woref-eq02.png

表計算ソフトやPythonなどではこの数式があれば十分だが、学術論文などではこれを近似式にしていて、それを導いてみる。

20251230-woref-eq03.png において θ が極めて小さく、 R >> h である場合、辺の両側をテイラー展開で簡略化できる。

まず左辺の cos θ から。θがゼロ近傍の場合は特にマクローリン展開といい

20251230-woref-eq04.png

大幅に簡略化すると

20251230-woref-eq05.png

次に右辺の 20251230-woref-eq06.png について

20251230-woref-eq07.png

ここで (1 + x)^n について xがゼロ近傍の場合も特にマクローリン展開ができて

20251230-woref-eq08.png

20251230-woref-eq093.png

となって、大幅に簡略化すると

20251230-woref-eq10.png

n = -1 のときは

20251230-woref-eq11.png

これを 20251230-woref-eq06.png に適用すると

20251230-woref-eq12.png

最終的に左辺・右辺を合体すると( (1) に (2), (3) を適用する )

20251230-woref-eq01.png

20251230-woref-eq14.png

20251230-woref-eq15.png

ここで R = 6371000 (メートル)を代入すると

20251230-woref-eq16.png (単位: rad)

20251230-woref-eq17.png (単位: 度)

伏角の厳密解と近似解の差を検証する

ここで、空気屈折なしの場合の厳密解「θ = arccos ( R / (R + h) )」と近似解「θ = 0.03211 sqrt(h)」の差をグラフで検証してみる。

20251230-verify-dip.jpg

国内の山で最も高い富士山の山頂での誤差率は0.025%と算出され、近似解は十分実用的と思われる。

--- 標高 3776m (富士山) での結果 ---
厳密解: 1.972162 度
近似解: 1.972649 度
誤差率: 0.024692 %
誤差率のグラフを描画するPythonスクリプト
import numpy as np
import matplotlib.pyplot as plt


def verify_dip_error():
    # --- 定数設定 ---
    R = 6371000.0  # 地球の平均半径 (m)
    h = np.linspace(1, 4000, 500)  # 標高 1m から 4000m まで (0除算回避のため1から)

    # --- 1. 厳密解の計算 (ラジアン) ---
    # theta = arccos(R / (R + h))
    theta_exact_rad = np.arccos(R / (R + h))

    # --- 2. 近似解の計算 (ラジアン) ---
    # theta = sqrt(2h / R)
    theta_approx_rad = np.sqrt(2 * h / R)

    # --- 単位変換 (ラジアン -> 度) ---
    theta_exact_deg = np.degrees(theta_exact_rad)
    theta_approx_deg = np.degrees(theta_approx_rad)

    # --- 3. 誤差率の計算 (%) ---
    # 誤差率 = |(近似 - 厳密) / 厳密| * 100
    error_rate = np.abs((theta_approx_deg - theta_exact_deg) / theta_exact_deg) * 100

    # --- グラフ描画 ---
    fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 10), sharex=True)
    fig.suptitle("伏角計算における厳密解と近似解の比較検証", fontsize=12)

    # 上段:角度の比較
    ax1.plot(h, theta_exact_deg, label="厳密解: $\\arccos(R/(R+h))$", color="black", linewidth=2)
    ax1.plot(
        h,
        theta_approx_deg,
        label="近似解: $\\sqrt{2h/R}$",
        color="red",
        linestyle="--",
        linewidth=1.5)
    ax1.set_ylabel("伏角 $\\theta$ (度)")
    ax1.set_title("標高に対する伏角の変化")
    ax1.legend()
    ax1.grid(True, which='both', linestyle='--', alpha=0.5)

    # 下段:誤差率
    ax2.plot(h, error_rate, color="blue", linewidth=1.5)
    ax2.set_xlabel("標高 $h$ (m)")
    ax2.set_ylabel("誤差率 (%)")
    ax2.set_title("厳密解に対する近似解の誤差率")
    ax2.grid(True, which='both', linestyle='--', alpha=0.5)

    # 特定の地点(富士山頂付近)の値を表示
    fuji_h = 3776
    fuji_exact = np.degrees(np.arccos(R / (R + fuji_h)))
    fuji_approx = np.degrees(np.sqrt(2 * fuji_h / R))
    fuji_error = np.abs((fuji_approx - fuji_exact) / fuji_exact) * 100

    print(f"--- 標高 {fuji_h}m (富士山) での結果 ---")
    print(f"厳密解: {fuji_exact:.6f} 度")
    print(f"近似解: {fuji_approx:.6f} 度")
    print(f"誤差率: {fuji_error:.6f} %")

    plt.tight_layout(rect=[0, 0, 1, 0.96])
    plt.show()


if __name__ == "__main__":
    verify_dip_error()

大気屈折を考慮した 伏角

国立天文台の論文「日出入時刻計算における標高の効果について」によれば、大気の層により光が屈折して、水平線は次の図のようになる(らしい)。

20251230-dip-w-refrection.png

国立天文台の論文で示された解法によれば

20251230-wref-eq01.png

(∮に比例する屈折の係数をβ≒0.077とする)

20251230-wref-eq02.png

θと∮が極めて小さい場合は、次の上の式のように近似できる。また、屈折した光の経路の接線の関係より、次の下の式が成り立つ。

20251230-wref-eq03.png

20251230-wref-eq04.png

ここで R = 6371000 (メートル), β = 0.077 を代入すると

20251230-wref-eq05.png (単位: rad)

20251230-wref-eq06.png (単位: 度)

大気屈折がある場合と無い場合の、日出・日没の時刻をPython ephemで計算すると

標高と日出・日没時刻のグラフを描画するPythonスクリプト
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys
import ephem
import math
import numpy as np
import matplotlib.pyplot as plt
from datetime import datetime, timedelta
from matplotlib.ticker import FuncFormatter
from typing import List, Dict, Tuple

# =================================================================
# グローバル設定変数 (デフォルト値を変えるには、ここを編集してください)
# =================================================================
TARGET_LATITUDE_DEG = 34.67
TARGET_LONGITUDE_DEG = 135.50
MAX_HEIGHT = 4000.0
# =================================================================


def get_user_inputs_for_single_day() -> Tuple[float, float, datetime]:
    """計算対象の地点と年月日をユーザから取得する。

    Returns:
        Tuple[float, float, datetime]: 以下の要素を含むタプル。
            - lat (float): 緯度(度)。
            - lon (float): 経度(度)。
            - target_date (datetime): 計算対象日のdatetimeオブジェクト。
    """
    # 1. 緯度・経度
    loc_prompt = f"緯度,経度 を入力してください (例: 35.68,139.76) [デフォルト: {TARGET_LATITUDE_DEG},{TARGET_LONGITUDE_DEG}]: "
    loc_in = input(loc_prompt).strip()

    if not loc_in:
        lat, lon = TARGET_LATITUDE_DEG, TARGET_LONGITUDE_DEG
    else:
        try:
            parts = [p.strip() for p in loc_in.split(',')]
            lat, lon = float(parts[0]), float(parts[1])
        except (ValueError, IndexError):
            print("入力形式エラー。デフォルト値を使用します。")
            lat, lon = TARGET_LATITUDE_DEG, TARGET_LONGITUDE_DEG

    # 2. 対象日
    date_str = input("計算対象年月日を入力してください (yyyy/mm/dd): ")
    try:
        target_date = datetime.strptime(date_str, "%Y/%m/%d")
    except ValueError:
        print("入力形式が正しくありません。本日を設定します。")
        target_date = datetime.now()

    return lat, lon, target_date


def calculate_sun_times_by_elevation(
    lat: float,
    lon: float,
    elevations: np.ndarray,
    target_date: datetime,
    with_refraction: bool
) -> Tuple[List[float], List[float]]:
    """標高の配列に基づき、それぞれの日の出・日の入り時刻を計算する。

    Args:
        lat (float): 観測地点の緯度(度)。
        lon (float): 観測地点の経度(度)。
        elevations (np.ndarray): 計算対象の標高(m)の配列。
        target_date (datetime): 計算対象日。
        with_refraction (bool): 大気屈折を考慮する場合はTrue。

    Returns:
        Tuple[List[float], List[float]]:
            - rise_times: 標高ごとの日の出時刻リスト(時間単位float)。
            - set_times: 標高ごとの日の入り時刻リスト(時間単位float)。
    """
    obs = ephem.Observer()
    obs.lat = str(lat)
    obs.lon = str(lon)

    rise_times = []
    set_times = []
    jst_offset = timedelta(hours=9)

    for elev in elevations:
        obs.elevation = float(elev)
        # 標高補正(伏角の計算)
        # https://www.nao.ac.jp/contents/about/reports/report-naoj/p91.pdf
        # 幾何学的眼高差: dip = (1.926/60)*sqrt(elevation)    ... (6)
        # 大気中の屈折を考慮した補正: dip = (1.77/60)*sqrt(elevation)    ... (15)
        if with_refraction:
            dip = 0.0293 * math.sqrt(elev)
        else:
            dip = 0.032 * math.sqrt(elev)

        # obs.horizon = -math.radians(dip)    # floatで渡す場合の単位は radian
        obs.horizon = str(-dip)             # stringで渡す場合の単位は degree
        obs.date = target_date - jst_offset
        sun = ephem.Sun()  # type: ignore[reportAttributeAccessIssue]

        try:
            # 日の出
            rise_t = obs.next_rising(sun).datetime() + jst_offset
            rise_times.append(rise_t.hour + rise_t.minute / 60.0 + rise_t.second / 3600.0)
            # 日没
            set_t = obs.next_setting(sun).datetime() + jst_offset
            set_times.append(set_t.hour + set_t.minute / 60.0 + set_t.second / 3600.0)
        except (ephem.AlwaysUpError, ephem.NeverUpError):
            rise_times.append(np.nan)
            set_times.append(np.nan)

    return rise_times, set_times


def plot_elevation_vs_time(
    lat: float,
    lon: float,
    target_date: datetime,
    elevations: np.ndarray,
    results: Dict
) -> None:
    """縦軸を標高、横軸を時刻とした日出・日入グラフを描画する。

    Args:
        lat (float): 緯度。
        lon (float): 経度。
        target_date (datetime): 対象年月日。
        elevations (np.ndarray): 標高データの配列。
        results (Dict): 大気屈折の有無をキーとした計算結果。
    """
    fig, (ax_rise, ax_set) = plt.subplots(2, 1, figsize=(10, 12))
    date_str = target_date.strftime('%Y/%m/%d')
    fig.suptitle(f"標高別 日出・日入時刻の変化\n({date_str}, 緯度:{lat}°, 経度:{lon}°)", fontsize=14)

    # 時刻用フォーマッタ (HH:MM)
    time_fmt = FuncFormatter(lambda y, p: f"{int(y):02d}:{int(round((y - int(y)) * 60)) % 60:02d}")

    # プロット実行
    # 日出 (上段)
    ax_rise.plot(results['with_ref']['rise'], elevations, 'b-', label='大気屈折あり')
    ax_rise.plot(results['without_ref']['rise'], elevations, 'b--', label='大気屈折なし')
    ax_rise.set_title("日の出時刻 (JST)")

    # 日入 (下段)
    ax_set.plot(results['with_ref']['set'], elevations, 'r-', label='大気屈折あり')
    ax_set.plot(results['without_ref']['set'], elevations, 'r--', label='大気屈折なし')
    ax_set.set_title("日の入り時刻 (JST)")

    # グラフの共通設定
    for ax, key in zip([ax_rise, ax_set], ['rise', 'set']):
        ax.set_ylabel("標高 (m)")
        ax.set_xlabel("時刻")
        ax.xaxis.set_major_formatter(time_fmt)
        ax.grid(True, linestyle='--', alpha=0.6)
        ax.legend()

        # 横軸レンジの自動調整(データの最小最大±5%)
        all_vals = results['with_ref'][key] + results['without_ref'][key]
        clean_vals = [v for v in all_vals if not np.isnan(v)]
        if clean_vals:
            v_min, v_max = min(clean_vals), max(clean_vals)
            margin = max((v_max - v_min) * 0.05, 0.02)  # 最小でも1分程度の幅
            ax.set_xlim(v_min - margin, v_max + margin)

    plt.tight_layout(rect=[0, 0, 1, 0.95])
    print("グラフを表示します...")
    plt.show()


def format_hour_to_hms(hour_float: float) -> str:
    """時間(float形式)を HH:MM:SS 形式の文字列に変換する。

    0.5 を入力すると "00:30:00"、14.75 を入力すると "14:45:00" のように、
    小数部分を分・秒に換算して整形した文字列を返す。数値が NaN の場合は、
    時刻が定義できないものとして "--:--:--" を返す。

    Args:
        hour_float (float): 時間単位の数値(例: 12.5 は 12時30分)。

    Returns:
        str: HH:MM:SS 形式の時刻文字列。計算不能な場合は "--:--:--"。
    """
    if np.isnan(hour_float):
        return "--:--:--"
    total_seconds = int(round(hour_float * 3600))
    h, remainder = divmod(total_seconds, 3600)
    m, s = divmod(remainder, 60)
    return f"{h:02d}:{m:02d}:{s:02d}"


def print_text_results(
        lat: float,
        lon: float,
        target_date: datetime,
        elevations: np.ndarray,
        results: Dict) -> None:
    """計算結果をコンソールに表形式で出力する。

    指定された地点、日付における標高ごとの日の出・日の入り時刻を、
    大気屈折の影響の有無を並べて比較しやすいテーブル形式で表示する。

    Args:
        lat (float): 観測地点の緯度。
        lon (float): 観測地点の経度。
        target_date (datetime): 計算対象日。
        elevations (np.ndarray): 計算を行った標高(m)の配列。
        results (Dict): 以下の構造を持つ計算結果の辞書。
            - 'with_ref': {'rise': List[float], 'set': List[float]}
            - 'without_ref': {'rise': List[float], 'set': List[float]}

    Returns:
        None: 結果を直接標準出力(コンソール)に表示する。
    """
    date_str = target_date.strftime('%Y/%m/%d')
    header = "  標高  | 日出(屈折有) | 日出(屈折無) | 日入(屈折有) | 日入(屈折無)"
    line = "--------------------------------------------------------------------"
    print(line)
    print(f" 計算結果: {date_str} (緯度:{lat}, 経度:{lon})")
    print(line)
    print(header)
    print(line)
    for i, elev in enumerate(elevations):
        r_w = format_hour_to_hms(results['with_ref']['rise'][i])
        r_wo = format_hour_to_hms(results['without_ref']['rise'][i])
        s_w = format_hour_to_hms(results['with_ref']['set'][i])
        s_wo = format_hour_to_hms(results['without_ref']['set'][i])
        print(f"{int(elev):5d}m  | {r_w:10}   | {r_wo:10}   | {s_w:10}   | {s_wo:10}")
    print(line)


def main() -> None:
    """スクリプトのメイン処理。"""
    lat, lon, target_date = get_user_inputs_for_single_day()

    ans = input("計算結果の出力方法を、 グラフを表示 (g), テキスト表示 (t) のいずれにしますか? (g / t): ")

    if ans.lower() == 'g':
        # 標高 0m から MAX_HEIGHT までを100分割して計算(曲線用)
        elevations = np.linspace(0, MAX_HEIGHT, 101)
    elif ans.lower() == 't':
        # 0からMAX_HEIGHTまで100mごとの値を入れる
        elevations = np.arange(0, MAX_HEIGHT + 1, 100)
    else:
        print("無効な選択です。プログラムを終了します。")
        sys.exit()

    print(f"{target_date.strftime('%Y/%m/%d')} のデータを標高 {int(MAX_HEIGHT)}m まで計算中...")

    # 計算実行
    r_w, s_w = calculate_sun_times_by_elevation(lat, lon, elevations, target_date, True)
    r_wo, s_wo = calculate_sun_times_by_elevation(lat, lon, elevations, target_date, False)

    results = {
        'with_ref': {'rise': r_w, 'set': s_w},
        'without_ref': {'rise': r_wo, 'set': s_wo}
    }

    if ans.lower() == 'g':
        # グラフを表示
        plot_elevation_vs_time(lat, lon, target_date, elevations, results)
    elif ans.lower() == 't':
        # テキスト表示
        print_text_results(lat, lon, target_date, elevations, results)


if __name__ == "__main__":
    main()

20251230-sunrise-sunset.jpg


たとえば、3,000mの稜線上の山小屋から見る日の出は、標高 0 m の地上に比べて、大気屈折なしの場合は 13分 早く、大気屈折を考慮した場合は 12分 早い。

  標高  | 日出(屈折有) | 日出(屈折無) | 日入(屈折有) | 日入(屈折無)
--------------------------------------------------------------------
    0m  | 07:04:45     | 07:04:45     | 16:58:07     | 16:58:07  
  100m  | 07:02:42     | 07:02:31     | 17:00:10     | 17:00:22  
  200m  | 07:01:50     | 07:01:34     | 17:01:02     | 17:01:19  
  300m  | 07:01:10     | 07:00:49     | 17:01:43     | 17:02:03  
  400m  | 07:00:36     | 07:00:12     | 17:02:17     | 17:02:41  
  500m  | 07:00:05     | 06:59:38     | 17:02:48     | 17:03:15  
  600m  | 06:59:37     | 06:59:07     | 17:03:16     | 17:03:45  
  700m  | 06:59:11     | 06:58:39     | 17:03:42     | 17:04:14  
  800m  | 06:58:47     | 06:58:12     | 17:04:06     | 17:04:41  
  900m  | 06:58:24     | 06:57:47     | 17:04:29     | 17:05:06  
 1000m  | 06:58:02     | 06:57:22     | 17:04:51     | 17:05:30  
 1100m  | 06:57:41     | 06:56:59     | 17:05:11     | 17:05:53  
 1200m  | 06:57:21     | 06:56:37     | 17:05:31     | 17:06:15  
 1300m  | 06:57:02     | 06:56:16     | 17:05:51     | 17:06:36  
 1400m  | 06:56:43     | 06:55:56     | 17:06:09     | 17:06:57  
 1500m  | 06:56:25     | 06:55:36     | 17:06:28     | 17:07:17  
 1600m  | 06:56:07     | 06:55:16     | 17:06:45     | 17:07:36  
 1700m  | 06:55:50     | 06:54:58     | 17:07:02     | 17:07:55  
 1800m  | 06:55:34     | 06:54:39     | 17:07:19     | 17:08:13  
 1900m  | 06:55:18     | 06:54:22     | 17:07:35     | 17:08:31  
 2000m  | 06:55:02     | 06:54:04     | 17:07:51     | 17:08:48  
 2100m  | 06:54:46     | 06:53:47     | 17:08:06     | 17:09:05  
 2200m  | 06:54:31     | 06:53:31     | 17:08:21     | 17:09:22  
 2300m  | 06:54:17     | 06:53:15     | 17:08:36     | 17:09:38  
 2400m  | 06:54:02     | 06:52:59     | 17:08:50     | 17:09:54  
 2500m  | 06:53:48     | 06:52:44     | 17:09:05     | 17:10:09  
 2600m  | 06:53:34     | 06:52:28     | 17:09:18     | 17:10:24  
 2700m  | 06:53:21     | 06:52:14     | 17:09:32     | 17:10:39  
 2800m  | 06:53:07     | 06:51:59     | 17:09:45     | 17:10:53  
 2900m  | 06:52:54     | 06:51:45     | 17:09:58     | 17:11:07  
 3000m  | 06:52:41     | 06:51:31     | 17:10:11     | 17:11:21  
 3100m  | 06:52:29     | 06:51:18     | 17:10:24     | 17:11:35  
 3200m  | 06:52:16     | 06:51:05     | 17:10:36     | 17:11:48  
 3300m  | 06:52:04     | 06:50:52     | 17:10:48     | 17:12:01  
 3400m  | 06:51:52     | 06:50:39     | 17:11:00     | 17:12:13  
 3500m  | 06:51:40     | 06:50:27     | 17:11:12     | 17:12:26  
 3600m  | 06:51:29     | 06:50:15     | 17:11:24     | 17:12:38  
 3700m  | 06:51:18     | 06:50:03     | 17:11:35     | 17:12:50  
 3800m  | 06:51:07     | 06:49:51     | 17:11:46     | 17:13:01  
 3900m  | 06:50:56     | 06:49:40     | 17:11:57     | 17:13:13  
 4000m  | 06:50:45     | 06:49:29     | 17:12:08     | 17:13:24  
--------------------------------------------------------------------

ここまでの結論で、矛盾点を感じるのは私だけではないはずだ。

Python ephemの天文計算では、伏角だけが計算結果に影響を及ぼし、大気により光の経路が曲げられている影響は全く考慮されない。

大気により光の経路が曲げられれば、「水平線はより遠く」になり、「水平線の向こうの天球面はもっと地表下まで」見えているはずだ。

Google Gemini AIにこれを質問すると次のような回答が得られる。

あなたのご指摘は、**「計算式で地平線が浮き上がった(伏角が小さくなった)数値を使うと、逆に日の出が遅くなるという矛盾」**を見事に突いています。

もし「屈折によってより遠くが見える効果」を正しくシミュレーションしたいのであれば、horizon に設定すべき値は**「屈折によって到達可能になった、より深い地平線の角度」**でなければなりません。

計算式としては、屈折を考慮した際に「見かけの伏角が小さくなる(=地平線が上がる)」のは事実ですが、それは「同じ場所にある水平線」を見た時の話です。実際には「より遠くの水平線」が見えるようになるため、観測可能な地表の範囲は広がります。

単純に obs.horizon = str(-dip) に伏角を設定するだけでは駄目だということだ。