小村軟體有限公司
第 7 章

進階(選修) — 用程式碼重現一遍

給熟悉程式的讀者:用純 JavaScript 一步步跟著模擬器的運算。

關於這個進階頁面:這裡是選修模組,針對熟悉程式的讀者,用純 JavaScript 把前一個進階頁模擬器的運算一步一步走完。對於「讀懂石腦油」本身並不需要這一頁。沒興趣的話,直接跳到模組 4(用案例來判斷)就好。

頁面用到的詞

輸入驗證
先擋掉超出範圍或不正確的值。
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))
    };
  }

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

指標公式的係數說明(D01 補充)

estimateIndicators 內各係數(0.750.150.10 等)並非依實測資料而定,而是為了讓正文所述的方向感能在滑桿上明顯呈現的「教材用參數」。以 volatility 為例:

  • 0.75 × (1 - cut) — 揮發性最主要由「越輕越高」主導,所以切取貢獻最大。
  • 0.15 × p — 烷烴比例越高越易揮發的傾向,依模組 2a 的方向以中等權重反映。
  • 0.10 × (1 - a) — 芳香烴增多會壓低相對揮發性的傾向,以小權重作為輔助項。

係數總和大致控制在 0〜1 範圍內,由人手調整。它不是工廠製程模型 — 改動係數,行為就會改變。動手調整可以體會「哪條軸主導哪個指標」。

劇本判斷與指標值的關係(D02 補充)

describeScenario 在挑選劇本文字時,先用 cutcomp 的原始值分支,然後才看 indicators.volatility >= 60。原則上以 indicators 也能重現相同判斷,但劇本文字的目的是把「組成與切取的直覺狀態」化為文字,所以從輸入端先判斷會更易讀。閾值(0.350.450.660 等)皆為教材示意值,不代表真實製程的標準。

關於「合計 100」的預設(D03 補充)

PRESETS 中 P / N / A 為了易讀,已手動調整為合計 100。但程式碼端不假設這項條件normalizeComposition 一律會除以總和,所以只要是正數,無論合計多少,結果都相同。新增預設時不必硬要保持合計 100,但為了與 UI 顯示一致,照 100 對齊比較保險。

程式碼與意義對照

程式碼意義
normalizeComposition把 P / N / A 的原始權重縮放成比例
estimateCutProfile把「輕 ↔ 重」對應到平均碳數與沸點範圍
volatility輕與蒸發難易的概念分數
steamCrackingSuitability與「切小塊」故事線的適配程度
reformingSuitability與「重 × N 偏多」那邊的適配程度

這些數字不是嚴謹預測,而是為了把正文裡講的方向感「視覺化」出來。

這個模組的一句話:先把比例歸一化,再依序把切取與組成映射成分數。模擬器的運算本體就是這兩步。

理解檢核 — 每段程式碼的意思

檢查你對每行程式碼意義的理解的 4 題。

Q1. normalizeComposition({ paraffins: 2, naphthenes: 1, aromatics: 1 }).paraffins 的值是多少?

Q2. estimateCutProfile(0.8).averageCarbonNumber 的值是多少?

Q3. 哪種輸入最容易把 reformingSuitability 推高?

Q4. 遇到負值權重之類的非法輸入時,這個實作會丟出哪個例外?

進階模組回顧

  • 歸一化 PNA 與映射切取,都能裝進幾個短的 JavaScript 函式。
  • 一律先驗證輸入,前提不符就用例外直接停住。
  • 模擬器的數字不是嚴謹預測,而是把方向感視覺化的計算。