合同会社小村ソフト
第 6 章

付録 — 最小実装を素の JavaScript で読む(任意)

任意の付録。化学理解のコアではなく、技術読者がシミュレータの内部ロジックを追いたいときの章。PNA の正規化、カットの写像、指標の計算を素の JavaScript で 1 段ずつ読む。

この章は任意の付録です。化学理解のコアは第 1 〜 5 章と第 7 章で完結します。本章はシミュレータの内部ロジックを覗きたい技術読者向けで、JavaScript を読まなくても講座のゴール到達には影響しません。読み飛ばす場合は 第 7 章 総合演習 へ進んでください。

このページで先に知っておく言葉

入力検証
範囲外の値や不正な値を先に弾くこと。
RangeError
この実装で、前提を満たさない数値を受け取ったときに投げる例外。
正規化
重みの合計を 1 にそろえて比率に直すこと。
概念モデル
実測予測ではなく、方向感を見やすくした簡略化モデル。

高校化学との橋渡し: 反応式で係数を合わせるとき、前提をそろえずに計算を始めると混乱します。このコードでも同じで、数字が有限か・範囲内か・合計が正かを先に確かめてから、次の計算へ進みます。

まず見るべき 4 段階

  1. 入力検証 — 数字かどうか、範囲内かどうかを先に調べる。
  2. 組成の正規化 — P / N / A の生の重みを合計 1 にそろえる。
  3. カットの写像 — 軽質 ↔ 重質を平均炭素数と沸点範囲に変換する。
  4. 指標の計算 — 揮発性・密度・改質適性などの概念スコアを返す。

シミュレータで使っている中心コード

実際に動いているコアは次の JavaScript です。外部ライブラリは使わず、前提を満たさない入力は RangeError で止めています。

(function (global) {
  "use strict";

  function assertFinite(name, value) {
    if (!Number.isFinite(value)) {
      throw new RangeError(name + " must be finite");
    }
  }

  function assertRange(name, value, min, max) {
    assertFinite(name, value);
    if (value < min || value > max) {
      throw new RangeError(name + " must be between " + min + " and " + max);
    }
  }

  function normalizeComposition(raw) {
    const paraffins = Number(raw.paraffins);
    const naphthenes = Number(raw.naphthenes);
    const aromatics = Number(raw.aromatics);

    assertFinite("paraffins", paraffins);
    assertFinite("naphthenes", naphthenes);
    assertFinite("aromatics", aromatics);

    if (paraffins < 0 || naphthenes < 0 || aromatics < 0) {
      throw new RangeError("composition weights must be non-negative");
    }

    const total = paraffins + naphthenes + aromatics;
    if (total <= 0) {
      throw new RangeError("composition weights must sum to a positive number");
    }

    return {
      paraffins: paraffins / total,
      naphthenes: naphthenes / total,
      aromatics: aromatics / total
    };
  }

  function estimateCutProfile(cut) {
    assertRange("cut", cut, 0, 1);

    return {
      averageCarbonNumber: 5.2 + 5.0 * cut,
      boilingStartC: 30 + 55 * cut,
      boilingEndC: 95 + 105 * cut
    };
  }

  function estimateIndicators(comp, cut) {
    assertRange("cut", cut, 0, 1);
    assertRange("paraffins", comp.paraffins, 0, 1);
    assertRange("naphthenes", comp.naphthenes, 0, 1);
    assertRange("aromatics", comp.aromatics, 0, 1);

    const p = comp.paraffins;
    const n = comp.naphthenes;
    const a = comp.aromatics;
    const profile = estimateCutProfile(cut);

    return {
      averageCarbonNumber: profile.averageCarbonNumber,
      boilingStartC: profile.boilingStartC,
      boilingEndC: profile.boilingEndC,
      volatility: Math.round(100 * (0.75 * (1 - cut) + 0.15 * p + 0.10 * (1 - a))),
      density: Math.round(100 * (0.15 + 0.35 * cut + 0.20 * n + 0.30 * a)),
      octaneTendency: Math.round(100 * (0.10 + 0.25 * cut + 0.20 * n + 0.35 * a)),
      steamCrackingSuitability: Math.round(100 * (0.35 + 0.30 * p + 0.20 * (1 - cut) - 0.15 * a)),
      reformingSuitability: Math.round(100 * (0.15 + 0.35 * cut + 0.30 * n + 0.20 * a))
    };
  }

  function describeScenario(comp, cut, indicators) {
    if (cut < 0.35 && comp.paraffins >= 0.45) {
      return "軽質でパラフィンが多めです。蒸発しやすく、水蒸気分解側の読みがしやすい状態です。";
    }

    if (cut >= 0.6 && comp.naphthenes >= 0.35) {
      return "重質寄りでナフテンが厚めです。改質とのつながりが見えやすい状態です。";
    }

    if (comp.aromatics >= 0.35) {
      return "芳香族がやや厚めです。密度とオクタン価の傾向が上がりやすい一方、暴露や規格の論点も意識したい状態です。";
    }

    if (indicators.volatility >= 60) {
      return "やや軽質寄りです。蒸気圧や引火の見方を先に意識すると読みやすくなります。";
    }

    return "中間的なカットと組成です。下流の向きは、前処理や規格条件しだいで揺れると読めます。";
  }

  const PRESETS = {
    lightParaffinic: {
      label: "軽質パラフィン寄り",
      cut: 25,
      paraffins: 60,
      naphthenes: 25,
      aromatics: 15
    },
    balanced: {
      label: "バランス",
      cut: 55,
      paraffins: 45,
      naphthenes: 35,
      aromatics: 20
    },
    reforming: {
      label: "改質向き",
      cut: 72,
      paraffins: 20,
      naphthenes: 50,
      aromatics: 30
    },
    aromaticRich: {
      label: "芳香族リッチ",
      cut: 65,
      paraffins: 15,
      naphthenes: 20,
      aromatics: 65
    }
  };

  global.NC101Core = {
    assertFinite: assertFinite,
    assertRange: assertRange,
    normalizeComposition: normalizeComposition,
    estimateCutProfile: estimateCutProfile,
    estimateIndicators: estimateIndicators,
    describeScenario: describeScenario,
    PRESETS: PRESETS
  };
})(window);

指標式の係数について(D01 補足)

estimateIndicators の中の係数(0.75, 0.15, 0.10 など)は、実測データに基づく決定値ではなく、本講座の本文で説明した方向感をスライダ上で見やすくするための教材用パラメータです。たとえば volatility の式では:

  • 0.75 × (1 - cut) — 揮発性は「軽質ほど高い」が支配的なので、カットの寄与をいちばん大きく取る。
  • 0.15 × p — パラフィン比率が高いほど揮発しやすい傾向を、第 2 章の方向感に合わせて中程度の重みで反映する。
  • 0.10 × (1 - a) — 芳香族が多いと相対的に揮発性が下がる傾向を、補助的な小さい重みで反映する。

合計はおおむね 1 になるよう手で調整しています。実プロセスのモデルではないので、係数を変えれば挙動も変わります。改造して試すと、どの軸が何を支配しているかが体感できます。

シナリオ判定と指標値の関係(D02 補足)

describeScenario はシナリオ文を選ぶときに、まず cutcomp の生の値で分岐し、その後で indicators.volatility >= 60 を見ています。これは indicators 経由でも同じ判定を再現できますが、シナリオ文の意図が「組成・カットの直感的な状態」を言葉にすることなので、入力側で先に判定したほうが読みやすいという設計判断です。閾値(0.35, 0.45, 0.6, 60 など)は教材用の仮値で、実プロセスの基準ではありません。

プリセットの「合計 100」について(D03 補足)

PRESETS の P / N / A は、見やすさのためすべて合計 100 になるよう手で揃えてあります。ただしコード側はこの合計を仮定していませんnormalizeComposition が必ず正規化するので、合計がいくつでも(正の数であれば)同じ結果になります。プリセットを増やすときは、合計 100 を保たなくても動作しますが、UI 表示と一致させるために 100 にそろえるのが無難です。

コードと意味の対応

コード意味
normalizeCompositionP / N / A の生の重みを比率へ直す
estimateCutProfile軽質 ↔ 重質を平均炭素数と沸点範囲に写す
volatility軽さ・蒸発しやすさの概念スコア
steamCrackingSuitability小さく切る文脈との相性
reformingSuitability重質側・ナフテン側との相性

数値は「厳密予測」ではなく、本文で説明した方向感を可視化するために置いています。

この章のひとことでまとめ: 比率をそろえてから、カットと組成を順にスコアへ写す。シミュレータの中身はこの 2 段だけです。

理解チェック — コードの各行の意味

コードの各行が何をしているかを確認する 4 問です。

Q1. normalizeComposition({ paraffins: 2, naphthenes: 1, aromatics: 1 }).paraffins はいくつでしょうか。

Q2. estimateCutProfile(0.8).averageCarbonNumber はいくつでしょうか。

Q3. estimateIndicatorsreformingSuitability を押し上げやすい入力はどれでしょうか。

Q4. 負の重みのような不正入力が来たとき、この実装が投げる例外はどれでしょうか。

第 6 章のまとめ

  • PNA の正規化とカットの写像は、短い JavaScript 関数に落とし込める。
  • 入力条件は先に検証し、前提を満たさない値は例外で止める。
  • シミュレータの数値は厳密予測ではなく、方向感を可視化するための計算。