diff --git a/docs/analyses-plots.js b/docs/analyses-plots.js
index b19866d..6509b1f 100644
--- a/docs/analyses-plots.js
+++ b/docs/analyses-plots.js
@@ -70,14 +70,6 @@
var FONT = { family: "-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif",
size: 12, color: C.text };
- /* Sequential, light-to-dark, for power and coherence. Perceptually ordered so
- "more" reads as "darker" in greyscale too, which a printed page needs. */
- var SEQ = [[0, "#f7fbfc"], [0.25, "#bfe3e0"], [0.5, "#5fbfb6"],
- [0.75, "#0d9488"], [1, "#134e4a"]];
- /* Cyclic, for phase: -pi and +pi are the same angle and must be the same colour. */
- var CYCLIC = [[0, "#7c3aed"], [0.25, "#0d9488"], [0.5, "#f0f6f6"],
- [0.75, "#9a6700"], [1, "#7c3aed"]];
-
function layout(extra) {
var base = {
font: FONT,
@@ -118,25 +110,74 @@
/* ---------- shared trace builders ----------------------------------------- */
- /* The cone of influence, drawn where the analysis actually applies it.
+ /* The period axis, laid out the way the dashboard's cross-wavelet tab lays it
+ out, so that a reader who follows this page and then opens a real study is
+ looking at the same picture: a linear axis carrying log2(period), ticked at
+ the octaves and labelled in seconds. Long periods are at the top.
- The step masks a cell when `scale > coi[t]`, and these axes are in period,
- so the boundary is coi scaled by the same period/scale ratio the transform
- used. Drawing raw `coi` on a period axis would put the line in the wrong
- place by 3.3% -- small, but wrong in the direction that matters. */
- function coiTrace(time, coi, period, scales, yTop) {
- var ratio = period[0] / scales[0];
- var x = [time[0]].concat(time, [time[time.length - 1]]);
- var y = [yTop].concat(coi.map(function (c) { return c * ratio; }), [yTop]);
+ Not `type: "log"` with a reversed autorange, which is the other convention
+ and puts the same data upside down. */
+ function log2Period(period) {
+ return period.map(function (p) { return Math.log2(p); });
+ }
+
+ function periodAxis(period) {
+ var lo = Math.ceil(Math.log2(Math.min.apply(null, period)));
+ var hi = Math.floor(Math.log2(Math.max.apply(null, period)));
+ var vals = [], text = [], i, p;
+ for (i = lo; i <= hi; i++) {
+ p = Math.pow(2, i);
+ vals.push(i);
+ text.push(p < 1 ? p.toFixed(1) : p.toFixed(0));
+ }
return {
- x: x, y: y, type: "scatter", mode: "lines", fill: "toself",
- fillcolor: "rgba(31,35,40,0.13)", line: { color: C.text, width: 1, dash: "dot" },
- hoverinfo: "skip", name: "cone of influence"
+ title: { text: "Period (s)" }, tickmode: "array",
+ tickvals: vals, ticktext: text,
+ gridcolor: C.line, zeroline: false, linecolor: C.line
};
}
+ /* A grid of the real period per cell, so the hover can name a period the
+ reader recognises while the axis carries its logarithm. `z` is 2-D, so a
+ 1-D customdata cannot be indexed against it. */
+ function periodGrid(z, period) {
+ return z.map(function (row, i) {
+ return row.map(function () { return period[i]; });
+ });
+ }
+
+ /* The cone of influence: a shaded region plus its boundary, as the tab draws
+ it.
+
+ Where it departs from the tab: the step masks a cell when `scale > coi[t]`,
+ and these axes are in period, so the boundary is coi scaled by the same
+ period/scale ratio the transform used. Drawing raw `coi` on a period axis --
+ which the tab does -- puts the line in the wrong place by 3.3%, small but
+ wrong in the direction that flatters the result. */
+ function coiTraces(time, coi, period, scales) {
+ var ratio = period[0] / scales[0];
+ var top = Math.log2(Math.max.apply(null, period));
+ var floorP = period[0];
+ var y = coi.map(function (c) { return Math.log2(Math.max(c * ratio, floorP)); });
+ return [
+ { x: time.concat([time[time.length - 1], time[0]]),
+ y: y.concat([top, top]),
+ type: "scatter", mode: "none", fill: "toself",
+ fillcolor: "rgba(0, 0, 0, 0.08)", line: { width: 0 },
+ hoverinfo: "skip", showlegend: false, name: "COI" },
+ { x: time, y: y, type: "scatter", mode: "lines",
+ line: { color: C.text, width: 2, dash: "dash" },
+ customdata: coi, showlegend: false, name: "cone of influence",
+ hovertemplate: "Time: %{x:.1f}s COI period: %{customdata:.2f}s" }
+ ];
+ }
+
/* The 95% boundary as a contour of ratio == 1, which is how the payload asks
- to be read: `power / signif_xwt`, `coherence / sig95_wtc`. */
+ to be read: `power / signif_xwt`, `coherence / sig95_wtc`.
+
+ Where it departs from the tab: the tab asks for contours from 0.95 to 1.5
+ every 0.5, which draws two lines, at 0.95 and 1.45. One line at the level
+ the text describes is what a figure explaining the level should show. */
function ratioContour(x, y, z, level) {
var ratio = z.map(function (row, i) {
return row.map(function (v) {
@@ -147,22 +188,43 @@
x: x, y: y, z: ratio, type: "contour", showscale: false,
autocontour: false,
contours: { start: 1, end: 1, size: 1, coloring: "none", showlabels: false },
- line: { color: "#111827", width: 1.4 }, hoverinfo: "skip"
+ line: { color: "black", width: 2 }, hoverinfo: "skip"
};
}
function heat(x, y, z, opts) {
return merge({
- x: x, y: y, z: z, type: "heatmap", colorscale: SEQ,
- colorbar: { thickness: 11, outlinewidth: 0, len: 0.92, tickfont: { size: 10 } },
- hovertemplate: "%{x:.1f} s · %{y:.2f} s %{z:.3f}"
+ x: x, y: y, z: z, type: "heatmap", colorscale: "Viridis",
+ colorbar: { titleside: "top", thickness: 10, outlinewidth: 0,
+ tickfont: { size: 9 } }
}, opts || {});
}
- var PERIOD_AXIS = {
- type: "log", autorange: "reversed", title: { text: "period (s)" },
- gridcolor: C.line, zeroline: false, linecolor: C.line
- };
+ /* Phase as one of eight glyphs, binned as the tab bins it. Right is in phase,
+ and the angle turns anticlockwise from there. */
+ var GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
+
+ function phaseGlyph(deg) {
+ return GLYPHS[Math.floor(((deg + 22.5) % 360) / 45)];
+ }
+
+ /* The glyph trace itself: a dark disc under white text, because the glyphs sit
+ on the pale end of Viridis as often as the dark end and neither a light nor
+ a dark ink is readable on both. */
+ function glyphTrace(x, y, text, customdata) {
+ return {
+ x: x, y: y, text: text, customdata: customdata,
+ type: "scatter", mode: "markers+text",
+ marker: { size: 15, color: "rgba(0,0,0,0.45)" },
+ textfont: {
+ // Arial has no U+2196-2199, so the diagonals would arrive as tofu.
+ family: "Segoe UI Symbol, Apple Symbols, DejaVu Sans, sans-serif",
+ size: 14, color: "#ffffff"
+ },
+ textposition: "middle center", showlegend: false,
+ hovertemplate: "%{customdata}"
+ };
+ }
/* ---------- data, fetched once each ---------------------------------------- */
@@ -212,17 +274,19 @@
return load("demo_crosswavelet.json").then(function (d) {
var p = xwtPair(d), v = p.visualization;
var z = decodeF32(v.power);
+ var y = log2Period(v.period);
draw(el, [
- heat(v.time, v.period, z, {
- colorbar: { title: { text: "|WXY|", side: "right" },
- thickness: 11, outlinewidth: 0, len: 0.92,
- tickfont: { size: 10 } } }),
- ratioContour(v.time, v.period, z, v.signif_xwt),
- coiTrace(v.time, v.coi, v.period, v.scales,
- v.period[v.period.length - 1])
- ], {
- xaxis: { title: { text: "time (s)" }, range: [v.time[0], v.time[v.time.length - 1]] },
- yaxis: PERIOD_AXIS
+ heat(v.time, y, z, {
+ colorbar: { title: "Power" },
+ customdata: periodGrid(z, v.period),
+ hovertemplate: "Time: %{x:.1f}s Period: %{customdata:.2f}s " +
+ "Power: %{z:.4f}"
+ }),
+ ratioContour(v.time, y, z, v.signif_xwt)
+ ].concat(coiTraces(v.time, v.coi, v.period, v.scales)), {
+ plot_bgcolor: C.panel,
+ xaxis: { title: { text: "Time (s)" }, range: [v.time[0], v.time[v.time.length - 1]] },
+ yaxis: periodAxis(v.period)
}, 380);
caption(el, "Cross-wavelet power, visualization.power — a " +
"f32-b64 grid of " + v.power.shape[0] + " periods × " +
@@ -237,47 +301,55 @@
return load("demo_crosswavelet.json").then(function (d) {
var p = xwtPair(d), v = p.visualization;
var z = decodeF32(v.coherence), phase = decodeF32(v.phase);
- var ratio = v.period[0] / v.scales[0];
-
- /* Arrows only where the relationship is above chance and outside the
- cone: a phase read off a cell that means nothing is a decoration. */
- var arrows = [];
- for (var i = 0; i < v.period.length; i += 3) {
- for (var j = 4; j < v.time.length; j += 10) {
+ var y = log2Period(v.period);
+
+ /* Glyphs only where the relationship is above chance and outside the
+ cone: a phase read off a cell that means nothing is a decoration.
+
+ Where this departs from the tab: the tab gates its glyphs on *power*
+ significance. Phase is only readable where the timing is consistent,
+ which is what coherence measures, so the gate here is `sig95_wtc`. */
+ var gx = [], gy = [], gt = [], gd = [];
+ var skipT = Math.max(1, Math.floor(v.time.length / 20));
+ var skipF = Math.max(1, Math.floor(v.period.length / 12));
+ for (var i = 0; i < v.period.length; i += skipF) {
+ for (var j = 0; j < v.time.length; j += skipT) {
var c = z[i][j], lvl = v.sig95_wtc ? v.sig95_wtc[i] : null;
if (c === null || lvl === null || c <= lvl) { continue; }
if (v.scales[i] > v.coi[j]) { continue; }
var a = phase[i][j];
if (a === null) { continue; }
- arrows.push({
- x: v.time[j], y: v.period[i], xref: "x", yref: "y",
- ax: -9 * Math.cos(a), ay: 9 * Math.sin(a),
- axref: "pixel", ayref: "pixel",
- showarrow: true, arrowhead: 2, arrowsize: 1, arrowwidth: 1.1,
- arrowcolor: "rgba(17,24,39,0.75)"
- });
+ var deg = (a * 180 / Math.PI + 360) % 360;
+ gx.push(v.time[j]);
+ gy.push(y[i]);
+ gt.push(phaseGlyph(deg));
+ gd.push("Time: " + v.time[j].toFixed(1) + "s | Period: " +
+ v.period[i].toFixed(2) + "s Phase: " + deg.toFixed(0) +
+ "° Coherence: " + c.toFixed(3));
}
}
draw(el, [
- heat(v.time, v.period, z, {
+ heat(v.time, y, z, {
zmin: 0, zmax: 1,
- colorbar: { title: { text: "R²", side: "right" }, thickness: 11,
- outlinewidth: 0, len: 0.92, tickfont: { size: 10 } } }),
- ratioContour(v.time, v.period, z, v.sig95_wtc),
- coiTrace(v.time, v.coi, v.period, v.scales,
- v.period[v.period.length - 1])
- ], {
- xaxis: { title: { text: "time (s)" }, range: [v.time[0], v.time[v.time.length - 1]] },
- yaxis: PERIOD_AXIS,
- annotations: arrows
+ colorbar: { title: "Coherence" },
+ customdata: periodGrid(z, v.period),
+ hovertemplate: "Time: %{x:.1f}s Period: %{customdata:.2f}s " +
+ "Coherence: %{z:.4f}"
+ }),
+ ratioContour(v.time, y, z, v.sig95_wtc)
+ ].concat(coiTraces(v.time, v.coi, v.period, v.scales),
+ [glyphTrace(gx, gy, gt, gd)]), {
+ plot_bgcolor: C.panel,
+ xaxis: { title: { text: "Time (s)" }, range: [v.time[0], v.time[v.time.length - 1]] },
+ yaxis: periodAxis(v.period)
}, 380);
var frac = p.statistics.wtc_signif_fraction;
caption(el, "Wavelet coherence, with the same cone and the 95% " +
- "sig95_wtc boundary. Arrows are phase, drawn " +
- "only on above-chance cells outside the cone: right means in phase, " +
- "and here they lean consistently at the 4 s band, where " +
+ "sig95_wtc boundary. The glyphs are phase, " +
+ "drawn only on above-chance cells outside the cone: → means in phase, " +
+ "and here they point consistently at the 4 s band, where " +
"sig_b lags by 0.7 s. The burst at 20–35 s is " +
"bright in power and not here. " +
(100 * frac).toFixed(1) + "% of usable cells are above chance, against " +
@@ -288,16 +360,24 @@
"fig-xwt-global": function (el) {
return load("demo_crosswavelet.json").then(function (d) {
var v = xwtPair(d).visualization;
+ var y = log2Period(v.period);
+ /* The visualization copies, not the `statistics` ones: those are written
+ at full resolution and would be a different length from this axis. */
draw(el, [
- { x: v.global_power, y: v.period, type: "scatter", mode: "lines",
- name: "global power", line: { color: C.teal, width: 2 } },
- { x: v.global_signif, y: v.period, type: "scatter", mode: "lines",
- name: "95% level", line: { color: C.amber, width: 1.6, dash: "dash" } }
+ { x: v.global_power, y: y, type: "scatter", mode: "lines",
+ name: "global power", line: { color: C.text, width: 2 },
+ customdata: v.period,
+ hovertemplate: "Power: %{x:.4f} Period: %{customdata:.2f}s" },
+ { x: v.global_signif, y: y, type: "scatter", mode: "lines",
+ name: "95% level", line: { color: C.text, width: 1, dash: "dash" },
+ customdata: v.period,
+ hovertemplate: "95% level: %{x:.4f} Period: %{customdata:.2f}s" }
], {
+ plot_bgcolor: C.panel,
showlegend: true, legend: { orientation: "h", y: 1.14, x: 0 },
margin: { t: 34 },
- xaxis: { title: { text: "time-averaged |WXY|" } },
- yaxis: PERIOD_AXIS
+ xaxis: { title: { text: "Power" } },
+ yaxis: periodAxis(v.period)
}, 340);
caption(el, "global_power against global_signif: " +
"the whole record averaged over time, tested with the degrees of freedom " +
@@ -311,18 +391,23 @@
return load("demo_crosswavelet.json").then(function (d) {
var p = xwtPair(d), v = p.visualization;
var lvl = p.statistics.scale_avg_signif;
+ var band = p.scale_avg_band;
draw(el, [
{ x: v.time, y: v.scale_avg_power, type: "scatter", mode: "lines",
- fill: "tozeroy", fillcolor: "rgba(13,148,136,0.16)",
- line: { color: C.teal, width: 1.6 }, name: "scale-averaged power" },
+ line: { color: C.text, width: 2 }, name: "scale-averaged power",
+ hovertemplate: "Time: %{x:.1f}s Power: %{y:.4f}" },
{ x: [v.time[0], v.time[v.time.length - 1]], y: [lvl, lvl],
type: "scatter", mode: "lines", name: "95% level",
- line: { color: C.amber, width: 1.5, dash: "dash" } }
+ line: { color: C.text, width: 1, dash: "dash" },
+ hovertemplate: "95% level: %{y:.4f}" }
], {
+ plot_bgcolor: C.panel,
showlegend: true, legend: { orientation: "h", y: 1.14, x: 0 },
margin: { t: 34 },
- xaxis: { title: { text: "time (s)" } },
- yaxis: { title: { text: "scale-averaged power" } }
+ xaxis: { title: { text: "Time (s)" } },
+ yaxis: { title: { text: band
+ ? band[0].toFixed(1) + "–" + band[1].toFixed(1) + "s avg"
+ : "scale-averaged power" } }
}, 280);
caption(el, "scale_avg_power over the band in " +
"scale_avg_band, one number per time point, against the " +
diff --git a/docs/analyses.html b/docs/analyses.html
index bd63c91..9afd68a 100644
--- a/docs/analyses.html
+++ b/docs/analyses.html
@@ -29,7 +29,7 @@
Analyses
-
The analyses DIMS ships
+
The analyses DIMS ships
Three analyses come with the core. Each one reads prepared time series from
assets/timeseries/, and each one writes a single JSON payload that a dashboard
tab draws and a notebook can read back.
asset layout says where every file goes and what a
visualization block holds; analysis output
says what any analysis must record and why.
-
The running example
+
The running example
Every figure in this section is real output. A generator builds six synthetic
signals whose answers are known before anything runs, feeds them to
dims-analysis run, and the pages plot the payloads that come out —
- docs/analyses/demo/make_demo_data.py, and the
+ docs/analyses/demo/make_demo_data.py, and the
committed results beside it. Nothing here is an illustration of what the code
is supposed to do.
Two of those six are the running example, carried through all three pages:
@@ -93,7 +93,7 @@
The running example
the kind of thing the chance level is estimated against.
That pair is the reason both cross-wavelet power and coherence exist: power
finds the burst, coherence does not.
-
What goes in
+
What goes in
One CSV per recording per signal, at assets/timeseries/{video}_{type}.csv,
with a Time column in seconds and one value column:
Time,sig_a
@@ -107,7 +107,7 @@
What goes in
series is sorted by time. What differs between them is the guard afterwards —
cross-wavelet refuses fewer than 50 samples, the recurrence analyses refuse
fewer than 10 and refuse a signal that does not vary at all.
-
Turning them on
+
Turning them on
Each analysis is gated by one key in the study's config.json, and the shape of
that key is not the same for all three:
{
@@ -140,14 +140,14 @@
Turning them on
Tuning goes under analysis.<step id>, where the step id is rqa, crqa or
crosswavelet — the names dims-analysis list prints, not the gate keys. Each
page below documents its own parameters; the schema that validates them is
- contracts/config.schema.json.
One file per recording per analysis, always this shape:
{
"video_id": "demo",
"payload_version": 2,
"<container key>": { /* keyed by data type, or by "{a}_vs_{b}" */ },
- "provenance": { "core_version": "1.4.2", "...": "the settings actually used" },
+ "provenance": { "core_version": "1.4.3", "...": "the settings actually used" },
"precision": { "significant_figures": 6, "note": "..." }
}
@@ -179,7 +179,7 @@
What comes out
Writing merges one level deep rather than replacing the file, so a study-owned
analysis can add its own entries to rqa_data without erasing the core's.
-
Numbers are rounded, arrays are packed
+
Numbers are rounded, arrays are packed
Every small field is rounded to six significant figures — significant
figures, not decimal places — and non-finite values become null, because
json.dump writes a bare NaN token that JSON.parse rejects outright.
@@ -230,7 +230,7 @@
Numbers are rounded, arrays are packed
In Python, dims_analysis.common.arrays.unpack() walks a payload and returns
whichever it finds, so you do not have to know in advance which fields grew
large enough to be packed.
-
A picture and the analysis are not the same file
+
A picture and the analysis are not the same file
Every payload holds a visualization block that has been reduced to
something a browser can draw, and records how:
"reduction": {
@@ -255,7 +255,7 @@
A picture and the analysis are not the same file
threshold, which each page shows.
The rule and its rationale are contract A3 in
analysis output.
-
Where these figures came from
+
Where these figures came from
The demo payloads were produced by running the real steps over the synthetic
signals, against the pinned dependencies. Re-generate them with: