top of page
交信ログにDXCC番号を付けるツールは、make_dxcc_adif.py(8,950バイト) です。
 

このツールでできること

Turbo HAMLOGなどから書き出したADIFを読み込み、DXCC代行ワッチ(dxcc_notify.py)とJTAlertで使える形に整えます。

  • 複数のADIFをまとめる:HAMLOGの書き出しと、JTAlertの log.adi などを合わせて1つにし、重複を除く(同じ相手・日付・バンドで、時刻の差が10分以内なら重複とみなす)

  • DXCC番号を付ける:元のログにDXCCの欄があればそれを使い、なければBig CTYの cty.csv で判定して付ける

  • 誤りを直す、または除く:不正な日付(FT8が登場する前のFT8の交信など)は削除、空欄のバンドは周波数から補う、HAMLOG特有のモード名(SSBJ、SSBB、FREE)を標準の名前に直す、バンドと合わない周波数は省く

  • 文字化けを防ぐ:日本語などが入る欄は出力せず、ASCIIの文字だけのファイルにする

  • 文字コードを自動で判定:UTF-8とShift_JIS(HAMLOGの書き出し)のどちらにも対応

使い方の例

python make_dxcc_adif.py --cty-csv common\cty_latest.csv --out logs\hamlog.adi LogList.adi

湯河原のように、HAMLOGの記録とJTAlertの記録を合わせる場合は、入力のファイルを並べて指定します。

python make_dxcc_adif.py --cty-csv common\cty_latest.csv --out logs\yugawara_IC7851.adi LogList.adi log.adi

実行すると表示されるもの

  • 削除・修正した記録と、その理由

  • DXCC番号を付けられなかった記録(入力ミスと思われるコールなど)

  • バンドごとの交信済みエンティティの数:JTAlertの「Rebuild Alert Database」の結果(Needed=340−この数)と照らし合わせて、正しく反映されたかを確かめられます

使うときの注意

  • 国別データ(cty.csv)は、country-files.com から入手したBig CTYのものを使ってください。

  • 書き出したファイルを、JTAlertが書き込んでいるファイル(home_SDR.adi など)に上書きしないでください。「1つのファイルに書き込むのは1台だけ」の決まりを守るため、HAMLOG用には hamlog.adi のような別の名前にします。

  • 1980年より前の日付は、削除せずに「確認」として表示します。念のため、目で確かめてください。

#!/usr/bin/env python3
"""
make_dxcc_adif.py
Turbo HAMLOG などから書き出した ADIF を読み、DXCC代行ワッチ(dxcc_notify.py)と
JTAlert で使える「DXCC番号付き・ASCIIのみ」の ADIF に整えるツール。

  - 複数の ADIF をまとめて読み込み、重複を除いて1つにする
  - 各交信に DXCC番号(ADIFのエンティティ番号)を付ける(Big CTY の cty.csv を使用)
  - 誤りのある記録を直す、または除く(理由は報告に表示)
  - 日本語などの非ASCII文字を含む欄は出力しない(JTAlertでの文字化け・長さのずれを防ぐ)

使い方(例):
  python make_dxcc_adif.py --cty-csv cty_latest.csv --out logs\\home_SDR.adi LogList.adi
  python make_dxcc_adif.py --cty-csv cty_latest.csv --out logs\\home_SDR.adi LogList.adi log.adi

標準ライブラリのみで動作(Python 3.8+)
"""
import argparse
import collections
import re
import sys

BANDS = [('160m', 1.8, 2.0), ('80m', 3.5, 4.0), ('60m', 5.25, 5.45), ('40m', 7.0, 7.3),
         ('30m', 10.1, 10.15), ('20m', 14.0, 14.35), ('17m', 18.068, 18.168), ('15m', 21.0, 21.45),
         ('12m', 24.89, 24.99), ('10m', 28.0, 29.7), ('6m', 50.0, 54.0), ('2m', 144.0, 148.0),
         ('70cm', 420.0, 450.0)]
# Turbo HAMLOG 固有・非標準のモード名 → ADIF のモード
MODE_MAP = {'SSBJ': 'SSB', 'SSBB': 'SSB', 'FREE': 'DIGITALVOICE'}
# デジタルモードの登場時期(これより前の日付は日付の誤りとみなす)
MODE_START = {'FT8': '20170701', 'FT4': '20190401'}
OUT_FIELDS = ['CALL', 'QSO_DATE', 'TIME_ON', 'BAND', 'MODE', 'FREQ', 'GRIDSQUARE', 'DXCC',
              'QSL_RCVD', 'LOTW_QSL_RCVD']


def band_of(mhz):
    for name, lo, hi in BANDS:
        if lo <= mhz <= hi:
            return name
    return None


def read_adif(path):
    """ADIF を読む。長さの数字はバイト数として扱う(Turbo HAMLOG の Shift_JIS 出力に対応)"""
    raw = open(path, 'rb').read()
    try:
        raw.decode('utf-8')
        enc = 'utf-8'
    except UnicodeDecodeError:
        enc = 'cp932'
    m = re.search(rb'<eoh>', raw, re.I)
    body = raw[m.end():] if m else raw
    recs = []
    for r in re.split(rb'<eor>', body, flags=re.I):
        f = {}
        for t in re.finditer(rb'<(\w+):(\d+)(?::\w+)?>', r):
            f[t.group(1).decode().upper()] = r[t.end():t.end() + int(t.group(2))].decode(enc, 'replace').strip()
        if f.get('CALL'):
            recs.append(f)
    return recs, enc


class CtyCsv:
    """Big CTY の cty.csv による最長一致プレフィックス判定('*' のWAE専用エンティティは除外)"""
    STRIP = re.compile(r'\(.*?\)|\[.*?\]|<.*?>|\{.*?\}|~.*?~')
    SUF = {'P', 'M', 'QRP', 'A', 'AM', 'B', 'LH'}

    def __init__(self, path):
        self.pre, self.exact, self.name = {}, {}, {}
        for line in open(path, encoding='latin-1'):
            p = line.rstrip('\r\n;').split(',')
            if len(p) < 10 or p[0].startswith('*') or not p[2].strip().isdigit():
                continue
            num = p[2].strip()
            self.name[num] = p[1].strip()
            for tok in p[9].split():
                tok = self.STRIP.sub('', tok).upper()
                if tok.startswith('='):
                    self.exact[tok[1:]] = num
                elif tok:
                    self.pre[tok] = num

    def dxcc(self, call):
        c = call.upper()
        if c in self.exact:
            return self.exact[c]
        if '/' in c:
            ps = [x for x in c.split('/') if x]
            if 'MM' in ps or 'AM' in ps[1:]:
                return None                       # 海上・航空移動は DXCC の対象外
            ps = [x for x in ps if x not in self.SUF and not x.isdigit()]
            if not ps:
                return None
            c = ps[0] if len(ps) == 1 else min(ps, key=len)
            if c in self.exact:
                return self.exact[c]
        for i in range(len(c), 0, -1):
            if c[:i] in self.pre:
                return self.pre[c[:i]]
        return None


def clean(f, report):
    """1件の記録を点検・修正する。除く場合は None を返す"""
    call = f['CALL'].upper()
    d = f.get('QSO_DATE', '')
    mode = f.get('MODE', '').upper()
    mode = MODE_MAP.get(mode, mode)
    if not re.fullmatch(r'(19[5-9]\d|20[0-9]\d)(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])', d) or \
            (mode in MODE_START and d < MODE_START[mode]):
        report['削除: 日付が不正'].append(f'{call} {d} {mode}')
        return None
    if d < '19800101':
        report['確認: 日付が古い(念のため確認してください。削除はしません)'].append(f'{call} {d} {mode}')
    frs = f.get('FREQ', '').replace(',', '.')
    m = re.match(r'\d+(\.\d+)?', frs)
    mhz = float(m.group()) if m else None
    band = f.get('BAND', '').lower()
    if not band:
        band = band_of(mhz) if mhz else None
        if not band:
            report['削除: バンドが不明'].append(f"{call} {d} FREQ={f.get('FREQ', '')}")
            return None
        report['修正: 周波数からバンドを補完'].append(f"{call} {d} {f.get('FREQ')} -> {band}")
    out = {'CALL': call, 'QSO_DATE': d, 'TIME_ON': f.get('TIME_ON', '')[:4], 'BAND': band, 'MODE': mode}
    if mhz is not None and band_of(mhz) == band:
        out['FREQ'] = ('%g' % mhz)
    elif f.get('FREQ'):
        report['修正: バンドと合わない周波数を省略'].append(f"{call} {d} {band} FREQ={f['FREQ']}")
    g = f.get('GRIDSQUARE', '')
    if re.fullmatch(r'[A-Ra-r]{2}\d{2}([A-Xa-x]{2})?', g):
        out['GRIDSQUARE'] = g.upper()
    for q in ('QSL_RCVD', 'LOTW_QSL_RCVD'):
        if f.get(q, '').upper() in ('Y', 'V'):
            out[q] = 'Y'
    if f.get('DXCC', '').isdigit():
        out['DXCC'] = f['DXCC']                  # 元のログにDXCC番号があれば優先
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('inputs', nargs='+', help='読み込むADIF(複数可)')
    ap.add_argument('--cty-csv', required=True, help='Big CTY の cty.csv')
    ap.add_argument('--out', required=True, help='書き出すADIF')
    ap.add_argument('--dup-minutes', type=int, default=10,
                    help='同じ相手・日付・バンドで、時刻の差がこの分数以内なら重複とみなす')
    a = ap.parse_args()

    cty = CtyCsv(a.cty_csv)
    report = collections.defaultdict(list)
    recs = []
    for p in a.inputs:
        rs, enc = read_adif(p)
        print(f'読み込み: {p}({enc}){len(rs)}件')
        for f in rs:
            c = clean(f, report)
            if c:
                recs.append(c)

    # 重複を除く
    def mins(t):
        return int(t[:2]) * 60 + int(t[2:4]) if re.fullmatch(r'\d{4}', t) else -9999
    seen = collections.defaultdict(list)
    out = []
    for r in recs:
        k = (r['CALL'], r['QSO_DATE'], r['BAND'])
        if any(abs(mins(r['TIME_ON']) - x) <= a.dup_minutes for x in seen[k]):
            report['削除: 重複'].append(f"{r['CALL']} {r['QSO_DATE']} {r['TIME_ON']} {r['BAND']}")
            continue
        seen[k].append(mins(r['TIME_ON']))
        out.append(r)

    # DXCC番号
    unresolved = []
    for r in out:
        if 'DXCC' not in r:
            n = cty.dxcc(r['CALL'])
            if n:
                r['DXCC'] = n
            else:
                unresolved.append(f"{r['CALL']} {r['QSO_DATE']} {r['BAND']}")
    out.sort(key=lambda r: (r['QSO_DATE'], r['TIME_ON']))

    with open(a.out, 'w', encoding='ascii', newline='') as fp:
        fp.write('ADIF for DXCC notify / JTAlert (DXCC numbers from Big CTY, ASCII only)\r\n'
                 '<ADIF_VER:5>3.1.4\r\n<PROGRAMID:14>make_dxcc_adif\r\n<EOH>\r\n')
        for r in out:
            fp.write(''.join(f'<{k}:{len(r[k])}>{r[k]}' for k in OUT_FIELDS
                             if r.get(k) and r[k].isascii()) + '<EOR>\r\n')

    # 報告
    print(f'\n書き出し: {a.out} {len(out)}件')
    for k in sorted(report):
        print(f'{k}: {len(report[k])}件')
        for x in report[k][:10]:
            print('   ', x)
        if len(report[k]) > 10:
            print('    …')
    print(f'DXCC番号を付けられなかった記録: {len(unresolved)}件(ファイルには番号なしで残します)')
    for x in unresolved:
        print('   ', x)
    per = collections.defaultdict(set)
    for r in out:
        if 'DXCC' in r:
            per[r['BAND']].add(r['DXCC'])
    order = [b for b, _, _ in BANDS]
    print('\nバンドごとの交信済みエンティティ数(JTAlert の Rebuild の確認用: 340 − この数 = Needed)')
    for b in sorted(per, key=lambda x: order.index(x) if x in order else 99):
        print(f'  {b:>5}: {len(per[b])}')
    print(f'  合計(エンティティ×バンド): {sum(len(v) for v in per.values())}')


if __name__ == '__main__':
    main()
bottom of page