Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions diff.html.tpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#title#</title>
<style>
body { margin: 0; font: 14px sans-serif; color: #222; background: #fff; display: flex; flex-direction: column; height: 100vh; }
header { padding: 8px 12px; border-bottom: 1px solid #ccc; }
h1 { font-size: 16px; margin: 0 0 4px; overflow-wrap: anywhere; }
main { flex: 1; display: flex; min-height: 0; }
nav { width: 340px; flex-shrink: 0; display: flex; flex-direction: column; border-right: 1px solid #ccc; }
input { margin: 8px; padding: 4px; }
ul { margin: 0; padding: 0; list-style: none; overflow-y: auto; flex: 1; }
li { padding: 3px 8px; cursor: pointer; overflow-wrap: anywhere; }
li:hover { background: #eee; }
li.selected { background: #cfe2ff; }
section { flex: 1; min-width: 0; padding: 8px; display: flex; flex-direction: column; }
canvas { flex: 1; min-height: 0; width: 100%; }
#legend span { margin-right: 16px; }
.reference { color: #1f6fd0; }
.result { color: #d03c3c; }
@media (max-width: 700px) { main { flex-direction: column; } nav { width: auto; height: 35%; border-right: 0; border-bottom: 1px solid #ccc; } }
</style>
</head>
<body>
<header>
<h1>#title#</h1>
<span id="summary"></span>
<a id="omplot" hidden>Tube comparison in OMPlot</a>
</header>
<main>
<nav><input id="filter" type="search" placeholder="Filter variables"><ul id="vars"></ul></nav>
<section>
<div id="legend"><span class="reference">&#9644; reference</span><span class="result">&#9644; result</span><span id="stats"></span></div>
<canvas id="plot"></canvas>
<small>Drag to zoom the time axis, double-click to reset.</small>
</section>
</main>
<script>
const FILES = { result: "#result#", reference: "#reference#" };

async function gunzip(base64) {
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
return new Response(stream).arrayBuffer();
}

// MATLAB v4 result file, as written by filterSimulationResults
function readMatrices(buffer) {
const view = new DataView(buffer), matrices = {};
const readers = { 0: ['getFloat64', 8], 1: ['getFloat32', 4], 2: ['getInt32', 4], 3: ['getInt16', 2], 4: ['getUint16', 2], 5: ['getUint8', 1] };
let pos = 0;
while (pos < buffer.byteLength) {
const [mopt, rows, cols, , nameLength] = [0, 4, 8, 12, 16].map((o) => view.getInt32(pos + o, true));
pos += 20;
const name = new TextDecoder().decode(new Uint8Array(buffer, pos, nameLength - 1));
pos += nameLength;
const [get, size] = readers[Math.floor(mopt / 10) % 10];
const data = new Float64Array(rows * cols);
for (let i = 0; i < data.length; i++, pos += size) data[i] = view[get](pos, true);
matrices[name] = { rows, cols, data };
}
return matrices;
}

function readResult(buffer) {
const m = readMatrices(buffer);
const aclass = m.Aclass;
const at = (matrix, row, col) => matrix.data[col * matrix.rows + row];
const text = (length, get) => String.fromCharCode(...Array.from({ length }, (_, k) => get(k)));
// binTrans: a column per time step in data_*, a column per variable in name and dataInfo. binNormal: the opposite.
const trans = text(8, (k) => at(aclass, 3, k)) === 'binTrans';
const info = (matrix, variable, k) => (trans ? at(matrix, k, variable) : at(matrix, variable, k));
const value = (matrix, variable, step) => (trans ? at(matrix, variable, step) : at(matrix, step, variable));
const count = trans ? m.name.cols : m.name.rows, width = trans ? m.name.rows : m.name.cols;
const steps = trans ? m.data_2.cols : m.data_2.rows, series = {};
for (let i = 0; i < count; i++) {
const name = text(width, (k) => info(m.name, i, k)).replace(/[\0 ]+$/, '');
// Table 0 is the abscissa, the first variable of data_2. Table 1 holds constants.
const index = info(m.dataInfo, i, 0), col = info(m.dataInfo, i, 1);
const table = index === 1 ? m.data_1 : m.data_2;
const c = Math.abs(col) - 1, sign = Math.sign(col) || 1;
series[name] = Float64Array.from({ length: steps }, (_, k) => sign * value(table, c, index === 1 ? 0 : k));
if (index === 0 || /^time$/i.test(name)) series.time = series[name];
}
return series;
}

function interpolate(xs, ys, x) {
let lo = 0, hi = xs.length - 1;
if (x <= xs[0]) return ys[0];
if (x >= xs[hi]) return ys[hi];
while (hi - lo > 1) { const mid = (lo + hi) >> 1; if (xs[mid] <= x) lo = mid; else hi = mid; }
return xs[hi] === xs[lo] ? ys[hi] : ys[lo] + (ys[hi] - ys[lo]) * (x - xs[lo]) / (xs[hi] - xs[lo]);
}

function maxDifference(result, reference, name) {
let max = 0;
reference.time.forEach((t, i) => { max = Math.max(max, Math.abs(interpolate(result.time, result[name], t) - reference[name][i])); });
return max;
}

function niceTicks(min, max, count) {
const step = (max - min) / count || 1, base = 10 ** Math.floor(Math.log10(step));
const nice = [1, 2, 5, 10].map((f) => f * base).find((s) => s >= step);
const ticks = [];
for (let t = Math.ceil(min / nice) * nice; t <= max + nice * 1e-9; t += nice) ticks.push(Math.abs(t) < nice * 1e-9 ? 0 : t);
return ticks;
}

function draw(canvas, curves, range, dragFrom, dragTo) {
const ratio = window.devicePixelRatio || 1, width = canvas.clientWidth, height = canvas.clientHeight;
canvas.width = width * ratio;
canvas.height = height * ratio;
const g = canvas.getContext('2d');
g.scale(ratio, ratio);
const left = 80, right = 16, top = 8, bottom = 28;
let min = Infinity, max = -Infinity;
for (const { xs, ys } of curves) {
xs.forEach((x, i) => { if (x >= range[0] && x <= range[1] && Number.isFinite(ys[i])) { min = Math.min(min, ys[i]); max = Math.max(max, ys[i]); } });
}
if (!(min < max)) { const v = Number.isFinite(min) ? min : 0, d = Math.abs(v) * 0.1 || 1; min = v - d; max = v + d; }
const X = (x) => left + (x - range[0]) / (range[1] - range[0] || 1) * (width - left - right);
const Y = (y) => height - bottom - (y - min) / (max - min) * (height - top - bottom);
g.font = '12px sans-serif';
g.strokeStyle = '#e4e4e4';
g.fillStyle = '#444';
g.textAlign = 'center';
for (const t of niceTicks(range[0], range[1], 8)) {
g.beginPath(); g.moveTo(X(t), top); g.lineTo(X(t), height - bottom); g.stroke();
g.fillText(Number(t.toPrecision(6)), X(t), height - bottom + 16);
}
g.textAlign = 'right';
for (const t of niceTicks(min, max, 6)) {
g.beginPath(); g.moveTo(left, Y(t)); g.lineTo(width - right, Y(t)); g.stroke();
g.fillText(Number(t.toPrecision(5)), left - 6, Y(t) + 4);
}
g.save();
g.beginPath(); g.rect(left, top, width - left - right, height - top - bottom); g.clip();
for (const { xs, ys, color } of curves) {
g.strokeStyle = color;
g.lineWidth = 1.5;
g.beginPath();
xs.forEach((x, i) => g.lineTo(X(x), Y(ys[i])));
g.stroke();
}
g.restore();
if (dragFrom !== undefined) {
g.fillStyle = 'rgba(0, 0, 0, 0.1)';
g.fillRect(Math.min(dragFrom, dragTo), top, Math.abs(dragTo - dragFrom), height - top - bottom);
}
return (px) => range[0] + (px - left) / (width - left - right) * (range[1] - range[0]);
}

(async () => {
const result = readResult(await gunzip(FILES.result));
const reference = readResult(await gunzip(FILES.reference));
const canvas = document.getElementById('plot'), list = document.getElementById('vars');
const names = Object.keys(reference).filter((n) => n in result && !/^time$/i.test(n)).sort();
const fullRange = [Math.min(result.time[0], reference.time[0]),
Math.max(result.time[result.time.length - 1], reference.time[reference.time.length - 1])];
let current = names[0], range = fullRange, toTime, dragFrom;

const render = (dragTo) => {
if (!current) return;
toTime = draw(canvas, [
{ xs: reference.time, ys: reference[current], color: '#1f6fd0' },
{ xs: result.time, ys: result[current], color: '#d03c3c' },
], range, dragFrom, dragTo);
};
const select = (name) => {
current = name;
for (const li of list.children) li.classList.toggle('selected', li.textContent === name);
document.getElementById('stats').textContent = current + ', largest difference ' + maxDifference(result, reference, current).toPrecision(3);
render();
};
for (const name of names) {
const li = document.createElement('li');
li.textContent = name;
li.onclick = () => select(name);
list.append(li);
}
document.getElementById('summary').textContent = names.length + ' differing variables.';
document.getElementById('filter').oninput = (e) => {
const text = e.target.value.toLowerCase();
for (const li of list.children) li.hidden = !li.textContent.toLowerCase().includes(text);
};
canvas.onmousedown = (e) => { dragFrom = e.offsetX; };
canvas.onmousemove = (e) => { if (dragFrom !== undefined) render(e.offsetX); };
canvas.onmouseup = (e) => {
if (dragFrom !== undefined && Math.abs(e.offsetX - dragFrom) > 4) {
const a = toTime(Math.min(dragFrom, e.offsetX)), b = toTime(Math.max(dragFrom, e.offsetX));
range = [Math.max(a, fullRange[0]), Math.min(b, fullRange[1])];
}
dragFrom = undefined;
render();
};
canvas.ondblclick = () => { range = fullRange; render(); };
window.onresize = () => render();
if (current) select(current);
})().catch((e) => { document.getElementById('summary').textContent = 'Could not read the results: ' + e; });

// The OMPlot page fetches the files itself, so they must be public and allow
// cross-origin reads: never the case for file://.
if (location.protocol.startsWith('http')) {
const omplot = document.getElementById('omplot');
const abs = (f) => encodeURIComponent(new URL(f, location.href).href);
omplot.href = 'https://playground.openmodelica.org/latest/omplot/?result=' + abs("#basename#.mat") + '&reference=' + abs("#basename#.ref.mat");
omplot.hidden = false;
}
</script>
</body>
</html>
10 changes: 0 additions & 10 deletions library.html.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,5 @@ Config: <pre>#config#</pre>
<tr><th>Model</th><th>Verified</th><th>Simulate</th><th>Total buildModel</th><th>Parsing</th><th>Frontend</th><th>Backend</th><th>SimCode</th><th>Templates</th><th>Compile</th><th>Total Execution</th></tr>
#testsHTML#
</table>
<script>
// A failed verification links its two .mat files (the differing variables of
// the result and of the reference) to the OMPlot page, which plots the tube
// comparison of each variable. The page needs absolute URLs.
const omplot = 'https://playground.openmodelica.org/latest/omplot/';
for (const a of document.querySelectorAll('a[data-omplot]')) {
const abs = (f) => encodeURIComponent(new URL(f, location.href).href);
a.href = omplot + '?result=' + abs(a.dataset.omplot + '.mat') + '&reference=' + abs(a.dataset.omplot + '.ref.mat');
}
</script>
</body>
</html>
19 changes: 18 additions & 1 deletion shared.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3

import collections, re, os, signal, string, subprocess, threading, traceback
import base64, collections, gzip, html, re, os, signal, string, subprocess, threading, traceback
import simplejson as json

# Windows has no SIGKILL, and no process group to signal instead; os.kill there
Expand Down Expand Up @@ -448,3 +448,20 @@ def branchForSimulator(branch, name):
together or on their own.
"""
return branch + fmiSimulator(name).get("branchSuffix", "-%s" % name)

def writeDiffPage(prefix, title):
"""Write prefix.html from prefix.mat and prefix.ref.mat, the differing
variables of a failed verification. The page holds both files, so that it
also works from a local disk and on servers that need a login.
"""
files = {"result": prefix + ".mat", "reference": prefix + ".ref.mat"}
if not all(os.path.exists(f) for f in files.values()):
return
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "diff.html.tpl")) as fin:
page = fin.read()
for key, f in files.items():
with open(f, "rb") as fin:
page = page.replace('"#%s#"' % key, '"%s"' % base64.b64encode(gzip.compress(fin.read())).decode())
page = page.replace("#title#", html.escape(title)).replace("#basename#", os.path.basename(prefix))
with open(prefix + ".html", "w") as fout:
fout.write(page)
3 changes: 2 additions & 1 deletion test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,6 +1536,7 @@ def publishLibrary(libname):
if (s[3].get("diff") or {}).get("vars"):
filesList.write("/%s.diff.mat\n" % filename_prefix)
filesList.write("/%s.diff.ref.mat\n" % filename_prefix)
filesList.write("/%s.diff.html\n" % filename_prefix)
filesList.close()
testsHTML = "\n".join(['<tr><td>%s%s</td><td bgcolor="%s">%s</td><td bgcolor="%s">%s</td><td bgcolor="%s">%s</td><td>%s</td><td bgcolor="%s">%s</td><td bgcolor="%s">%s</td><td bgcolor="%s">%s</td><td bgcolor="%s">%s</td><td bgcolor="%s">%s</td><td>%s</td></tr>\n' %
(lambda filename_prefix, errPrefix, diff:
Expand All @@ -1546,7 +1547,7 @@ def publishLibrary(libname):
# Nothing was compared, whatever phase the model reports.
"&nbsp;" if diff is None else
(("%s (%d verified)" % (timeSeconds(diff.get("time")), diff.get("numCompared"))) if s[3]["phase"]>=7 else
('%s (<a href="%s.diff.mat" data-omplot="%s.diff">%d/%d failed</a>)' % (timeSeconds(diff.get("time")), filename_prefix, filename_prefix, len(diff.get("vars")), diff.get("numCompared")))),
('%s (<a href="%s.diff.html">%d/%d failed</a>)' % (timeSeconds(diff.get("time")), filename_prefix, len(diff.get("vars")), diff.get("numCompared")))),
checkPhase(s[3]["phase"], 6),
timeSeconds(s[3].get("sim") or 0),
checkPhase(s[3]["phase"], 5),
Expand Down
5 changes: 3 additions & 2 deletions testmodel.py
Original file line number Diff line number Diff line change
Expand Up @@ -1175,10 +1175,11 @@ def verifyAgainstReference(resFile, prefix, stat):
fp.write("\nVariables in the result:" )
fp.write(",".join(resVars)+"\n")
stat["diff"]["vars"]=diffVars
# Both files reduced to the differing variables: what the report's OMPlot
# link compares, instead of a CSV and an HTML page per variable.
# Both files reduced to the differing variables: what the page linked from
# the report plots, instead of a CSV and an HTML page per variable.
filterDiffVars(resFile, prefix + ".mat", diffVars)
filterDiffVars(referenceFile, prefix + ".ref.mat", diffVars)
shared.writeDiffPage(prefix, conf["modelName"])

def filterDiffVars(inFile, outFile, diffVars):
vars = ", ".join('"%s"' % v.replace('\\', '\\\\').replace('"', '\\"') for v in diffVars)
Expand Down
Loading