1. はじめに
ControlDeskでエクスポートしたcsvをサクッとグラフにするためのMATALBのmファイルの書き方を説明します.
table形式にするまではdSPACE特有の話ですが,table変換後の部分は汎用的に使えます.
何万行もあるデータをExcelで処理するのはナンセンスですからね.
今回は以下の内容で便利ツールを構築してみました.
(1) ファイルから表頭と計測値の部分を抽出しテーブル作成
(2) どの変数をグラフにするか選べるUI(オプション付き)
(3) グラフ描画
2. 要件
- 変数名(Simulinkのブロック名)をヘッダーとしたtableを生成
- グラフ描画オプションインタフェース
- x軸とy軸の変数を選択
- y軸は複数選択可能
- 第2軸
- 論文に貼れるような形式
- 拡張モードに切り替えるチェックボックスを実装
- x軸のレンジを変数の立ち上がりや閾値比較などで抽出したインデックスを基に決定
- グラフ画像出力
- (未実装)フィルタ機能
- (未実装)複数ファイル処理
3. 記述
% ------------------------------------------------------------------------
% + Automatic graph drawing program for dSPACE ControlDesk
% + version: 1.1
% + date: 2025/01/11
% + creator: Ryunosuke Sawahashi
% +
% + Confirmed simulink version: R2020a, R2022a, R2023b
% ------------------------------------------------------------------------
% import data
%TODO: 複数のファイルを比較するモードを作成 ファイル個数に応じてsubplotの分割数などを可変に
disp("select csv file.");
[file, path] = uigetfile('*.csv');
% Cancel: skipping
if isequal(file, 0)
disp("...canceled.");
return;
end
% ファイル名の"_"までの文字列を抽出
fileNameHead = extractBefore(file, ".");
%% ------------------------------------------------------------------------
% 前処理
% セルとして読み込む
csvData = readcell(append(path, file));
% A列に"path"が入っているセルを検索
[row, col] = find(strcmp(csvData(:,1), 'path'));
% 対象のセルが見つかった場合
if ~isempty(row)
% その行のB列より右側をヘッダーとして取得
headers = csvData(row, col+1:end);
% time [s]ラベルだけデフォルトでついていないので追加
headers{1} = 'time';
% "Model Root/"を削除
headers = regexprep(headers, 'Model Root/', '');
% エスケープされているダッシュ記号(/)を Example) [m//s] -> [m/s]
headers = regexprep(headers, '//', '/');
% 数値データの開始行 ("path"行の6個下)
dataStartRow = row + 6;
% 数値データを取得
numericData = csvData(dataStartRow:end, col+1:col+numel(headers));
% 数値に変換
%TODO: 指数を少数に変換 0秒近傍に起こりやすいE-01
% 数値データを行列に変換
numericData = cell2mat(numericData);
% テーブル形式に変換
T = array2table(numericData, 'VariableNames', headers);
% 結果を表示
head(T);
% メモリ解放
clear csvData;
clear numericData;
% インタフェース生成
createInterface(T, fileNameHead);
else
disp('ファイル構造が対象外です.');
end
%% ------------------------------------------------------------------------
% インターフェース生成関数
function createInterface(T, fileNameHead)
% MATLAB.exeの現在の位置を取得
matlabPos = get(groot, 'MonitorPositions');
currScreen = matlabPos(1,:);
% インタフェースの表示位置調整
uifigW = 300; % width
uifigH = 300; % height
uifigPos = [
currScreen(1) + 50, ... % x (origin: bottom-left)
currScreen(2) + currScreen(4) - uifigH - 100, ... % y
uifigW, ...
uifigH
];
% インターフェースの作成
uifig = uifigure('Name', 'Graph Options', 'Position', uifigPos);
% ウィンドウサイズ変更時にui要素をリサイズしない
uifig.AutoResizeChildren = 'off';
% ヘッダーの取得
headers = T.Properties.VariableNames;
% x軸選択用ドロップダウン
uilabel(uifig, 'Text', '-------- Axis ---------', 'Position', [20, uifigH-30, 100, 30]);
uilabel(uifig, 'Text', 'X-Axis', 'Position', [20, uifigH-50, 100, 30]);
ui.xDropdown = uidropdown(uifig, 'Items', headers, 'Position', [20, uifigH-80, 150, 30]);
% y軸選択用リストボックス
uilabel(uifig, 'Text', 'Y-Axis (Multiselect: ctrl)', ...
'Position', [20, uifigH-120, 150, 30], ...
'Tooltip', 'shiftやctrlで複数選択が可能です');
ui.yListbox = uilistbox(uifig, 'Items', headers, 'Multiselect', 'on', ...
'Position', [20, uifigH-215, 150, 100]);
% グラフ自動保存チェックボックス
ui.exportPictChekbox = uicheckbox(uifig, 'Text', 'Export picture', ...
'Value', true, 'Position', [20, uifigH-250, 100, 30]);
% 第二軸選択用のチェックボックスを追加 (最初は非表示)
exModeTag = "exMode";
% ラベル
uilabel(uifig, 'Text', '1st', ...
'Position', [190, uifigH-120, 30, 30], ...
'Tag', exModeTag, 'Visible', 'off');
uilabel(uifig, 'Text', '2nd', ...
'Position', [220, uifigH-120, 30, 30], ...
'Tag', exModeTag, 'Visible', 'off');
% チェックボックスの配置位置
checkboxPos = getCheckboxPositions(ui.yListbox.Position, length(headers));
% チェックボックスオブジェクト
ui.checkboxes = gobjects(2, length(headers));
% チェックボックス生成
for i = 1:length(headers)
ui.checkboxes(1,i) = uicheckbox(uifig, 'Text', '', 'Value', true, ...
'Position', checkboxPos(i, :), ...
'Tag', sprintf('%s_%s_1', exModeTag, headers{i}), ...
'Visible', 'off', ...
'ValueChangedFcn', @(cb, event) syncCheckboxes(cb, uifig));
ui.checkboxes(2,i) = uicheckbox(uifig, 'Text', '', 'Value', false, ...
'Position', [checkboxPos(i, 1) + 30, checkboxPos(i, 2), 50, 20], ...
'Tag', sprintf('%s_%s_2', exModeTag, headers{i}), ...
'Visible', 'off', ...
'ValueChangedFcn', @(cb, event) syncCheckboxes(cb, uifig));
end
% y軸リストの選択に応じてチェックボックスをグレーアウト
ui.yListbox.ValueChangedFcn = @(lb, event) updateCheckboxStates(lb, headers, ui.checkboxes);
% Font
% Font選択用ドロップダウン
fontList = {'Times New Roman', 'Segoe UI'}; % 必要があれば都度追加
uilabel(uifig, 'Text', '-------- Font ---------', 'Position', [300, uifigH-30, 100, 30]);
uilabel(uifig, 'Text', 'Font type', 'Position', [300, uifigH-50, 100, 30]);
ui.fontDropdown = uidropdown(uifig, 'Items', fontList, 'Position', [300, uifigH-80, 150, 30]);
% Font size
fontSizeList = arrayfun(@num2str, 6:2:24, 'UniformOutput', false); % 8から24までのサイズ
uilabel(uifig, 'Text', 'Font Size', 'Position', [300, uifigH-110, 100, 30]);
ui.fontSizeDropdown = uidropdown(uifig, 'Items', fontSizeList, 'Value', '14', 'Position', [300, uifigH-140, 150, 30]);
% x軸レンジ設定用ドロップダウン
% グラフの左端の基準を指令値の立上りや立下りなどから決定
riseFallOptions = {'----', 'Rise', 'Fall', '>', '>=', '<', '<=', '=='};
uilabel(uifig, 'Text', '-------- Signal --------', 'Position', [300, uifigH-170, 120, 30]);
% Start signal
uilabel(uifig, 'Text', 'Start', 'Position', [300, uifigH-190, 120, 30]);
ui.rangeStartDropdown = uidropdown(uifig, 'Items', headers, 'Position', [300, uifigH-220, 150, 30]);
% 閾値入力フィールド
uilabel(uifig, 'Text', 'Threshold', 'Position', [550, uifigH-190, 100, 30]);
ui.thresholdStartField = uieditfield(uifig, 'numeric', ...
'Position', [550, uifigH-220, 80, 30], ...
'Value', 0, 'Enable', 'off');
% 比較タイプ選択ドロップダウン
uilabel(uifig, 'Text', 'Edge Type', 'Position', [450, uifigH-190, 100, 30]);
ui.riseFallStartDropdown = uidropdown(uifig, 'Items', riseFallOptions, ...
'Position', [450, uifigH-220, 100, 30], ...
'Value', '----', ...
'ValueChangedFcn', @(dd,event) updateThresholdField(dd, ui.thresholdStartField));
% End signal
uilabel(uifig, 'Text', 'End', 'Position', [300, uifigH-250, 120, 30]);
ui.rangeEndDropdown = uidropdown(uifig, 'Items', headers, 'Position', [300, uifigH-280, 150, 30]);
% 閾値入力フィールド
uilabel(uifig, 'Text', 'Threshold', 'Position', [550, uifigH-250, 100, 30]);
ui.thresholdEndField = uieditfield(uifig, 'numeric', ...
'Position', [550, uifigH-280, 80, 30], ...
'Value', 0, 'Enable', 'off');
% 比較タイプ選択ドロップダウン
uilabel(uifig, 'Text', 'Edge Type', 'Position', [450, uifigH-250, 100, 30]);
ui.riseFallEndDropdown = uidropdown(uifig, 'Items', riseFallOptions, ...
'Position', [450, uifigH-280, 100, 30], ...
'Value', '----', ...
'ValueChangedFcn', @(dd,event) updateThresholdField(dd, ui.thresholdEndField));
% TODO: 複数ファイル(例えば被験者)を同じグラフに出力機能とかつけたい
% TODO: x軸目盛
% TODO: グラフエリアの枠線をしっかりつける 第二軸側と上側が消える
% TODO: 軸ラベル入力欄
% TODO: グラフの描画順序 z-index filter->rawの順だとノイズで線が見えなくなる
% EX Mode用チェックボックス
extraModeCheckbox = uicheckbox(uifig, 'Text', 'EX Mode', ...
'Position', [180, uifigH-30, 100, 30], ...
'ValueChangedFcn', @(cb, event) toggleExtraMode(cb, uifig, ui.checkboxes, headers));
% 生成ボタン
uibutton(uifig, 'Text', 'Generate', ...
'Position', [50, 20, 200, 30], ...
'BackgroundColor', [0.2, 0.6, 1.0], ...
'FontColor', [1, 1, 1], ...
'ButtonPushedFcn', @(btn, event) confirmSelection(ui, uifigPos, T, headers, fileNameHead));
%TODO: グラフ描画オプションを保存 and Loadできるようにする
% ファイル出力とか
end
%% ------------------------------------------------------------------------
% グラフ描画
% 生成ボタンのコールバック関数
function confirmSelection(ui, uifigPos, T, headers, fileNameHead)
xVar = ui.xDropdown.Value; % 選択されたx軸
yVars = ui.yListbox.Value; % 選択されたy軸 (複数)
yVarIndex = zeros(1, length(yVars)); % 選択されたy軸はheadersの何番目か
fontName = ui.fontDropdown.Value; % 選択されたフォント
fontSize = str2num(ui.fontSizeDropdown.Value)
disp(['X-Axis: ', xVar]);
disp('Y-Axis: ');
disp(yVars);
% 軸ラベル自動生成用
for i = 1:length(yVars)
yVarIndex(i) = find(strcmp(headers, yVars{i}));
end
% 第2軸フラグ作成
hasSecondAxis = false;
for i = 1:length(headers)
if (ui.checkboxes(2, i).Value)
hasSecondAxis = true;
end
end
% Signal処理
% Start条件の取得
rangeStartVar = ui.rangeStartDropdown.Value;
riseFallStartType = ui.riseFallStartDropdown.Value;
thresholdStart = ui.thresholdStartField.Value;
% End条件の取得
rangeEndVar = ui.rangeEndDropdown.Value;
riseFallEndType = ui.riseFallEndDropdown.Value;
thresholdEnd = ui.thresholdEndField.Value;
% Startインデックスを取得
startIndex = getIndexFromSignal(T, rangeStartVar, riseFallStartType, thresholdStart);
% Endインデックスを取得
endIndex = getIndexFromSignal(T, rangeEndVar, riseFallEndType, thresholdEnd);
% endIndexとstartIndexが同じもしくは小さい場合endIndexを無効
% (----の場合も含む. endが----の場合に頻発)
if (endIndex <= startIndex)
endIndex = height(T);
end
disp(startIndex);
disp(endIndex);
% x軸のデータ値をオフセット
xData = T.(xVar) - T.(xVar)(startIndex);
% インデックスからrange設定用の数値に変換
xStartLim = xData(startIndex);
xEndLim = T.(xVar)(endIndex-startIndex);
%TODO: 時間のように線形に増加する場合はうまくいくが,
% 変位-力のように2次元座標を彷徨う場合にxlimがうまくいかない
% ***********************************************************************
% ***********************************************************************
% **************** Main Plot ****************
% ***********************************************************************
% ***********************************************************************
% プロット用ウィンドウ生成
% ウィンドウタイトルやツールバーあたりをまとめた縦幅: 80くらい (4Kdisplay)
figure('position', [uifigPos(1), uifigPos(2)-80-300, 400, 300]);
% 選択されたデータのプロット
% 第1軸に描画(第2軸が設定されていなければ無視)
if hasSecondAxis
yyaxis left;
end
% 軸ラベルになるheadersのインデックス
axisLabelNum = 1;
hold on;
for i = 1:length(yVars)
if (ui.checkboxes(2, yVarIndex(i)).Value == false)
plot(xData, T.(yVars{i}), 'DisplayName', yVars{i});
axisLabelNum = i;
end
end
ax = gca; % get current axes: 現在の軸オブジェクト(Axes)を取得
% 目盛
ax.FontName = fontName; % 目盛のフォント
ax.FontSize = fontSize - 4; % 目盛のフォントサイズ
% x軸ラベル
xlabel(xVar, 'FontSize', fontSize, 'FontName', fontName);
% x軸レンジ
% 範囲指定のインデックス付が何かしらの原因で無効になった場合はスキップ
if (startIndex ~= 1 && endIndex ~= height(T))
xlim([xStartLim, xEndLim]);
end
% x軸目盛
% 0から50までを10間隔の場合
% xticks(0:10:50);
% xticks(xtickStart:xtickSpan:xtickEnd);
% Y軸ラベルはヘッダーの番号が後ろのものを採用
ylabel(yVars(axisLabelNum), 'FontSize', fontSize, 'FontName', fontName);
% 第2軸に描画
if hasSecondAxis
yyaxis right;
for i = 1:length(yVars)
if (ui.checkboxes(2, yVarIndex(i)).Value)
plot(T.(xVar), T.(yVars{i}), 'DisplayName', yVars{i});
axisLabelNum = i;
end
end
ylabel(yVars(axisLabelNum), 'FontSize', fontSize, 'FontName', fontName);
end
hold off;
% グリッド
grid on;
ax.XGrid = 'off'; % グリッド縦線削除
ax.YGrid = 'on'; % グリッド横線
ax.GridLineStyle = '-.'; % 一点鎖線 '-':実線 '--':破線 ':':点線 '-.':一点鎖線
ax.GridColor = [0, 0, 0]; % 黒色
ax.GridAlpha = 1.0; % グリッド線の透明度
% NOTE: グリッドの線幅を変えたい場合はYTickのLineWidthを設定してオリジナルグリッドを生成する必要あり
% 凡例
legend show;
%TODO: 凡例の位置を線に重ならない位置にする.
% 場合によってはy軸のylimを広げる.
% 軸ラベルを黒色
if hasSecondAxis
ax.YAxis(1).Color = [0 0 0];
ax.YAxis(2).Color = [0 0 0];
end
% 画像として保存する場合
% NOTE: 解像度を指定する場合はdpi指定でprint(gcf, '-djpeg', 'figname.jpg', '-r600')
% TODO: 同名ファイルが存在する場合の処理 時刻をファイル名に追記?
if ui.exportPictChekbox.Value
saveas(gcf, append(fileNameHead, '.png'));
end
end
% Signalのインデックスを取得する関数
% <augment>
% T: tableデータ
% rangeVar: 対象の列名
% conditionType: 'Rise', 'Fall', '>', '>=', '<', '<=', '=='
% threshold: 閾値
function index = getIndexFromSignal(T, rangeVar, conditionType, threshold)
data = T.(rangeVar);
index = 1;
switch conditionType
case '----' % 初期値のまま
case 'Rise' % 立ち上がり
index = find(diff(data) > 0, 1);
case 'Fall' % 立ち下がり
index = find(diff(data) < 0, 1);
case '>' % 大なり
index = find(data > threshold, 1);
case '>=' % 大なりイコール
index = find(data >= threshold, 1);
case '<' % 小なり
index = find(data < threshold, 1);
case '<=' % 小なりイコール
index = find(data <= threshold, 1);
case '==' % 等しい
index = find(data == threshold, 1);
otherwise
error('Invalid condition type: %s', conditionType);
end
% 条件に合うデータが見つからない場合
if isempty(index)
warning('No matching index found for condition: %s', conditionType);
index = 1; % デフォルトで最初のインデックスを返す
end
end
%% ------------------------------------------------------------------------
% UI用関数群
% Extra Modeを切り替える関数
function toggleExtraMode(extraModeCheckbox, fig, checkboxes, headers)
exModeTag = "exMode";
% 現在のウィンドウの左上位置を取得
currLeftTop = [fig.Position(1), fig.Position(2) + fig.Position(4)];
% Extraモード時のみ描画するオブジェクト
exLabels = findobj(fig, 'Tag', exModeTag);
if extraModeCheckbox.Value
% Extra Mode: ON
% コンポーネントを表示
for i = 1:length(headers)
checkboxes(1, i).Visible = 'on';
checkboxes(2, i).Visible = 'on';
end
for i = 1:length(exLabels)
exLabels(i).Visible = 'on';
end
% ウィンドウを拡大
newFigW = 640; % 横幅を拡大
newFigH = 300; % 縦幅を拡大
else
% Extra Mode: OFF
% コンポーネントを非表示
for i = 1:length(headers)
checkboxes(1, i).Visible = 'off';
checkboxes(2, i).Visible = 'off';
end
for i = 1:length(exLabels)
exLabels(i).Visible = 'off';
end
% ウィンドウを元のサイズに戻す
newFigW = 300;
newFigH = 300;
% TODO: 初期値に戻す
% signalとかは----に
end
% ウィンドウの左上の位置を変えないようにウィンドウ原点の左下の位置調整
newFigX = currLeftTop(1);
newFigY = currLeftTop(2) - newFigH;
fig.Position = [newFigX, newFigY, newFigW, newFigH];
end
% 第2軸チェックボックスの位置を計算する関数
function positions = getCheckboxPositions(listboxPosition, numItems)
% y軸リストボックスの右側にチェックボックスを配置する位置を計算
xOffset = 170; % リストボックスからの水平オフセット
yOffset = listboxPosition(4) / numItems; % 各アイテムの垂直間隔
positions = zeros(numItems, 4);
for i = 1:numItems
x = listboxPosition(1) + xOffset;
y = listboxPosition(2) + listboxPosition(4) - i * yOffset + 5; % 調整
positions(i, :) = [x, y, 20, 20];
end
end
% 第2軸チェックボックスの状態を同期する関数
function syncCheckboxes(sourceCheckbox, fig)
% 自分自身のタグ
tag = sourceCheckbox.Tag;
% 1stか2ndか識別
order = str2num(tag(end));
if (order == 1)
targetTag = append(tag(1:end-1), "2");
elseif(order == 2)
targetTag = append(tag(1:end-1), "1");
end
targetCheckbox = findobj(fig, 'Tag', targetTag);
% (排他処理)相手のチェックボックスがtrueの場合二重でtrueになるのを防止
if targetCheckbox.Value
targetCheckbox.Value = false;
end
end
% y軸リスト選択に応じてチェックボックスの状態を更新
function updateCheckboxStates(yListbox, headers, checkboxes)
selectedVars = yListbox.Value;
for i = 1:length(headers)
if ismember(headers{i}, selectedVars)
checkboxes(1, i).Enable = 'on';
checkboxes(2, i).Enable = 'on';
else
checkboxes(1, i).Enable = 'off';
checkboxes(2, i).Enable = 'off';
end
end
end
function updateThresholdField(riseFallDropdown, thresholdField)
% Dropdownの値を取得
selectedValue = riseFallDropdown.Value;
% 'Rise' または 'Fall' の場合はしきい値フィールドを無効
if ismember(selectedValue, {'Rise', 'Fall', '----'})
thresholdField.Enable = 'off'; % 無効
else
thresholdField.Enable = 'on'; % 有効
end
end
4. 解説
ヘッダーと数値データ抽出
ControlDesk 7.5からエクスポートしたcsvの中身はこのような形になっています.
(1) A列からpathの文字の入ったセルを探索
(2) その行の右端までヘッダー名として抽出
(3) ヘッダー名の調整
(4) 6列下の行から最下点までを数値データとして抽出
(5) cell -> matrix -> tableに変換
今思うと,"trace_value"の入ったセルをA列で探索したほうが柔軟性高いですね...
UI
わざわざUIなんて代物作らなくてもいい訳ですが,汎化性向上を目的に導入してみました.
ちょっとだけ凝ってます.
UIが煩雑すぎても認知負荷が高いので,デフォルトはさくっとグラフ描画できるようにして少し高度に設定したい場合はExtraモードに入ってもらうという設計思想です.
plot
UIに軸ラベル入力欄を作っても良かったのですが,ひとまずY軸で選ばれた変数名を軸ラベルに設定しました.
計測値とフィルタされた値を載せることを考慮して名前が短い方を採用したのがちょっとしたポイント.
そのうち項目増やしておきます.
それにしてもMATLABのplotって第2軸の扱いが面倒臭いと感じています.何かと使いますからおざなりにできないんですよね...
NIRSデータとかイベント駆動に対応できるように変数で背景を半透明で塗りつぶす機能も欲しいですね.
Excelでやると一筋縄ではいかなかった思い出があります.これもそのうち実装.
5. その他
タスクバーのサイズ取得
javaがインストールされているPCであればWindowsのタスクバーのサイズなどを取得することもできます.plotをディスプレイにきっちり収めたい方向け.
% TODO: javaが存在しない場合のエラー処理
toolkit = java.awt.Toolkit.getDefaultToolkit();
scr_size = toolkit.getScreenSize(); % display size
jframe = javax.swing.JFrame;
insets = toolkit.getScreenInsets(jframe.getGraphicsConfiguration()); % taskbar size
readtable()
csvがヘッダーと数値データのみの整っている場合はreadtableを使うことが多いと思います.
今回はSimulinkのブロック名をそのまま使えるように考慮しましたが,以下の手順でtableを生成するとヘッダーの記号はすべてアンダーバー(_)になります.
例)current_[A] -> current__A_
(1) dSAPCEでエクスポートしたcsvからヘッダーと数値データのみを抽出したcsvを生成
(2) T = readtable(append(path, file), 'NumHeaderLines', 0);
6. 注意事項
- ControlDeskの(特に古い)バージョンや環境によってcsvファイルに記載される情報が変わるので注意が必要です.
- リファクタリング全然できていません.
- 重いファイルや変数が多いファイルで試していないのでUIが崩れたりメモリリークしたりするかもしれません.その辺は適宜改善していただければ思います.
7. バージョン
2024/01/09 v1.0 初期版
2024/01/11 v1.1 画像出力機能追加
2024/01/XX v1.2 論文用に体裁を整える(最終)
2024/02/XX v1.3 近似追加
2024/02/XX v2.0 複数ファイルの処理に対応
2024/03/XX v2.1 フィルタ追加