Here is the simplest possible system with two internal states: a "driven" state that the input pushes directly, and a "passenger" state that only moves because it's coupled to the driven one. A slider controls the strength of that coupling.
<p class="hint">A driven state x1 is pushed directly by the input u. A passenger state x2 only moves through a coupling of strength k. Drag the slider to change k and watch both Kalman rank tests react live.</p>
<div class="diagram" id="diagram">
<div class="node input" id="nodeU">u</div>
<svg class="arrows" viewBox="0 0 220 120" preserveAspectRatio="none">
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="currentColor"/>
</marker>
</defs>
<line id="uToX1" x1="20" y1="60" x2="90" y2="60" marker-end="url(#arrow)"/>
<line id="x1ToX2" x1="130" y1="30" x2="130" y2="90" marker-end="url(#arrow)"/>
<line id="x2ToY" x1="170" y1="60" x2="200" y2="60" marker-end="url(#arrow)"/>
</svg>
<div class="node state driven" id="nodeX1">x1<span class="sub">driven</span></div>
<div class="node state passenger" id="nodeX2">x2<span class="sub">passenger</span></div>
<div class="node output" id="nodeY">y</div>
</div>
<div class="slider-row">
<label for="k">Coupling k</label>
<input type="range" id="k" min="0" max="100" value="50" step="1">
<span id="kval" class="kval">0.50</span>
</div>
<div class="matrices">
<div class="mat-box">
<div class="mat-title">Controllability matrix [B AB]</div>
<div class="mat" id="ctrlMat"></div>
<div class="rank" id="ctrlRank"></div>
</div>
<div class="mat-box">
<div class="mat-title">Observability matrix [C; CA]</div>
<div class="mat" id="obsMat"></div>
<div class="rank" id="obsRank"></div>
</div>
</div>
<div class="status" id="status">Full rank both ways — every state can be steered and every state can be seen.</div>
<div class="btns">
<button id="zero" type="button">Set k = 0 (decouple)</button>
<button id="reset" type="button" class="ghost">Reset to k = 0.5</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.diagram { position: relative; height: 140px; margin: .5rem 0 1rem; }
.arrows { position: absolute; top: 0; left: 0; width: 100%; height: 100%; color: #8a97a6; }
.arrows line { stroke: currentColor; stroke-width: 2; transition: stroke .15s, opacity .15s; }
.node { position: absolute; display: flex; flex-direction: column; align-items: center; justify-content: center;
border-radius: 10px; font: 700 15px ui-monospace, monospace; text-align: center; padding: .3rem .5rem; }
.node .sub { font: 500 10px system-ui, sans-serif; margin-top: 2px; }
.input { left: 0; top: 42px; width: 60px; height: 40px; background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.state { top: 12px; width: 60px; height: 55px; }
.driven { left: 85px; background: #cfe8d8; color: #14532d; border: 1px solid #a8d4b7; }
.passenger { left: 85px; top: 72px; background: #d8dce3; color: #1d3557; border: 1px solid #b9c1cd; transition: opacity .2s, background .2s; }
.output { right: 0; top: 42px; width: 60px; height: 40px; background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin: .6rem 0; font-size: .9rem; }
.slider-row input[type="range"] { flex: 1; }
.kval { font: 700 14px ui-monospace, monospace; min-width: 3.2em; text-align: right; }
.matrices { display: flex; gap: 1rem; flex-wrap: wrap; margin: .6rem 0; }
.mat-box { background: #f4f6f8; border: 1px solid #e1e6ea; border-radius: 8px; padding: .5rem .7rem; min-width: 150px; }
.mat-title { font-size: .78rem; font-weight: 700; color: #444; margin-bottom: .25rem; }
.mat { font: 600 13px ui-monospace, monospace; white-space: pre; color: #1d3557; }
.rank { font-size: .8rem; font-weight: 700; margin-top: .3rem; }
.rank.full { color: #0a7d33; }
.rank.deficient { color: #c92f3c; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Model: dx1/dt = -x1 + u (input drives x1 directly)
// dx2/dt = k*x1 - x2 (x2 only moves via coupling k)
// y = x1 + k*x2 (sensor sees x2 only via the same coupling k)
const slider = document.getElementById('k');
const kval = document.getElementById('kval');
// Rank of a 2x2 matrix: 2 if det != 0, else 1 if any entry is nonzero, else 0.
function rank2(m){
const [[a,b],[c,d]] = m;
const det = a*d - b*c;
if (Math.abs(det) > 1e-9) return 2;
if (Math.abs(a) > 1e-9 || Math.abs(b) > 1e-9 || Math.abs(c) > 1e-9 || Math.abs(d) > 1e-9) return 1;
return 0;
}
function fmtMat(m){
const w = Math.max(...m.flat().map(x => x.toFixed(2).length));
return m.map(row => row.map(x => x.toFixed(2).padStart(w)).join(' ')).join('\n');
}
function update(){
const k = Number(slider.value) / 100;
kval.textContent = k.toFixed(2);
// Controllability matrix C = [B, A*B] for A, B above.
const A = [[-1, 0], [k, -1]];
const B = [[1], [0]];
const AB = [A[0][0]*B[0][0] + A[0][1]*B[1][0], A[1][0]*B[0][0] + A[1][1]*B[1][0]];
const ctrlMat = [[B[0][0], AB[0]], [B[1][0], AB[1]]];
const ctrlRank = rank2(ctrlMat);
// Observability matrix O = [C; C*A] for C, A above.
const C = [1, k];
const CA = [C[0]*A[0][0] + C[1]*A[1][0], C[0]*A[0][1] + C[1]*A[1][1]];
const obsMat = [C, CA];
const obsRank = rank2(obsMat);
document.getElementById('ctrlMat').textContent = fmtMat(ctrlMat);
document.getElementById('obsMat').textContent = fmtMat(obsMat);
const ctrlRankEl = document.getElementById('ctrlRank');
ctrlRankEl.textContent = fmt('rank = %r%', { r: ctrlRank });
ctrlRankEl.className = 'rank ' + (ctrlRank === 2 ? 'full' : 'deficient');
const obsRankEl = document.getElementById('obsRank');
obsRankEl.textContent = fmt('rank = %r%', { r: obsRank });
obsRankEl.className = 'rank ' + (obsRank === 2 ? 'full' : 'deficient');
// Fade the x1-to-x2 link and the passenger node as k shrinks toward zero.
const link = document.getElementById('x1ToX2');
const passenger = document.getElementById('nodeX2');
link.style.opacity = String(0.15 + 0.85 * Math.abs(k));
passenger.style.opacity = String(0.35 + 0.65 * Math.min(1, Math.abs(k) * 3));
const status = document.getElementById('status');
if (ctrlRank === 2 && obsRank === 2) {
status.textContent = 'Full rank both ways — every state can be steered and every state can be seen.';
status.className = 'status ok';
} else {
status.textContent = 'Rank drops to 1 — the passenger state x2 is unreachable AND invisible.';
status.className = 'status bad';
}
console.log(fmt('k=%k% rank(controllability)=%rc% rank(observability)=%ro%', { k: k.toFixed(2), rc: ctrlRank, ro: obsRank }));
}
slider.addEventListener('input', update);
document.getElementById('zero').addEventListener('click', () => { slider.value = '0'; update(); });
document.getElementById('reset').addEventListener('click', () => { slider.value = '50'; update(); });
update();
<p class="hint">A driven state x1 is pushed directly by the input u. A passenger state x2 only moves through a coupling of strength k. Drag the slider to change k and watch both Kalman rank tests react live.</p>
<div class="diagram" id="diagram">
<div class="node input" id="nodeU">u</div>
<svg class="arrows" viewBox="0 0 220 120" preserveAspectRatio="none">
<defs>
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
<path d="M0,0 L6,3 L0,6 Z" fill="currentColor"/>
</marker>
</defs>
<line id="uToX1" x1="20" y1="60" x2="90" y2="60" marker-end="url(#arrow)"/>
<line id="x1ToX2" x1="130" y1="30" x2="130" y2="90" marker-end="url(#arrow)"/>
<line id="x2ToY" x1="170" y1="60" x2="200" y2="60" marker-end="url(#arrow)"/>
</svg>
<div class="node state driven" id="nodeX1">x1<span class="sub">driven</span></div>
<div class="node state passenger" id="nodeX2">x2<span class="sub">passenger</span></div>
<div class="node output" id="nodeY">y</div>
</div>
<div class="slider-row">
<label for="k">Coupling k</label>
<input type="range" id="k" min="0" max="100" value="50" step="1">
<span id="kval" class="kval">0.50</span>
</div>
<div class="matrices">
<div class="mat-box">
<div class="mat-title">Controllability matrix [B AB]</div>
<div class="mat" id="ctrlMat"></div>
<div class="rank" id="ctrlRank"></div>
</div>
<div class="mat-box">
<div class="mat-title">Observability matrix [C; CA]</div>
<div class="mat" id="obsMat"></div>
<div class="rank" id="obsRank"></div>
</div>
</div>
<div class="status" id="status">Full rank both ways — every state can be steered and every state can be seen.</div>
<div class="btns">
<button id="zero" type="button">Set k = 0 (decouple)</button>
<button id="reset" type="button" class="ghost">Reset to k = 0.5</button>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.diagram { position: relative; height: 140px; margin: .5rem 0 1rem; }
.arrows { position: absolute; top: 0; left: 0; width: 100%; height: 100%; color: #8a97a6; }
.arrows line { stroke: currentColor; stroke-width: 2; transition: stroke .15s, opacity .15s; }
.node { position: absolute; display: flex; flex-direction: column; align-items: center; justify-content: center;
border-radius: 10px; font: 700 15px ui-monospace, monospace; text-align: center; padding: .3rem .5rem; }
.node .sub { font: 500 10px system-ui, sans-serif; margin-top: 2px; }
.input { left: 0; top: 42px; width: 60px; height: 40px; background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.state { top: 12px; width: 60px; height: 55px; }
.driven { left: 85px; background: #cfe8d8; color: #14532d; border: 1px solid #a8d4b7; }
.passenger { left: 85px; top: 72px; background: #d8dce3; color: #1d3557; border: 1px solid #b9c1cd; transition: opacity .2s, background .2s; }
.output { right: 0; top: 42px; width: 60px; height: 40px; background: #e8eef3; color: #1d3557; border: 1px solid #cdd9e3; }
.slider-row { display: flex; align-items: center; gap: .6rem; margin: .6rem 0; font-size: .9rem; }
.slider-row input[type="range"] { flex: 1; }
.kval { font: 700 14px ui-monospace, monospace; min-width: 3.2em; text-align: right; }
.matrices { display: flex; gap: 1rem; flex-wrap: wrap; margin: .6rem 0; }
.mat-box { background: #f4f6f8; border: 1px solid #e1e6ea; border-radius: 8px; padding: .5rem .7rem; min-width: 150px; }
.mat-title { font-size: .78rem; font-weight: 700; color: #444; margin-bottom: .25rem; }
.mat { font: 600 13px ui-monospace, monospace; white-space: pre; color: #1d3557; }
.rank { font-size: .8rem; font-weight: 700; margin-top: .3rem; }
.rank.full { color: #0a7d33; }
.rank.deficient { color: #c92f3c; }
.status { font-size: 1rem; font-weight: 600; margin: .6rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Model: dx1/dt = -x1 + u (input drives x1 directly)
// dx2/dt = k*x1 - x2 (x2 only moves via coupling k)
// y = x1 + k*x2 (sensor sees x2 only via the same coupling k)
const slider = document.getElementById('k');
const kval = document.getElementById('kval');
// Rank of a 2x2 matrix: 2 if det != 0, else 1 if any entry is nonzero, else 0.
function rank2(m){
const [[a,b],[c,d]] = m;
const det = a*d - b*c;
if (Math.abs(det) > 1e-9) return 2;
if (Math.abs(a) > 1e-9 || Math.abs(b) > 1e-9 || Math.abs(c) > 1e-9 || Math.abs(d) > 1e-9) return 1;
return 0;
}
function fmtMat(m){
const w = Math.max(...m.flat().map(x => x.toFixed(2).length));
return m.map(row => row.map(x => x.toFixed(2).padStart(w)).join(' ')).join('\n');
}
function update(){
const k = Number(slider.value) / 100;
kval.textContent = k.toFixed(2);
// Controllability matrix C = [B, A*B] for A, B above.
const A = [[-1, 0], [k, -1]];
const B = [[1], [0]];
const AB = [A[0][0]*B[0][0] + A[0][1]*B[1][0], A[1][0]*B[0][0] + A[1][1]*B[1][0]];
const ctrlMat = [[B[0][0], AB[0]], [B[1][0], AB[1]]];
const ctrlRank = rank2(ctrlMat);
// Observability matrix O = [C; C*A] for C, A above.
const C = [1, k];
const CA = [C[0]*A[0][0] + C[1]*A[1][0], C[0]*A[0][1] + C[1]*A[1][1]];
const obsMat = [C, CA];
const obsRank = rank2(obsMat);
document.getElementById('ctrlMat').textContent = fmtMat(ctrlMat);
document.getElementById('obsMat').textContent = fmtMat(obsMat);
const ctrlRankEl = document.getElementById('ctrlRank');
ctrlRankEl.textContent = fmt('rank = %r%', { r: ctrlRank });
ctrlRankEl.className = 'rank ' + (ctrlRank === 2 ? 'full' : 'deficient');
const obsRankEl = document.getElementById('obsRank');
obsRankEl.textContent = fmt('rank = %r%', { r: obsRank });
obsRankEl.className = 'rank ' + (obsRank === 2 ? 'full' : 'deficient');
// Fade the x1-to-x2 link and the passenger node as k shrinks toward zero.
const link = document.getElementById('x1ToX2');
const passenger = document.getElementById('nodeX2');
link.style.opacity = String(0.15 + 0.85 * Math.abs(k));
passenger.style.opacity = String(0.35 + 0.65 * Math.min(1, Math.abs(k) * 3));
const status = document.getElementById('status');
if (ctrlRank === 2 && obsRank === 2) {
status.textContent = 'Full rank both ways — every state can be steered and every state can be seen.';
status.className = 'status ok';
} else {
status.textContent = 'Rank drops to 1 — the passenger state x2 is unreachable AND invisible.';
status.className = 'status bad';
}
console.log(fmt('k=%k% rank(controllability)=%rc% rank(observability)=%ro%', { k: k.toFixed(2), rc: ctrlRank, ro: obsRank }));
}
slider.addEventListener('input', update);
document.getElementById('zero').addEventListener('click', () => { slider.value = '0'; update(); });
document.getElementById('reset').addEventListener('click', () => { slider.value = '50'; update(); });
update();
Drag the coupling to zero and watch the controllability matrix collapse from rank 2 to rank 1 — the passenger state becomes a stowaway the input can never reach, no matter how hard you push. Slide it back up and full rank returns instantly. The same coupling term controls whether an output sensor watching only the driven state can ever detect what the passenger state is doing — turn it off and that state goes dark to every sensor, too.
Comments
Loading comments...