#!/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()