【備忘録】PLCのBINファイルをCSVに変換する方法|JavaScriptだけで波形グラフ表示まで

JavaScript

皆さん、こんにちは!
上越市を拠点にし、「FA設備・装置開発」と「画像処理」に強い会社、NSIです!
私達は豊富な経験と専門知識で、各種業界の自動化・システム化のお手伝いをしています。

お盆も明けて、まだまだ暑い日が続いていますね。現場に入ると盤の中も設備の中も熱気がすごく、この時期は人も機械も冷却が課題です。皆さん、水分補給を忘れずご安全に

さて、今回はPLCに関連した久しぶりプログラムネタです。
テーマは「PLCで取った波形データ(BINファイル)を、JavaScriptだけでCSV化してグラフ表示する」です。

現場でこんな場面、ありませんか?

「PLCで波形は取れたけど、この.binってどうやって開くの?」
「Excelで見たいけど、客先PCにはPythonもVisual Studioも入れられない」

今回はこれを、HTMLファイル1つで解決してみます。
記事の最後には、そのまま使える完成版HTMLのダウンロードも用意していますので、ぜひ最後までお付き合いください!

この記事で作るもの

まず完成形からお見せします。

完成した波形ビューアの画面(設定欄・ドロップ領域・グラフ全体)

BINファイルをドラッグ&ドロップすると、Ch1・Ch2の波形が時間軸で表示され、そのままCSVとして保存できます。使うのはHTMLファイル1つだけです。

作る順番は次のとおりです。

手順やること該当する章
1BINファイルの仕様を確定させる今回扱うBINファイルの仕様
2バイナリを数値として読むJavaScriptでバイナリを読む3つのポイント
3Ch1・Ch2に分割するBINを読み込んでCh1・Ch2に分割する
4時間軸・物理量に換算する横軸を時間に、縦軸を物理量にする
5CSVに変換して保存するCSVとして保存する
6グラフで表示するCanvasで波形を表示する
7操作しやすくするドラッグ&ドロップに対応する
べんぞうくん
べんぞうくん

ポイントは手順1だよ。
ここが決まっていないと、その後の工程に進めないよ~

なぜPLC側はBIN、PC側はCSVなのか

PLCのデバイスデータを外部ファイルにするための保存形式には、大きくCSV(テキスト)とBIN(バイナリ)があります。 どちらが良いという話ではなく、PLC側とPC側で得意なことが違うというのが実際のところです。

PLC側でBINが有利な理由は、

  • 数値を文字列に変換する処理が不要
  • カンマや改行のぶんのデータが増えない
  • 1データあたりのサイズが固定なので、書き込み処理が単純で速い

たとえば今回の4000wordなら、BINならぴったり8000byteです。文字列への変換処理が不要なので、スキャンタイムを圧迫したくない場面ではこの差が効いてきます。

一方、PC側でCSVが有利な理由は、

  • Excelでそのまま開ける
  • PythonやRでもすぐ読める
  • データベースへ投入しやすい
  • 人が目で見て確認できる

つまり、以下の役割分担が現実的です。

PLC側 → BINで高速に保存 → PC側で必要なときだけCSVへ変換

今回作るのは、この矢印の後半部分にあたるツールです。

今回扱うBINファイルの仕様

今回のサンプルでは、PLCから出力したBINファイルを次の形式とします。

項目仕様
データ数4000 word
1データ16 bit(2 byte)
符号signed
バイト順Little Endian
Ch1word 0 ~ 1999
Ch2word 2000 ~ 3999
ファイルサイズ8000 byte

ファイルの中身は、2つの波形が前半・後半に分かれて入っているイメージです。

┌────────────────────────┐
│ word 0 ~ 1999         │
│ Ch1 波形データ 2000点   │
├────────────────────────┤
│ word 2000 ~ 3999      │
│ Ch2 波形データ 2000点   │
└────────────────────────┘

PLC屋さんには馴染みのある「word」ですが、PC側では基本的にbyte単位でデータを扱います。 ここがPLCとPCのデータ受け渡しで、最初につまずきやすいポイントです。

べんぞうくん
べんぞうくん

PLCではword、PCではbyte。この違いを意識すると一気に分かりやすくなるよ。

JavaScriptでバイナリを読む3つのポイント

① ArrayBuffer = 生のbyte列を入れる箱

BINファイルはテキストではないので、文字列としてではなくバイナリとして読み込む必要があります。 JavaScriptではFile.arrayBuffer()を使います。

const arrayBuffer = await file.arrayBuffer();
console.log(arrayBuffer.byteLength);   // 8000

ArrayBufferは、ファイルから読み込んだ生のbyte列を格納するメモリ領域です。今回は 4000 word × 2 byte なので、byteLengthは8000になります。

② DataView = 「この2byteをどう読むか」を指定する

ArrayBufferにはbyte列が入っているだけで、「この2byteをsigned 16bitとして読む」という意味は持っていません。そこで使うのがDataViewです。

const view = new DataView(arrayBuffer);
const value = view.getInt16(0, true);

DataViewを使うと、8bit / 16bit / 32bit、符号付き / 符号なし、浮動小数点など、形式を指定して読み出せます。

今回はsigned 16bitなのでgetInt16()です。

メソッド範囲
getInt16()signed 16bit-32768 ~ 32767
getUint16()unsigned 16bit0 ~ 65535

ここを間違えると、PLC側で-100だった値がPC側で65436という巨大な正数として表示されます。振動波形やA/D変換値のようにマイナス側へ振れるデータでは、必ずsignedで読んでください。

③ Little Endian = 第2引数の true

getInt16()の第2引数がバイト順の指定です。

view.getInt16(0, true);    // Little Endian
view.getInt16(0, false);   // Big Endian(省略時もこちら)

省略するとBig Endianになるので、ここは要注意です。

たとえばBINファイル内に34 12という2byteが入っていた場合、

読み方解釈10進数
Little Endian0x12344660
Big Endian0x341213330

同じ2byteなのに、まったく違う値になります。波形が「なんとなくノイズだらけに見える」ときは、だいたいここです。

おまけ:なぜ i * 2 なのか

PLCは1word単位で考えますが、DataViewはbyte位置で指定します。

word 0 → byte 0 ~ 1
word 1 → byte 2 ~ 3
word 2 → byte 4 ~ 5

したがって、i番目のwordの開始位置はi * 2です。 PLC側はword、PC側はbyte。この変換を意識しておくと、バイナリ解析はぐっと分かりやすくなります。

BINを読み込んでCh1・Ch2に分割する

ここまでをまとめると、解析部分はこれだけです。

function parseBin(arrayBuffer, config) {

    // ファイルサイズは仕様の一部。必ずチェックする
    if (arrayBuffer.byteLength !== config.expectedBytes) {
        throw new Error(
            "ファイルサイズが仕様と一致しません。期待値 "
            + config.expectedBytes + " byte / 実際 "
            + arrayBuffer.byteLength + " byte"
        );
    }

    const view = new DataView(arrayBuffer);
    const wordCount = arrayBuffer.byteLength / 2;
    const values = new Array(wordCount);

    for (let i = 0; i < wordCount; i++) {
        values[i] = view.getInt16(i * 2, true) * config.scale;
    }

    return {
        ch1: values.slice(0, config.pointCount),
        ch2: values.slice(config.pointCount, config.pointCount * 2)
    };
}

ポイントは、期待するファイルサイズを点数から計算しているところです。

expectedBytes = pointCount × 2ch × 2byte

8000という数字を直接書いてしまうと、PLC側の保存点数が変わったときにツールが壊れます。点数を設定値として持たせておけば、そのまま使い回せます。

このチェックがあるだけで、以下のような異常に、グラフを見る前に気付けます。

  • 別仕様のBINを間違えて開いた
  • 収集途中でファイルが欠損した
  • PLC側の保存点数が変更された

横軸を時間に、縦軸を物理量にする

ここが実は一番大事なところです。
Index(点番号)のままでも波形の形は分かりますが、現場で使うなら

「この振動、何ミリ秒続いてる?」
「ピークは何G?」

に答えられないと意味がありません。必要な情報は2つだけです。

設定内容
サンプリング周期1点あたりの時間0.1 ms
スケール係数生値1あたりの物理量0.001 G/digit
単位物理量の単位G

変換自体は掛け算だけです。

時間[ms]   = index × samplePeriod
物理量     = rawValue × scale

スケール係数は、センサの感度とA/Dの分解能から決まります。たとえば ±10V を16bitでA/D変換し、センサ感度が 100 mV/G であれば、

1 digit = 20V / 65536 ≒ 0.305 mV
        ≒ 0.00305 G

といった具合です。このあたりの数値はセンサとアンプの仕様書に必ず書いてあるので、ツール側は「係数を入力できるようにしておく」のが正解です。案件ごとに数値を埋め込んだツールを量産すると、後で必ず分からなくなります。

単位もあわせて持たせておきましょう。 CSVのヘッダ、グラフの縦軸、統計値の表示——単位を出せる場所にはすべて出すのが原則です。 Ch1 とだけ書かれたCSVを3か月後に開いても、それが生値なのかGなのか誰も分かりません。客先へ提出するデータであればなおさらです。

なお、係数を入力式にする場合は入力チェックも忘れずに。空欄のまま読み込むとparseFloat()NaNを返し、全データがNaNになってグラフが消えます。

if (!Number.isFinite(config.scale) || config.scale === 0) {
    showMessage("スケール係数には0以外の数値を入力してください。", true);
    return;
}

設定を変えたら、必ず作り直す

設定を入力式にすると、もう一つ見落としやすい落とし穴があります。

読み込み時の設定でグラフとCSVを作ったあと、利用者が画面上でスケール係数を変更したとします。 このとき再計算する仕組みがないと、画面に表示されている設定と、保存されるCSVの中身が食い違います

利用者は「係数を変えたから、この状態で保存しよう」と考えて保存ボタンを押すわけですが、出てくるのは変更前のCSVです。しかも見た目には何の異常もないので、気付くのは解析が一通り終わったあとです。

対策は、読み込んだデータを保持しておき、設定が変わったら描き直すことです。

let currentBuffer = null;

// 設定を変更したら、読み込み済みのBINを描き直す
["pointCount", "samplePeriod", "scale", "unit"].forEach(function (id) {
    document.getElementById(id).addEventListener("change", render);
});

あわせて、エラーで処理を中断したときにも前回の結果を必ず消してください。 グラフも統計値も保存ボタンも、有効なデータがないなら残しておいてはいけません。

function clearOutput() {
    currentCsv = "";
    document.getElementById("saveButton").disabled = true;

    const canvas = document.getElementById("waveCanvas");
    canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
    document.getElementById("statsTable").hidden = true;
}

現場で使うツールで一番まずいのは、動かないことではなく、間違った結果を平然と表示することです。

CSVとして保存する

CSVの生成は文字列の組み立てだけで十分です。

function buildCsv(ch1, ch2, config) {
    const lines = ["Index,Time[ms],Ch1[" + config.unit + "],Ch2[" + config.unit + "]"];

    for (let i = 0; i < ch1.length; i++) {
        lines.push(
            i + "," + (i * config.samplePeriod).toFixed(4)
              + "," + formatValue(ch1[i]) + "," + formatValue(ch2[i])
        );
    }

    return lines.join("\r\n") + "\r\n";
}

スケール係数を掛けたときの端数に注意

生値にスケール係数を掛けると、浮動小数点の誤差がそのままCSVに出てきます。

1305 * 0.00305
// → 3.9802500000000003

CSVにこの数字が並ぶと、見た目が悪いだけでなく「計算がおかしいのでは」と要らぬ疑いを持たれます。出力時に有効桁で丸めておきましょう。

function formatValue(v) {
    return Number.isInteger(v) ? String(v) : String(Number(v.toPrecision(12)));
}

これで 3.98025 と出力されます。

なおtoPrecision(12)はあくまで「浮動小数点の誤差を落とす」ための桁数です。本来の有効桁は生値の分解能とスケール係数の桁数から決まりますので、実際の測定データに合わせて調整してください。また、値が極端に大きい/小さい場合は指数表記(1e-7など)になることがあります。

出力されるCSVはこうなります。

Index,Time[ms],Ch1[digit],Ch2[digit]
0,0.0000,-46,5357
1,0.1000,768,5504
2,0.2000,1305,5683
3,0.3000,1951,5772

Excelで文字化けさせないための一手間

保存にはBlobを使いますが、ここに日本語のCSVで必ず踏む落とし穴があります。

// これだとExcelでダブルクリックしたときに文字化けする
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });

JavaScriptが出力するのはBOMなしのUTF-8です。 Excelのバージョンや開き方によっては、BOMなしUTF-8のCSVを直接開いた際に日本語が文字化けする場合があります。ヘッダに日本語や単位記号を入れているときは特に注意が必要です。

対策は、先頭にBOM(\uFEFF)を付けるだけです。

function saveCsv(csv, fileName) {
    const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");

    link.href = url;
    link.download = fileName;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);

    // 即時 revoke するとダウンロードが中断されるブラウザがあるため遅延させる
    setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}

Webサーバーは不要です。今回の完成版は外部ライブラリもサーバー通信も使用していないため、読み込んだBINデータはPC内だけで処理されます。

べんぞうくん
べんぞうくん

CSVは作れたのにExcelで文字化け…は、地味にあるある。

Canvasで波形を表示する

CSVにできても、波形はやはりグラフで見た方が早いです。外部ライブラリを使わず、HTML Canvasに直接描いてみます。

<canvas id="waveCanvas" width="1000" height="400"></canvas>

縦軸のスケーリングで注意すること

最大値・最小値から縦軸を自動スケーリングするのが基本ですが、ここに罠があります。

// 波形が平坦だと maxValue - minValue が 0 → ゼロ割で NaN
return height - (value - minValue) / (maxValue - minValue) * height;

設備が停止中でデータが全点0だった、センサ配線が外れていて値が張り付いていた—— こういうBINは現場で必ず出てきます。 そしてグラフが真っ白になり、「ツールが壊れた」と言われます。

レンジが0のときの逃げ道を用意しておきましょう。

if (maxValue === minValue) {
    minValue -= 1;
    maxValue += 1;
}
べんぞうくん
べんぞうくん

正常なデータだけで作ると、現場に持っていった途端にコケがち…。

最大・最小はループで求める

もう1点。最大値・最小値を求めるとき、つい次のように書きたくなります。

const minValue = Math.min(...allValues);   // 点数が増えると落ちる

スプレッド構文は配列の要素を「引数」として展開するため、数万点を超えると引数の上限に当たって例外になります。波形データのように点数が変わり得るものは、素直にループで回すのが安全です。

function getStats(wave) {
    let min = wave[0];
    let max = wave[0];
    let sum = 0;

    for (let i = 0; i < wave.length; i++) {
        const v = wave[i];
        if (v < min) min = v;
        if (v > max) max = v;
        sum += v;
    }

    return { min: min, max: max, pp: max - min, avg: sum / wave.length };
}

ついでに P-P(最大-最小)と平均も出しておくと、波形の確認や簡易的な解析指標として便利です。なお量産設備で良否判定に使う場合は、別途しきい値の妥当性検証が必要になります。

2chは必ず色を分ける

ctx.strokeStyleを指定しないと、Ch1もCh2も同じ黒で描かれて見分けがつきません。色と凡例はセットで入れておきましょう。

function plot(ctx, wave, color) {
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.2;
    ctx.beginPath();

    for (let i = 0; i < wave.length; i++) {
        const x = toX(i, wave.length);
        const y = toY(wave[i]);
        if (i === 0) {
            ctx.moveTo(x, y);
        } else {
            ctx.lineTo(x, y);
        }
    }

    ctx.stroke();
}

実行するとこのように表示されます。

グラフ部分と統計値テーブルの拡大(Ch1=青、Ch2=オレンジ、横軸ms)

ドラッグ&ドロップに対応する

現場で複数の測定ファイルを次々に確認する場合、毎回ファイル選択ダイアログを開くのは面倒です。ドラッグ&ドロップに対応させておきましょう。

const dropArea = document.getElementById("dropArea");

dropArea.addEventListener("dragover", function (event) {
    event.preventDefault();
    dropArea.classList.add("over");
});

dropArea.addEventListener("dragleave", function () {
    dropArea.classList.remove("over");
});

dropArea.addEventListener("drop", function (event) {
    event.preventDefault();
    dropArea.classList.remove("over");

    const file = event.dataTransfer.files[0];
    if (file) handleFile(file);
});

そして、これを忘れると必ずイライラします

// ドロップ領域から少し外れた場所に落とすと、
// ブラウザがそのBINファイルを開いてしまいツールの画面が消える
window.addEventListener("dragover", function (e) { e.preventDefault(); });
window.addEventListener("drop", function (e) { e.preventDefault(); });

ウィンドウ全体でデフォルト動作を止めておくのが定番の対策です。

BINファイルをドロップ領域にドラッグしている状態(枠が青くハイライトされる)

完成版:コピペで動く1ファイル

ここまでの内容をまとめた完成版です。テキストエディタに貼り付けてbin_wave_viewer.htmlという名前で保存し、ブラウザで開けばそのまま動きます。インストール作業も、Webサーバーも不要です。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PLC BIN波形ビューア</title>
<style>
  body {
    font-family: "Segoe UI", "Meiryo", sans-serif;
    margin: 20px;
    color: #222;
    background: #fff;
  }
  h1 { font-size: 18px; margin: 0 0 16px; }
  #dropArea {
    border: 2px dashed #9aa5ad;
    border-radius: 6px;
    padding: 28px;
    text-align: center;
    color: #55606a;
    background: #f7f9fa;
    margin-bottom: 12px;
  }
  #dropArea.over { border-color: #1c6fb0; background: #eaf3fa; }
  .row { margin-bottom: 12px; }
  .config label { margin-right: 14px; font-size: 13px; }
  .config input { width: 90px; }
  #message { min-height: 20px; font-size: 13px; margin-bottom: 8px; }
  #message.error { color: #c0392b; }
  canvas { border: 1px solid #ccc; max-width: 100%; }
  table { border-collapse: collapse; margin-top: 12px; font-size: 13px; }
  th, td { border: 1px solid #ccc; padding: 4px 10px; text-align: right; }
  th { background: #f0f3f5; }
  button { padding: 6px 14px; }
  button:disabled { opacity: 0.5; }
</style>
</head>
<body>

<h1>PLC BIN波形ビューア(signed 16bit / Little Endian)</h1>

<div class="row config">
  <label>1ch点数 <input type="number" id="pointCount" value="2000"></label>
  <label>サンプリング周期[ms] <input type="number" id="samplePeriod" value="0.1" step="0.01"></label>
  <label>スケール係数 <input type="number" id="scale" value="1" step="0.001"></label>
  <label>単位 <input type="text" id="unit" value="digit" style="width:60px"></label>
</div>

<div id="dropArea">BINファイルをここへドロップ</div>

<div class="row">
  <input type="file" id="fileInput" accept=".bin">
  <button id="saveButton" disabled>CSVで保存</button>
</div>

<div id="message"></div>

<canvas id="waveCanvas" width="1000" height="400"></canvas>

<table id="statsTable" hidden>
  <thead>
    <tr><th>Ch</th><th>最小</th><th>最大</th><th>P-P</th><th>平均</th></tr>
  </thead>
  <tbody></tbody>
</table>

<script>
"use strict";

const COLOR_CH1 = "#1c6fb0";
const COLOR_CH2 = "#e07b39";

let currentBuffer = null;
let currentFileName = "";
let currentCsv = "";

// ---------------------------------------------------------------
// 設定の取得
// ---------------------------------------------------------------
function getConfig() {
  const pointCount = parseInt(document.getElementById("pointCount").value, 10);
  const samplePeriod = parseFloat(document.getElementById("samplePeriod").value);
  const scale = parseFloat(document.getElementById("scale").value);
  const unit = document.getElementById("unit").value || "digit";

  return {
    pointCount: pointCount,
    samplePeriod: samplePeriod,
    scale: scale,
    unit: unit,
    expectedBytes: pointCount * 2 * 2   // 2ch × 2byte/word
  };
}

// ---------------------------------------------------------------
// BINファイルの解析
// ---------------------------------------------------------------
function parseBin(arrayBuffer, config) {
  if (arrayBuffer.byteLength !== config.expectedBytes) {
    throw new Error(
      "ファイルサイズが仕様と一致しません。期待値 "
      + config.expectedBytes + " byte / 実際 "
      + arrayBuffer.byteLength + " byte"
    );
  }

  const view = new DataView(arrayBuffer);
  const wordCount = arrayBuffer.byteLength / 2;
  const values = new Array(wordCount);

  for (let i = 0; i < wordCount; i++) {
    // 第2引数 true = Little Endian
    values[i] = view.getInt16(i * 2, true) * config.scale;
  }

  return {
    ch1: values.slice(0, config.pointCount),
    ch2: values.slice(config.pointCount, config.pointCount * 2)
  };
}

// ---------------------------------------------------------------
// 数値整形(スケール係数を掛けると 3.9802500000000003 のような
// 浮動小数点の誤差が出るため、有効桁で丸めてから出力する)
// ---------------------------------------------------------------
function formatValue(v) {
  return Number.isInteger(v) ? String(v) : String(Number(v.toPrecision(12)));
}

// ---------------------------------------------------------------
// 統計値(スプレッド構文は使わない:点数が増えると引数上限で落ちるため)
// ---------------------------------------------------------------
function getStats(wave) {
  let min = wave[0];
  let max = wave[0];
  let sum = 0;

  for (let i = 0; i < wave.length; i++) {
    const v = wave[i];
    if (v < min) min = v;
    if (v > max) max = v;
    sum += v;
  }

  return { min: min, max: max, pp: max - min, avg: sum / wave.length };
}

// ---------------------------------------------------------------
// 波形描画
// ---------------------------------------------------------------
function drawWave(ch1, ch2, config) {
  const canvas = document.getElementById("waveCanvas");
  const ctx = canvas.getContext("2d");
  const width = canvas.width;
  const height = canvas.height;
  const pad = { left: 70, right: 12, top: 26, bottom: 32 };
  const plotW = width - pad.left - pad.right;
  const plotH = height - pad.top - pad.bottom;

  ctx.clearRect(0, 0, width, height);

  const s1 = getStats(ch1);
  const s2 = getStats(ch2);
  let minValue = Math.min(s1.min, s2.min);
  let maxValue = Math.max(s1.max, s2.max);

  // レンジ0(停止中で全点同値など)だとゼロ割でNaNになる
  if (maxValue === minValue) {
    minValue -= 1;
    maxValue += 1;
  }

  const range = maxValue - minValue;

  function toX(index, length) {
    return pad.left + (length === 1 ? 0 : index / (length - 1) * plotW);
  }
  function toY(value) {
    return pad.top + plotH - (value - minValue) / range * plotH;
  }

  // グリッドと目盛
  ctx.strokeStyle = "#e3e7ea";
  ctx.fillStyle = "#666";
  ctx.font = "11px sans-serif";
  ctx.lineWidth = 1;
  ctx.textAlign = "right";

  for (let i = 0; i <= 5; i++) {
    const value = minValue + range * i / 5;
    const y = toY(value);
    ctx.beginPath();
    ctx.moveTo(pad.left, y);
    ctx.lineTo(pad.left + plotW, y);
    ctx.stroke();
    ctx.fillText(value.toFixed(range < 10 ? 2 : 0), pad.left - 6, y + 4);
  }

  // 縦軸の単位
  ctx.fillText("[" + config.unit + "]", pad.left - 6, pad.top - 8);

  // 横軸(時間)
  const totalMs = (ch1.length - 1) * config.samplePeriod;
  for (let i = 0; i <= 5; i++) {
    const x = pad.left + plotW * i / 5;
    ctx.beginPath();
    ctx.moveTo(x, pad.top);
    ctx.lineTo(x, pad.top + plotH);
    ctx.stroke();

    // 両端は中央揃えにするとキャンバスからはみ出して文字が切れる
    ctx.textAlign = (i === 0) ? "left" : (i === 5) ? "right" : "center";
    ctx.fillText((totalMs * i / 5).toFixed(2) + " ms", x, height - 12);
  }

  // 0ライン
  if (minValue < 0 && maxValue > 0) {
    ctx.strokeStyle = "#b0b8be";
    ctx.beginPath();
    ctx.moveTo(pad.left, toY(0));
    ctx.lineTo(pad.left + plotW, toY(0));
    ctx.stroke();
  }

  function plot(wave, color) {
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    for (let i = 0; i < wave.length; i++) {
      const x = toX(i, wave.length);
      const y = toY(wave[i]);
      if (i === 0) {
        ctx.moveTo(x, y);
      } else {
        ctx.lineTo(x, y);
      }
    }
    ctx.stroke();
  }

  plot(ch1, COLOR_CH1);
  plot(ch2, COLOR_CH2);

  // 凡例
  ctx.textAlign = "left";
  ctx.fillStyle = COLOR_CH1;
  ctx.fillText("■ Ch1", pad.left + 8, pad.top + 14);
  ctx.fillStyle = COLOR_CH2;
  ctx.fillText("■ Ch2", pad.left + 60, pad.top + 14);

  // 統計値テーブル
  const table = document.getElementById("statsTable");
  const headers = table.querySelectorAll("thead th");
  ["Ch", "最小", "最大", "P-P", "平均"].forEach(function (label, i) {
    headers[i].textContent = (i === 0) ? label : label + "[" + config.unit + "]";
  });

  const tbody = table.querySelector("tbody");
  tbody.innerHTML = "";

  const digits = Math.max(s1.pp, s2.pp) < 10 ? 4 : 2;

  [["Ch1", s1], ["Ch2", s2]].forEach(function (pair) {
    const tr = document.createElement("tr");
    const s = pair[1];
    [pair[0], s.min, s.max, s.pp, s.avg].forEach(function (v, i) {
      const td = document.createElement("td");
      td.textContent = (i === 0) ? v : v.toFixed(digits);
      tr.appendChild(td);
    });
    tbody.appendChild(tr);
  });

  table.hidden = false;
}

// ---------------------------------------------------------------
// CSV生成
// ---------------------------------------------------------------
function buildCsv(ch1, ch2, config) {
  const lines = ["Index,Time[ms],Ch1[" + config.unit + "],Ch2[" + config.unit + "]"];

  for (let i = 0; i < ch1.length; i++) {
    lines.push(
      i + "," + (i * config.samplePeriod).toFixed(4)
        + "," + formatValue(ch1[i]) + "," + formatValue(ch2[i])
    );
  }

  return lines.join("\r\n") + "\r\n";
}

// ---------------------------------------------------------------
// CSV保存(Excelで開くため先頭にBOMを付ける)
// ---------------------------------------------------------------
function saveCsv(csv, fileName) {
  const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8;" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");

  link.href = url;
  link.download = fileName;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

  // 即時 revoke するとダウンロードが中断されるブラウザがある
  setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
}

// ---------------------------------------------------------------
// メッセージ表示
// ---------------------------------------------------------------
function showMessage(text, isError) {
  const el = document.getElementById("message");
  el.textContent = text;
  el.className = isError ? "error" : "";
}

// ---------------------------------------------------------------
// 表示のクリア(古い結果を残さない)
// ---------------------------------------------------------------
function clearOutput() {
  currentCsv = "";
  document.getElementById("saveButton").disabled = true;

  const canvas = document.getElementById("waveCanvas");
  canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
  document.getElementById("statsTable").hidden = true;
}

// ---------------------------------------------------------------
// 現在のBINを、現在の設定で描き直す
// 設定欄を変更したときにも呼ぶ。これをしないと、画面の設定と
// 保存されるCSVの中身が食い違う
// ---------------------------------------------------------------
function render() {
  clearOutput();

  if (!currentBuffer) return;

  const config = getConfig();

  if (!(config.pointCount > 0) || !(config.samplePeriod > 0)) {
    showMessage("点数とサンプリング周期には正の数を入力してください。", true);
    return;
  }

  // 空欄だと parseFloat が NaN を返し、全データが NaN になる
  if (!Number.isFinite(config.scale) || config.scale === 0) {
    showMessage("スケール係数には0以外の数値を入力してください。", true);
    return;
  }

  try {
    const wave = parseBin(currentBuffer, config);

    drawWave(wave.ch1, wave.ch2, config);
    currentCsv = buildCsv(wave.ch1, wave.ch2, config);

    document.getElementById("saveButton").disabled = false;
    showMessage(
      currentFileName + " を表示中(" + config.pointCount + "点 × 2ch)",
      false
    );
  } catch (e) {
    showMessage(e.message, true);
  }
}

// ---------------------------------------------------------------
// ファイル処理の入口
// ---------------------------------------------------------------
async function handleFile(file) {
  try {
    currentBuffer = await file.arrayBuffer();
    currentFileName = file.name;
  } catch (e) {
    currentBuffer = null;
    clearOutput();
    showMessage("ファイルを読み込めませんでした。", true);
    return;
  }

  render();
}

// ---------------------------------------------------------------
// イベント登録
// ---------------------------------------------------------------
document.getElementById("fileInput").addEventListener("change", function (event) {
  const file = event.target.files[0];
  if (file) handleFile(file);
});

document.getElementById("saveButton").addEventListener("click", function () {
  if (currentCsv) saveCsv(currentCsv, "wave_data.csv");
});

// 設定を変更したら、読み込み済みのBINを描き直す
["pointCount", "samplePeriod", "scale", "unit"].forEach(function (id) {
  document.getElementById(id).addEventListener("change", render);
});

const dropArea = document.getElementById("dropArea");

dropArea.addEventListener("dragover", function (event) {
  event.preventDefault();
  dropArea.classList.add("over");
});

dropArea.addEventListener("dragleave", function () {
  dropArea.classList.remove("over");
});

dropArea.addEventListener("drop", function (event) {
  event.preventDefault();
  dropArea.classList.remove("over");
  const file = event.dataTransfer.files[0];
  if (file) handleFile(file);
});

// 領域外にドロップしたときにブラウザがファイルを開いてしまうのを防ぐ
window.addEventListener("dragover", function (e) { e.preventDefault(); });
window.addEventListener("drop", function (e) { e.preventDefault(); });
</script>

</body>
</html>

ソースファイルおよびサンプルBINデータは以下よりzip形式でダウンロードが可能です。
ソース内の設定値(1ch点数 2000/サンプリング周期 0.1ms)のまま読み込めます。

PLC屋さんとPC屋さんで決めておくべきこと

実は今回の記事で一番お伝えしたいのは、コードよりもこちらです。
BINデータの受け渡しで手が止まる原因は、プログラムが書けないことではなく、フォーマットが決まっていないことがほとんどです。

「BINで4000word入っています」

これだけでは、PC側は1バイトも正しく読めません。最低限、次の項目は仕様書に明記しておくことをおすすめします。

項目今回の例決めていないと起こること
データ型signed 16bitマイナス値が巨大な正数になる
1データのbyte数2 byte全データがずれる
バイト順Little Endian全データが別の値になる
Ch数2波形が混ざる
Ch1開始位置word 0
Ch1点数2000Ch境界がずれる
Ch2開始位置word 2000
Ch2点数2000
総データ数4000 word
ファイルサイズ8000 byte欠損に気付けない
サンプリング周期0.1 ms時間軸が作れない
スケール係数・単位0.001 G/digit物理量に換算できない

この表を装置の仕様書に1ページ入れておくだけで、後工程のトラブルはかなり減ります。 特にサンプリング周期とスケール係数は、PLC側では自明でもファイルの中には入っていないため、抜けやすい項目です。

なぜWebブラウザで作るのか

PLCデータの解析なら、C#、Python、C++、Excel VBAなど選択肢はいろいろあります。 その中でHTML + JavaScriptを選ぶ理由は、実行環境の制約が一番少ないことです。

客先のPCでは、こんな状況が珍しくありません。

  • 管理者権限がない
  • 新しいアプリケーションをインストールできない
  • Pythonのライブラリを追加できない
  • 開発環境が入っていない

HTMLファイル1つなら、ブラウザで開くだけで動きます。

ただし「どのブラウザでも動く」わけではありません

ここは正直に書いておきます。今回使用しているFile.arrayBuffer()は比較的新しいAPIで、Internet Explorerでは動作しません。 Chrome / Edge / Firefox / Safari はいずれもこのAPIに対応していますが、当社で動作を確認したのはChromeとFirefoxのみです。他のブラウザで使用する場合は、お使いの環境で一度確認してください。

ファイル読み込みだけであれば、古い環境でも動くFileReaderという選択肢もあります。

const reader = new FileReader();
reader.onload = function () {
    readBinFile(reader.result);   // reader.result が ArrayBuffer
};
reader.readAsArrayBuffer(file);

ただし、今回の完成版はconst / letasync / awaitといったES6以降の機能を全体で使っているため、 ファイル読み込みの部分だけ差し替えてもIEでは動きません。 IE対応が要件に入る現場では、ES5で書き直すか、素直にC#などで作る判断になります。

客先PCのブラウザが何なのかは、持ち込みの可否と合わせて事前に確認しておきましょう。 今回の完成版は外部ライブラリの読み込みもサーバー通信も行っていないため、解析対象のBINファイルが外部へ送信されることはありません。 セキュリティ部門への説明という意味でも、この「外に出ない」は効きます。

ただし現実的な注意点として、そもそも客先へのファイル持ち込み自体が制限されている現場も多くあります。 USBメモリの持ち込み禁止、外部ファイルの実行禁止など、ルールは現場ごとに違います。 「インストール不要だから大丈夫」と考えず、持ち込みの可否は事前に確認しておきましょう。 ソースが1ファイルで完結していると、中身を見せて説明しやすいという利点もあります。

まとめ

今回は、PLCで取得したBIN形式の波形データを、JavaScriptだけでCSV化・グラフ表示してみました。

処理の流れはシンプルです。

PLC
 ↓ BINファイル
ArrayBuffer(生のbyte列)
 ↓ DataView
signed 16bit / Little Endianで変換
 ↓
Ch1・Ch2に分割
 ↓
時間軸・物理量へスケーリング
 ↓
CSV保存 + 波形グラフ表示

ポイントは、コードそのものよりも

  • signed / unsigned
  • 16bit / 32bit
  • Little Endian / Big Endian
  • word / byte
  • サンプリング周期とスケール係数

を、PLC側とPC側で最初に合意しておくことです。 ここさえ決まっていれば、今回のようにHTMLファイル1つで実用的な解析ツールを構成できます。

「専用ソフトを作るほどではないけれど、取った波形をすぐ確認したい」そんな場面では、Web技術はなかなか便利な選択肢です。


私たちNSIは、PLC制御と画像処理の両方を手がけているからこそ、 設備側でデータを取る仕組みと、PC側で見える化する仕組みを一貫してご提案できるのが強みです。

  • 設備から取得したデータの収集・蓄積の仕組みづくり
  • 現場データの見える化・分析
  • 既存設備からのデータ取り出し

このあたりでお困りのことがありましたら、お気軽にご相談ください。

ここまで読んでいただき、ありがとうございました。
ご質問・ご要望・ご相談などは、下記お問い合わせフォームからお気軽にご連絡ください。
https://www.net-nsi.co.jp/toiawase.html

タイトルとURLをコピーしました