本文へ移動
BenriWorks Lab

開発記事公開日

教科書の式の不連続を、直さずにテストで固定する

2枚のモニタに折れ線グラフを表示した机で、印刷したグラフにペンを添えて振り返る若手のエンジニア

学習ツールwavelaneで、ナイフエッジ回折の近似式に残る区分境界の不連続を、式を歪めずに既知の性質としてテストで文書化した判断を紹介します

開発中の学習ツールに、山の稜線のような障害物を電波が回り込む「ナイフエッジ回折」を体感する教材があります。回折損失は教科書どおりの区分近似式で計算しているのですが、この式を忠実に実装すると、損失曲線の一箇所に約0.8 dBの小さな段差が残ります。写し間違いを疑って係数を突き合わせても、実装は式のとおりです。段差は、式そのものが持っている性質でした。滑らかにつなぎ直したくなるところですが、このツールでは式を歪めない側を選び、段差を既知の性質としてテストで固定しました。この記事はその線引きの記録です。どの境界は直し、どの境界は直さず、直さないという判断をどうやってコードに残したのかを、実装のコードで紹介します。

Lee近似の区分構成と境界の段差

対象のツールは、一陸技と二陸技(第一級・第二級陸上無線技術士)の受験者向けの学習補助アプリです。ナイフエッジ回折の教材では、障害物が見通し線からどれだけ突き出しているかをスライダーで動かし、回折損失の変化を観察します。計算は2段構えで、まず突出量h、距離、周波数から無次元のフレネルパラメータvを求め、次にvを損失dBへ変換します。この後段の変換に、仕様はLee近似と呼ばれる区分近似式を採用しています。

実装は仕様の式の逐語です。vの範囲ごとに5つの式を切り替えます。

export function knifeEdgeLoss(v: number): number {
  let lossDb: number;
  if (v <= -1) {
    return 0;
  } else if (v <= 0) {
    lossDb = -20 * Math.log10(0.5 - 0.62 * v);
  } else if (v <= 1) {
    lossDb = -20 * Math.log10(0.5 * Math.exp(-0.95 * v));
  } else if (v <= 2.4) {
    lossDb = -20 * Math.log10(0.4 - Math.sqrt(0.1184 - (0.38 - 0.1 * v) ** 2));
  } else {
    lossDb = -20 * Math.log10(0.225 / v);
  }
  return Math.max(0, lossDb);
}

区分近似ですから、式が切り替わる境界で値は完全には一致しません。現行の実装で境界の両側を突き合わせると、v=0は両側とも6.0206 dBで厳密に一致し、v=1は14.272 dBと13.979 dBで0.293 dBの差、そしてv=2.4は21.343 dBと20.561 dBで0.782 dBの差になります。このアプリの演習は数値解答を「±0.5 dBまたは±1%の大きい方」の既定許容で判定するので、その物差しに載せると、0.293 dBは内側、0.782 dBは外側です。ここだけは、誤差と呼んで流せる大きさではありません。

ナイフエッジ回折のLee近似による損失曲線と、v=2.4近傍の拡大図。v=0は両側とも6.0206 dBで一致し、v=1では0.293 dB、v=2.4では0.782 dBの段差が残る。判定の許容は±0.5 dBまたは±1%の大きい方で、v=2.4の段差だけが許容の外に出る
曲線は実装のコードをそのまま評価したもの。段差は拡大しないと見えない大きさで残っている

直した境界と直さなかった境界

境界に手を入れた場所も、実はあります。生のLee式は、vが−1から約−0.806の範囲で負の損失、つまり最大0.98 dBほどの増強を返します。仕様の共通規約は「dBの損失は正値で表す」なので、末尾のmax(0, lossDb)で0へクリップしています。このクリップの結果として、損失0の区分と接するv=−1の境界は連続になりました。滑らかにしたくて変形したのではなく、符号の規約に従ったら連続が付いてきた、という順番です。

であれば、v=2.4も同じように調整できないでしょうか。方法自体はあります。実際、最後の区分の定数0.225を約0.206へ変えれば境界はぴたりとつながる、というところまで計算しました。

つないだ後に起きることを考えると、これは直しではありません。定数を変えれば、v>2.4の全域で値が0.78 dBずつ持ち上がり、教科書の式なら約27.6 dBになる山岳回折の条件で、このツールは28.3 dBを表示するようになります。受験者は表示された値を、参考書の計算例や過去問の解答と突き合わせるはずです。そこで食い違う数字を出すツールは、曲線がどれだけ滑らかでも、学習ツールとしては壊れています。ここでの正しさの基準は物理の真値への近さではなく、教科書と同じ手順で計算したときに同じ値が出ることだからです。

二つの境界を分けたものは、変形の根拠が仕様にあるかどうかでした。符号規約という根拠を持つクリップは行い、見た目の連続性しか根拠のない定数調整は行わない。段差は残します。ただし、知らずに残っているのと、知っていて残しているのとでは、コードとしてまるで別物です。この区別を、実行される形で書き残すことにしました。

現状の挙動を仕様として書き取るテスト

characterizationテストは、既存コードの現状の挙動を観測し、それをそのまま期待値としてテストに書き取る手法です。もともとは、仕様の記録が失われたレガシーコードを安全に変更するための道具として知られています。挙動の正しさを保証するのではなく、挙動が変わったことを検出する網を張る。今回はこれを少し逆向きに使いました。挙動は完全に把握できていて、しかも一見バグに見える。その挙動が将来「直されない」ように固定します。

it("v=2.4 境界: Lee 近似固有の 0.783 dB 不連続(characterization、仕様書修正提案済み)", () => {
  const EPS = 1e-9;
  const jump = Math.abs(knifeEdgeLoss(2.4 - EPS) - knifeEdgeLoss(2.4 + EPS));
  expect(jump).toBeCloseTo(0.783, 2);
  // 各側の絶対値も固定(式の逐語実装を保証)
  expect(knifeEdgeLoss(2.4)).toBeCloseTo(21.343, 2); // 区分4(v≤2.4)
  expect(knifeEdgeLoss(2.4 + EPS)).toBeCloseTo(20.561, 2); // 区分5
});

固定しているのは段差の大きさだけではありません。境界の両側の絶対値も一緒に固定しています。段差の幅だけを検査すると、両方の区分を同じ量だけずらす「修正」が通り抜けてしまいますが、絶対値まで固定すれば、どちらの式に触れてもテストが落ちます。テスト名には、近似式固有の不連続であることと、仕様書へ追記を提案済みであることを書きました。数か月後の自分や、リファクタリングを任せたAIがこの段差を「修正」したとき、最初に目にする失敗メッセージがそのまま説明になる想定です。

一方、v=−1、0、1の境界には、別のテストで「不連続は0.3 dB未満」という上限だけを掛けています。こちらの境界のずれは受け入れる誤差であって、値そのものに意味がないからです。意図して残す段差は値まで固定し、ただの誤差は上限で縛る。同じ境界の検査でも、固定の強さを意図の強さに合わせています。

テストと合わせて、文書も三箇所に置きました。実装のコメントには不連続の位置と大きさを、仕様書には「約0.8 dBの既知の段差を持つ。物理現象として説明せず、近似式固有の性質として表示・テストする」という一文を、教材の試験対策の解説には「近似式の性質であり物理現象ではない」という注記を書いています。グラフの段に気づいた学習者と、コードの段に気づいた開発者では、最初に開く場所が違うからです。

テストの書き分けを対比した図。意図して残す段差は段差0.783 dBと両側の絶対値21.343 dB・20.561 dBまで固定し、ただの誤差は不連続0.3 dB未満という上限だけを検査する
意図して残す段差は値まで、ただの誤差は上限だけ。固定の強さを意図の強さに合わせる

単位ミスを検出するクロステスト

値を固定するテストを並べれば安心か、というと、まだ取りこぼしがあります。ここまでのテストはすべて、損失関数へvを直接渡していました。vそのものの計算、つまりkmからmへの換算やλ=300/fという波長近似をどこかで取り違えていたら、固定した値のテストは全部素通りします。

vを求める関数と、教材の図で使う第1フレネルゾーン半径r1を求める関数は別モジュールにあり、それぞれの内部で単位を換算しています。この2つには、パラメータの取り方によらず成り立つ関係があります。vはhに√(2(d1+d2)/(λd1d2))を掛けた量、r1は√(λd1d2/(d1+d2))なので、hにr1を入れると距離も波長もすべて約分され、vは必ず√2になります。

it("√2 クロステスト(単位混入検出器): h = r1 のとき v = √2", () => {
  for (const [d1, d2, f] of [
    [5, 5, 6000],
    [2, 8, 1000],
    [0.5, 30, 150],
  ]) {
    const r1 = fresnelZoneRadius(1, d1!, d2!, f!);
    expect(fresnelParameter(r1, d1!, d2!, f!)).toBeCloseTo(Math.SQRT2, 9);
  }
});

距離と周波数を変えた3組で、小数9桁の精度で√2を要求しています。片方のモジュールだけが換算を忘れた、2つのモジュールが違うλを使っている、といった食い違いは、この恒等式で露見します。正直に言えば、これ単独では穴が残ります。すべてが約分されるということは、両方のモジュールが同じ取り違えをした場合にも√2が出てしまう、ということだからです。その穴は、半径側の絶対値テスト(f=300 MHz、d1=d2=1 kmで√500=22.3607 mになる)が塞いでいます。絶対値テストが半径を基準へ固定し、クロステストがvを半径へつなぐ。2本を張って初めて、単位ミスの逃げ場がなくなります。

作業ジャケット姿の男性エンジニアがクリップボードを指さしているイラスト。左に「「直さない」をテストに残す 区分近似の段差を仕様として書き取る」というキャッチコピーが入っている
一見バグに見える段差を、知っていて残していると分かる形にする

教科書との一致を選んだもう一つの場所

同じ基準で決めた定数が、リポジトリの別の場所にもあります。自由空間伝搬損失の定数には、物理定数から導ける32.4478ではなく、試験の出題慣習に合わせた32.45を使っています。こちらにも、f=1 MHz、d=1 kmで厳密に32.45を返すことを要求する判別テストが付いていて、いつか誰かが厳密光速へ「改良」すれば、テストが落ちてそれが改良ではないことを伝えます。

教科書の値を正とすると決めた以上、教科書の近似が持つ癖は、段差ごと引き受けることになります。その決め事を、コメントの中の口約束ではなく、破れば落ちるテストにしておくのが今回の判断でした。この計算部の上に作っている「wavelane」は、一陸技と二陸技の受験者向けに電波伝搬を可視化する学習ツールで、現在公開準備中です。公開後、損失曲線の小さな段に気づく学習者がどれだけいるのか、先回りして置いた注記はそのとき働くのか。答え合わせはこれからです。

関連アプリ

こんな記事も読まれています