<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://kanishkegb.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://kanishkegb.github.io/" rel="alternate" type="text/html" /><updated>2026-06-12T03:31:43+00:00</updated><id>https://kanishkegb.github.io/feed.xml</id><title type="html">Kanishke Gamagedara</title><subtitle>Personal website and blog about UAVs, robotics, control systems, navigation, and embedded software engineering.</subtitle><author><name>Kanishke Gamagedara</name></author><entry><title type="html">Population vs Sample Standard Deviation</title><link href="https://kanishkegb.github.io/2024/10/02/population-vs-sample-std-dev/" rel="alternate" type="text/html" title="Population vs Sample Standard Deviation" /><published>2024-10-02T00:00:00+00:00</published><updated>2024-10-02T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2024/10/02/population-vs-sample-std-dev</id><content type="html" xml:base="https://kanishkegb.github.io/2024/10/02/population-vs-sample-std-dev/"><![CDATA[<p>The difference between <strong>population standard deviation</strong> and <strong>sample standard deviation</strong> boils down to who you’re measuring and how you correct for bias when estimating variability.</p>

<hr />

<h3 id="-definitions">📊 Definitions</h3>

<table>
  <thead>
    <tr>
      <th>Type</th>
      <th>What It Measures</th>
      <th>Formula Difference</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Population Standard Deviation (\(\sigma\))</td>
      <td>Variability of <strong>all</strong> data points in a population</td>
      <td>Divide by \(N\) (total number of data points)</td>
    </tr>
    <tr>
      <td>Sample Standard Deviation (\(s\))</td>
      <td>Variability in a <strong>subset</strong> (sample) of the population</td>
      <td>Divide by (\(N - 1\)) (Bessel’s correction)</td>
    </tr>
  </tbody>
</table>

<hr />

<h3 id="-why-the-difference">🧠 Why the Difference?</h3>

<ul>
  <li><strong>Population SD</strong> assumes you have access to every data point in the population. No need to correct for bias.</li>
  <li><strong>Sample SD</strong> uses Bessel’s correction (dividing by \(N - 1\)) to avoid underestimating the true variability of the population. 
This correction compensates for the fact that a sample tends to be less variable than the full population.</li>
</ul>

<hr />

<h3 id="-formulas">🧮 Formulas</h3>

<ul>
  <li><strong>Population SD</strong>:</li>
</ul>

\[\sigma = \sqrt{\frac{1}{N} \sum_{i=1}^{N} (x_i - \mu)^2}\]

<ul>
  <li><strong>Sample SD</strong>:</li>
</ul>

\[s = \sqrt{\frac{1}{N - 1} \sum_{i=1}^{N} (x_i - \bar{x})^2}\]

<p>Where:</p>
<ul>
  <li>\(x_i\)  = each data point</li>
  <li>\(\mu\)  = population mean</li>
  <li>\(\bar{x}\)  = sample mean</li>
  <li>\(N\)  = number of data points</li>
</ul>

<hr />

<h3 id="-when-to-use-which">🎯 When to Use Which?</h3>

<ul>
  <li>Use <strong>population SD</strong> when you have data for the entire group you’re studying (e.g., all students in a school).</li>
  <li>Use <strong>sample SD</strong> when you’re working with a subset and want to generalize to the whole population (e.g., survey results from 100 out of 10,000 customers).</li>
</ul>

<hr />

<p><br /><br /></p>

<p><em>Note:</em></p>

<p>Current version of this post is generated partially using generative AI.</p>]]></content><author><name>Kanishke Gamagedara</name></author><category term="[&quot;tutorials&quot;, &quot;guides&quot;, &quot;statistics&quot;]" /><summary type="html"><![CDATA[The difference between population standard deviation and sample standard deviation boils down to who you’re measuring and how you correct for bias when estimating variability.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://kanishkegb.github.io/assets/images/posts/coding.jpg" /><media:content medium="image" url="https://kanishkegb.github.io/assets/images/posts/coding.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Finding Your Place in an Infinite Sea</title><link href="https://kanishkegb.github.io/2024/04/04/ancient-navigation/" rel="alternate" type="text/html" title="Finding Your Place in an Infinite Sea" /><published>2024-04-04T00:00:00+00:00</published><updated>2024-04-04T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2024/04/04/ancient-navigation</id><content type="html" xml:base="https://kanishkegb.github.io/2024/04/04/ancient-navigation/"><![CDATA[<p>Navigation at sea was one of history’s greatest intellectual and engineering challenges. For sailors who ventured beyond sight of shore, the ocean was a featureless expanse — no road signs, no landmarks, only the sky above and the water below. Yet over millennia, they developed remarkably elegant methods to determine their position anywhere on Earth.</p>

<p>Their story is one of two problems: one solved with elegant simplicity, the other that stumped the world’s greatest minds for centuries.</p>

<hr />

<h2 id="finding-latitude">Finding Latitude</h2>

<p>Latitude — how far north or south of the equator you are — was the easier of the two problems. The sky itself provided a natural measuring stick.</p>

<h3 id="using-the-north-star">Using the North Star</h3>

<p>The most elegant solution was <em>Polaris</em>, the North Star. Because Earth’s axis points almost directly at it, Polaris appears nearly stationary in the northern sky while all other stars wheel around it through the night. This unique property made it the perfect reference: <strong>the angle of Polaris above your horizon equals your latitude almost exactly.</strong></p>

<ul>
  <li>At the equator, Polaris sits on the horizon (0°)</li>
  <li>At the North Pole, it is directly overhead (90°)</li>
  <li>Everywhere in between, the angle matches your latitude precisely</li>
</ul>

<p>Early sailors estimated this angle by holding fingers at arm’s length. Later came purpose-built instruments: the astrolabe, the cross-staff, the backstaff, and eventually the optical sextant — each generation refining the measurement with greater precision.</p>

<div style="border: 1px solid #ccc; border-radius: 4px; padding: 1.5rem; margin: 2rem 0; background: #fafafa;">
  <p style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: #888; margin: 0 0 1rem;">Interactive — Polaris Altitude Simulator</p>
  <label style="font-size: 0.9rem; color: #555;">Your latitude: <strong id="lat-val">45°N</strong></label><br />
  <input type="range" min="0" max="85" value="45" id="lat-slider" style="width: 100%; margin: 0.5rem 0 1rem;" />
  <canvas id="sky-canvas" style="width: 100%; height: 160px; display: block; border-radius: 3px;"></canvas>
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem;">
    <div>
      <div style="font-size: 1.8rem; font-weight: 600;" id="polaris-angle">45°</div>
      <div style="font-size: 0.8rem; color: #777;">Polaris altitude above horizon</div>
    </div>
    <div>
      <div style="font-size: 1.8rem; font-weight: 600;" id="lat-display">45°N</div>
      <div style="font-size: 0.8rem; color: #777;">Your latitude</div>
    </div>
  </div>
  <script>
  (function () {
    var slider = document.getElementById('lat-slider');
    var canvas = document.getElementById('sky-canvas');
    var ctx = canvas.getContext('2d');

    function draw(lat) {
      var dpr = window.devicePixelRatio || 1;
      var rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr);
      var w = rect.width, h = rect.height;

      ctx.fillStyle = '#0a1828';
      ctx.fillRect(0, 0, w, h);

      var horizonY = h * 0.7;

      // Ocean
      ctx.fillStyle = '#0e2a4a';
      ctx.fillRect(0, horizonY, w, h - horizonY);

      // Horizon line
      ctx.strokeStyle = '#1d5a8a';
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(0, horizonY); ctx.lineTo(w, horizonY); ctx.stroke();

      // Background stars
      ctx.fillStyle = 'rgba(255,255,255,0.6)';
      [[0.12,0.15],[0.3,0.08],[0.55,0.18],[0.75,0.1],[0.88,0.25],[0.2,0.4],[0.65,0.35]].forEach(function(s) {
        ctx.beginPath(); ctx.arc(s[0]*w, s[1]*h, 1, 0, Math.PI*2); ctx.fill();
      });

      // Polaris position
      var polarisX = w * 0.5;
      var polarisY = horizonY - (lat / 90) * horizonY * 0.9;

      // Dashed vertical guide
      ctx.setLineDash([5, 7]);
      ctx.strokeStyle = 'rgba(184,134,11,0.45)';
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.moveTo(polarisX, horizonY); ctx.lineTo(polarisX, polarisY + 6); ctx.stroke();
      ctx.setLineDash([]);

      // Arc
      if (lat > 3) {
        var arcR = horizonY - polarisY;
        ctx.strokeStyle = 'rgba(184,134,11,0.3)';
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.arc(polarisX, horizonY, arcR, -Math.PI / 2, 0);
        ctx.stroke();
        ctx.fillStyle = '#b8860b';
        ctx.font = '10px sans-serif';
        ctx.fillText(lat + '°', polarisX + arcR * 0.4 + 6, horizonY - arcR * 0.35);
      }

      // Polaris star
      ctx.fillStyle = '#fffbe6';
      ctx.shadowColor = '#ffd700';
      ctx.shadowBlur = 10;
      ctx.beginPath(); ctx.arc(polarisX, polarisY, 4, 0, Math.PI*2); ctx.fill();
      ctx.shadowBlur = 0;

      // Labels
      ctx.fillStyle = '#ffd700';
      ctx.font = '11px sans-serif';
      ctx.fillText('Polaris', polarisX + 10, polarisY + 4);
      ctx.fillStyle = '#4a7fa8';
      ctx.font = '10px sans-serif';
      ctx.fillText('Horizon', 8, horizonY - 4);
    }

    function update() {
      var lat = parseInt(slider.value);
      document.getElementById('lat-val').textContent = lat + '°N';
      document.getElementById('polaris-angle').textContent = lat + '°';
      document.getElementById('lat-display').textContent = lat + '°N';
      draw(lat);
    }

    slider.addEventListener('input', update);
    window.addEventListener('resize', update);
    update();
  })();
  </script>
</div>

<h3 id="using-the-sun">Using the Sun</h3>

<p>During the day, sailors measured the Sun’s angle above the horizon at solar noon — its highest point. Cross-referenced against astronomical tables showing the Sun’s declination for each day of the year, this allowed latitude calculation in both hemispheres, on any day clear enough to see the sun.</p>

<h3 id="the-southern-hemisphere">The Southern Hemisphere</h3>

<p>The Southern Hemisphere had no bright pole star. Sailors used the <em>Southern Cross</em> constellation as a rough reference and relied more heavily on noon sun sights. This difficulty partly explains why European exploration reached southern latitudes later than northern ones.</p>

<hr />

<h2 id="the-longitude-problem">The Longitude Problem</h2>

<p>While latitude could be read from the sky, longitude — your east-west position — was an entirely different kind of problem. It stumped navigators, mathematicians, and monarchs for centuries, and its failure cost thousands of lives in shipwrecks.</p>

<p>The reason is deceptively simple: <strong>longitude requires knowing time.</strong> Earth rotates 360° in 24 hours — exactly 15° per hour. If you know what time it is at your home port and what local time it is where you are, the difference gives your longitude directly.</p>

<h3 id="dead-reckoning--and-its-deadly-errors">Dead Reckoning — and Its Deadly Errors</h3>

<p>Without accurate clocks, sailors estimated east-west position by <em>dead reckoning</em>: start from a known position, track your compass heading, estimate your speed, and calculate where you must now be. In calm seas over short voyages, this worked reasonably well. But small errors accumulated relentlessly across days and weeks at sea.</p>

<div style="border: 1px solid #ccc; border-radius: 4px; padding: 1.5rem; margin: 2rem 0; background: #fafafa;">
  <p style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: #888; margin: 0 0 1rem;">Interactive — Dead Reckoning Error Simulator</p>
  <label style="font-size: 0.9rem; color: #555;">Days at sea: <strong id="days-val">7 days</strong></label><br />
  <input type="range" min="1" max="30" value="7" id="days-slider" style="width: 100%; margin: 0.5rem 0 1rem;" />
  <label style="font-size: 0.9rem; color: #555;">Speed estimation error: <strong id="err-val">2%</strong></label><br />
  <input type="range" min="0" max="10" value="2" id="err-slider" step="0.5" style="width: 100%; margin: 0.5rem 0 1rem;" />
  <canvas id="dr-canvas" style="width: 100%; height: 180px; display: block; border-radius: 3px;"></canvas>
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1rem;">
    <div>
      <div style="font-size: 1.8rem; font-weight: 600;" id="drift-miles">—</div>
      <div style="font-size: 0.8rem; color: #777;">Position error (nautical miles)</div>
    </div>
    <div>
      <div style="font-size: 1.8rem; font-weight: 600;" id="drift-deg">—</div>
      <div style="font-size: 0.8rem; color: #777;">Longitude error (degrees)</div>
    </div>
  </div>
  <p style="font-size: 0.8rem; color: #888; margin: 0.8rem 0 0;">A 2% speed error over 30 days could place a ship 150+ nautical miles off course — enough to miss an island entirely, or strike a reef.</p>
  <script>
  (function () {
    var daysSlider = document.getElementById('days-slider');
    var errSlider = document.getElementById('err-slider');
    var canvas = document.getElementById('dr-canvas');
    var ctx = canvas.getContext('2d');

    function draw(days, errPct) {
      var dpr = window.devicePixelRatio || 1;
      var rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr);
      var w = rect.width, h = rect.height;

      ctx.fillStyle = '#0e2a4a';
      ctx.fillRect(0, 0, w, h);

      // Grid
      ctx.strokeStyle = 'rgba(29,90,138,0.25)';
      ctx.lineWidth = 0.5;
      for (var x = 0; x < w; x += 40) { ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,h); ctx.stroke(); }
      for (var y = 0; y < h; y += 40) { ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(w,y); ctx.stroke(); }

      var startX = 30, startY = h / 2;
      var scaleX = (w - 50) / 30;
      var dailyMiles = 144; // 6 knots * 24h

      // True path
      ctx.strokeStyle = '#1d9e75';
      ctx.lineWidth = 1.5;
      ctx.setLineDash([]);
      ctx.beginPath();
      ctx.moveTo(startX, startY);
      ctx.lineTo(startX + days * scaleX, startY);
      ctx.stroke();

      // Estimated path
      ctx.strokeStyle = '#c4781e';
      ctx.lineWidth = 1.5;
      ctx.setLineDash([5, 5]);
      ctx.beginPath();
      ctx.moveTo(startX, startY);
      var ex = startX, ey = startY, totalErr = 0;
      for (var d = 1; d <= days; d++) {
        totalErr += (errPct / 100) * dailyMiles * 0.6 * (Math.sin(d * 1.3) + Math.cos(d * 0.7));
        ex = startX + d * scaleX;
        ey = startY - (totalErr / dailyMiles) * (h * 0.18);
        ctx.lineTo(ex, ey);
      }
      ctx.stroke();
      ctx.setLineDash([]);

      // Error bar
      var endX = startX + days * scaleX;
      if (Math.abs(ey - startY) > 2) {
        ctx.strokeStyle = 'rgba(184,134,11,0.6)';
        ctx.lineWidth = 1;
        ctx.setLineDash([3, 3]);
        ctx.beginPath(); ctx.moveTo(endX, startY); ctx.lineTo(endX, ey); ctx.stroke();
        ctx.setLineDash([]);
      }

      // Labels
      ctx.font = '10px sans-serif';
      ctx.fillStyle = '#1d9e75'; ctx.fillText('True position', startX + 4, startY - 6);
      ctx.fillStyle = '#c4781e'; ctx.fillText('Estimated position', startX + 4, startY + 16);

      // Dots
      ctx.fillStyle = '#ffd700'; ctx.beginPath(); ctx.arc(startX, startY, 4, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#1d9e75'; ctx.beginPath(); ctx.arc(endX, startY, 4, 0, Math.PI*2); ctx.fill();
      ctx.fillStyle = '#c4781e'; ctx.beginPath(); ctx.arc(ex, ey, 4, 0, Math.PI*2); ctx.fill();

      var errMiles = Math.abs(totalErr);
      document.getElementById('drift-miles').textContent = Math.round(errMiles) + ' nm';
      document.getElementById('drift-deg').textContent = (errMiles / 60).toFixed(1) + '°';
    }

    function update() {
      var days = parseInt(daysSlider.value);
      var err = parseFloat(errSlider.value);
      document.getElementById('days-val').textContent = days + (days > 1 ? ' days' : ' day');
      document.getElementById('err-val').textContent = err + '%';
      draw(days, err);
    }

    daysSlider.addEventListener('input', update);
    errSlider.addEventListener('input', update);
    window.addEventListener('resize', update);
    update();
  })();
  </script>
</div>

<h3 id="the-lunar-distance-method">The Lunar Distance Method</h3>

<p>Some navigators attempted the <em>lunar distance method</em>: measuring the angle between the Moon and bright stars, then consulting elaborate tables to convert that into Greenwich time, and thus longitude. It worked in theory — but required hours of painstaking calculation, exceptional mathematical skill, and a clear sky at precisely the right moment. It was too demanding for routine use at sea.</p>

<hr />

<h2 id="the-solution-john-harrison-and-the-marine-chronometer">The Solution: John Harrison and the Marine Chronometer</h2>

<p>Longitude remained unsolved until the 18th century. In 1714, the British government established the <em>Longitude Prize</em> — £20,000 for anyone who could determine longitude to within half a degree over a transatlantic voyage. For decades it went unclaimed.</p>

<p>It was eventually solved not by an astronomer or mathematician, but by a self-taught Yorkshire carpenter and clockmaker named <strong>John Harrison</strong>.</p>

<p>Harrison’s insight was that the problem was not mathematical — it was mechanical. Longitude did not need a better theory. It needed a better clock.</p>

<p>He overcame several engineering challenges that had defeated every previous attempt:</p>

<ul>
  <li>A <strong>balance wheel</strong> replaced the pendulum, which was useless on a rolling ship</li>
  <li><strong>Bimetallic strips</strong> compensated for temperature changes that expanded or contracted metal parts</li>
  <li><strong>Jeweled bearings</strong> minimized friction, keeping the mechanism accurate for months at sea</li>
  <li>His final design, <em>H4</em>, lost only five seconds over an 81-day sea trial in 1762</li>
</ul>

<p>With an accurate chronometer aboard, finding longitude became almost trivial: determine local noon from the sun, compare it to the time on the chronometer set to Greenwich, and multiply the difference in hours by 15 to get your longitude in degrees.</p>

<div style="border: 1px solid #ccc; border-radius: 4px; padding: 1.5rem; margin: 2rem 0; background: #fafafa;">
  <p style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.1em; color: #888; margin: 0 0 1rem;">Interactive — Longitude from Time Difference</p>
  <label style="font-size: 0.9rem; color: #555;">Local solar noon: <strong id="local-time-val">13:30</strong></label><br />
  <input type="range" min="0" max="1440" value="810" id="local-slider" step="5" style="width: 100%; margin: 0.5rem 0 1rem;" />
  <label style="font-size: 0.9rem; color: #555;">Chronometer reads (Greenwich): <strong id="green-time-val">12:00</strong></label><br />
  <input type="range" min="0" max="1440" value="720" id="green-slider" step="5" style="width: 100%; margin: 0.5rem 0 1rem;" />
  <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
    <div>
      <div style="font-size: 1.8rem; font-weight: 600;" id="time-diff">—</div>
      <div style="font-size: 0.8rem; color: #777;">Time difference</div>
    </div>
    <div>
      <div style="font-size: 1.8rem; font-weight: 600;" id="long-result">—</div>
      <div style="font-size: 0.8rem; color: #777;">Calculated longitude</div>
    </div>
  </div>
  <canvas id="globe-canvas" style="width: 100%; height: 140px; display: block; border-radius: 3px;"></canvas>
  <script>
  (function () {
    var localSlider = document.getElementById('local-slider');
    var greenSlider = document.getElementById('green-slider');
    var canvas = document.getElementById('globe-canvas');
    var ctx = canvas.getContext('2d');

    function toHHMM(mins) {
      var h = Math.floor(mins / 60) % 24;
      var m = mins % 60;
      return (h < 10 ? '0' + h : h) + ':' + (m < 10 ? '0' + m : m);
    }

    function draw(localMins, greenMins) {
      var dpr = window.devicePixelRatio || 1;
      var rect = canvas.getBoundingClientRect();
      canvas.width = rect.width * dpr;
      canvas.height = rect.height * dpr;
      ctx.scale(dpr, dpr);
      var w = rect.width, h = rect.height;
      var cx = w / 2, cy = h / 2;
      var R = Math.min(cx, cy) - 16;

      ctx.fillStyle = '#0a1828';
      ctx.fillRect(0, 0, w, h);

      // Globe outline
      ctx.strokeStyle = 'rgba(29,90,138,0.5)';
      ctx.lineWidth = 1;
      ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke();

      // Longitude grid lines
      for (var i = 0; i < 12; i++) {
        var a = (i / 12) * Math.PI * 2 - Math.PI / 2;
        ctx.strokeStyle = 'rgba(29,90,138,0.2)';
        ctx.lineWidth = 0.5;
        ctx.beginPath();
        ctx.moveTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
        ctx.lineTo(cx - Math.cos(a) * R, cy - Math.sin(a) * R);
        ctx.stroke();
      }

      // Equator ellipse
      ctx.strokeStyle = 'rgba(29,90,138,0.3)';
      ctx.lineWidth = 0.5;
      ctx.beginPath(); ctx.ellipse(cx, cy, R, R * 0.28, 0, 0, Math.PI * 2); ctx.stroke();

      // Prime meridian
      ctx.strokeStyle = 'rgba(184,134,11,0.8)';
      ctx.lineWidth = 1.5;
      ctx.beginPath(); ctx.moveTo(cx, cy - R); ctx.lineTo(cx, cy + R); ctx.stroke();
      ctx.fillStyle = '#b8860b';
      ctx.font = '10px sans-serif';
      ctx.fillText('Greenwich', cx + 4, cy - R + 12);

      // Calculate longitude
      var diffMins = localMins - greenMins;
      if (diffMins > 720) diffMins -= 1440;
      if (diffMins < -720) diffMins += 1440;
      var longitude = (diffMins / 60) * 15;
      var dir = longitude >= 0 ? 'E' : 'W';
      var absDeg = Math.abs(longitude).toFixed(1);

      // Ship angle and position
      var angle = (longitude / 180) * Math.PI - Math.PI / 2;
      var shipX = cx + Math.cos(angle) * R * 0.82;
      var shipY = cy + Math.sin(angle) * R * 0.28;

      // Dashed meridian to ship
      ctx.strokeStyle = 'rgba(196,120,30,0.5)';
      ctx.lineWidth = 1;
      ctx.setLineDash([4, 4]);
      ctx.beginPath();
      ctx.moveTo(cx, cy - R);
      ctx.lineTo(cx + Math.cos(angle) * R, cy + Math.sin(angle) * R);
      ctx.stroke();
      ctx.setLineDash([]);

      // Ship dot
      ctx.fillStyle = '#c4781e';
      ctx.shadowColor = '#c4781e';
      ctx.shadowBlur = 8;
      ctx.beginPath(); ctx.arc(shipX, shipY, 5, 0, Math.PI * 2); ctx.fill();
      ctx.shadowBlur = 0;
      ctx.fillStyle = '#c4781e';
      ctx.font = '10px sans-serif';
      ctx.fillText('Your ship', shipX + 8, shipY + 4);

      // Update stats
      var hrs = Math.abs(Math.floor(diffMins / 60));
      var mins2 = Math.abs(diffMins % 60);
      document.getElementById('time-diff').textContent = (diffMins >= 0 ? '+' : '-') + hrs + 'h ' + (mins2 < 10 ? '0' + mins2 : mins2) + 'm';
      document.getElementById('long-result').textContent = absDeg + '° ' + dir;
    }

    function update() {
      var local = parseInt(localSlider.value);
      var green = parseInt(greenSlider.value);
      document.getElementById('local-time-val').textContent = toHHMM(local);
      document.getElementById('green-time-val').textContent = toHHMM(green);
      draw(local, green);
    }

    localSlider.addEventListener('input', update);
    greenSlider.addEventListener('input', update);
    window.addEventListener('resize', update);
    update();
  })();
  </script>
</div>

<hr />

<h2 id="a-timeline-of-navigation">A Timeline of Navigation</h2>

<table>
  <thead>
    <tr>
      <th>Year</th>
      <th>Milestone</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>~3000 BC</td>
      <td>Polynesian navigators cross thousands of miles of open Pacific using stars, ocean swells, and bird patterns — no instruments</td>
    </tr>
    <tr>
      <td>~200 BC</td>
      <td>Greek astronomers formalize latitude; Eratosthenes calculates Earth’s circumference with remarkable accuracy</td>
    </tr>
    <tr>
      <td>~900 AD</td>
      <td>Arab sailors refine the astrolabe and dominate Indian Ocean trade using systematic celestial navigation</td>
    </tr>
    <tr>
      <td>1400s</td>
      <td>Portuguese navigators develop noon sun sight methods as they push south along the African coast</td>
    </tr>
    <tr>
      <td>1707</td>
      <td>Four British warships strike the Isles of Scilly due to a longitude error, killing ~1,400 sailors</td>
    </tr>
    <tr>
      <td>1714</td>
      <td>Britain offers the £20,000 Longitude Prize for a reliable method of finding longitude at sea</td>
    </tr>
    <tr>
      <td>1759</td>
      <td>John Harrison completes H4, his marine chronometer, losing only five seconds over 81 days at sea</td>
    </tr>
    <tr>
      <td>1837</td>
      <td>The optical sextant reaches its modern form, giving sailors sub-mile accuracy worldwide</td>
    </tr>
    <tr>
      <td>1995</td>
      <td>GPS becomes fully operational for civilian use — solving in silicon what took millennia by hand</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="summary">Summary</h2>

<p><strong>Latitude</strong> was relatively straightforward: measure the angle of Polaris or the noon sun above the horizon and compare it to astronomical tables. The North Star made this especially elegant for northern sailors.</p>

<p><strong>Longitude</strong> remained unsolved for centuries because it requires keeping accurate time at sea — a problem that wasn’t cracked until John Harrison’s marine chronometers proved reliable enough for ocean voyages in the 18th century.</p>

<p>Together, one ancient and elegant, one requiring 18th-century engineering, these two methods allowed sailors to navigate the world’s oceans with confidence — and laid the conceptual groundwork for every positioning system since, including the GPS in your pocket.</p>]]></content><author><name>Kanishke Gamagedara</name></author><category term="[&quot;tutorials&quot;, &quot;guides&quot;, &quot;navigation&quot;, &quot;history&quot;]" /><summary type="html"><![CDATA[Navigation at sea was one of history’s greatest intellectual and engineering challenges. For sailors who ventured beyond sight of shore, the ocean was a featureless expanse — no road signs, no landmarks, only the sky above and the water below. Yet over millennia, they developed remarkably elegant methods to determine their position anywhere on Earth.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://kanishkegb.github.io/assets/images/posts/coding_2.png" /><media:content medium="image" url="https://kanishkegb.github.io/assets/images/posts/coding_2.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Schuler Period: Resonance at the Heart of Inertial Navigation</title><link href="https://kanishkegb.github.io/2024/02/10/schuler-period/" rel="alternate" type="text/html" title="The Schuler Period: Resonance at the Heart of Inertial Navigation" /><published>2024-02-10T00:00:00+00:00</published><updated>2024-02-10T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2024/02/10/schuler-period</id><content type="html" xml:base="https://kanishkegb.github.io/2024/02/10/schuler-period/"><![CDATA[<p>The Schuler period is a fundamental resonance condition that governs the error
dynamics of any inertial navigation system (INS) operating on or near the
surface of the Earth. Its value — approximately <strong>84.4 minutes</strong> — is not a
hardware characteristic but a consequence of Earth’s geometry. Understanding
why it exists, and what happens when a system departs from it, is essential for
the design and analysis of high-accuracy navigation systems.</p>

<hr />

<h2 id="1-background-the-pendulum-analogy">1. Background: The Pendulum Analogy</h2>

<p>Consider a simple pendulum of length \(L\) oscillating under gravity \(g\).
Its small-angle period is:</p>

\[T = 2\pi\sqrt{\frac{L}{g}}\]

<p>Now ask: what length \(L\) would produce a period equal to the orbital period
of a low-Earth-orbit satellite? A circular orbit at radius \(R\) from Earth’s
centre satisfies</p>

\[\frac{v^2}{R} = g \;\Longrightarrow\; T_{\text{orbit}} = 2\pi\sqrt{\frac{R}{g}}\]

<p>Setting \(L = R \approx 6{,}371\text{ km}\) yields \(T \approx 84.4\) min.
This is the <strong>Schuler period</strong>, named after Maximilian Schuler who described the
condition in 1923.</p>

<p>The physical picture is striking: a pendulum whose bob hangs at the centre of
the Earth would be immune to horizontal accelerations of the carrier, because
its effective restoring force is always directed toward Earth’s centre
regardless of the platform’s motion. An INS tuned to this period emulates that
ideal pendulum.</p>

<hr />

<h2 id="2-derivation-from-first-principles">2. Derivation from First Principles</h2>

<h3 id="21-platform-tilt-error">2.1 Platform Tilt Error</h3>

<p>Let \(\alpha\) denote a small tilt angle of the navigation platform away from
the local level. A tilted accelerometer measures an apparent horizontal
acceleration</p>

\[\delta a = g \sin\alpha \approx g\alpha\]

<p>which, when integrated twice, produces a growing position error. Left
unchecked, this is an unbounded (Schuler-unstable) error mode.</p>

<h3 id="22-closing-the-loop">2.2 Closing the Loop</h3>

<p>A mechanised INS feeds back computed velocity to drive a <em>levelling torque</em>
that corrects the platform tilt. Let \(v\) be the northward velocity error and
\(R\) the Earth radius. The angular rate needed to maintain local-level
alignment as the vehicle moves over Earth’s curvature is \(\dot\theta =
v/R\). The coupled error equations become:</p>

\[\ddot\alpha + \frac{g}{R}\,\alpha = 0\]

<p>This is simple harmonic motion with angular frequency</p>

\[\omega_S = \sqrt{\frac{g}{R}}\]

<p>and period</p>

\[\boxed{T_S = 2\pi\sqrt{\frac{R}{g}} \approx 84.4 \text{ min}}\]

<p>The system is <strong>Schuler-tuned</strong> when the feedback gain is chosen to produce
exactly this frequency. Critically, the amplitude of the oscillation does not
grow — initial tilt errors oscillate rather than diverge.</p>

<h3 id="23-state-space-form">2.3 State-Space Form</h3>

<p>The complete first-order error state for a single horizontal channel is:</p>

\[\frac{d}{dt}\begin{bmatrix}\delta v \\ \alpha\end{bmatrix} =
\begin{bmatrix}0 &amp; -g \\ 1/R &amp; 0\end{bmatrix}
\begin{bmatrix}\delta v \\ \alpha\end{bmatrix}\]

<p>The eigenvalues of this matrix are \(\pm j\omega_S\), confirming purely
oscillatory (neutrally stable) behaviour.</p>

<hr />

<h2 id="3-effect-on-inertial-navigation-system-errors">3. Effect on Inertial Navigation System Errors</h2>

<h3 id="31-gyroscope-drift">3.1 Gyroscope Drift</h3>

<p>A constant gyroscope drift rate \(\varepsilon\) (rad/s) acts as an input
disturbance. The resulting horizontal position error is:</p>

\[\delta x(t) = \frac{\varepsilon R}{g}\bigl(1 - \cos(\omega_S t)\bigr) \cdot g =
R\varepsilon\,\bigl(1 - \cos(\omega_S t)\bigr)\]

<p>The error is <strong>bounded</strong> and oscillates at the Schuler frequency. It does not
grow secularly — a direct consequence of Schuler tuning. The peak position
error from a drift \(\varepsilon\) is \(2R\varepsilon/\omega_S\).</p>

<h3 id="32-accelerometer-bias">3.2 Accelerometer Bias</h3>

<p>A constant accelerometer bias \(b\) (m/s²) produces a tilt error that also
oscillates at \(\omega_S\). The position error envelope is:</p>

\[|\delta x|_{\max} = \frac{b}{\omega_S^2} = \frac{bR}{g}\]

<h3 id="33-initial-condition-errors">3.3 Initial Condition Errors</h3>

<table>
  <thead>
    <tr>
      <th>Error source</th>
      <th>Position error growth</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Initial tilt \(\alpha_0\)</td>
      <td>\(R\,\alpha_0\,\sin(\omega_S t)\)</td>
    </tr>
    <tr>
      <td>Initial velocity \(\delta v_0\)</td>
      <td>\(\frac{\delta v_0}{\omega_S}\sin(\omega_S t)\)</td>
    </tr>
    <tr>
      <td>Gyro drift \(\varepsilon\)</td>
      <td>\(\frac{\varepsilon g}{\omega_S^2}(1-\cos\omega_S t)\)</td>
    </tr>
    <tr>
      <td>Accel bias \(b\)</td>
      <td>\(\frac{b}{\omega_S^2}(1-\cos\omega_S t)\)</td>
    </tr>
  </tbody>
</table>

<p>All errors are bounded and periodic — none diverge. This is the principal
practical benefit of Schuler tuning.</p>

<hr />

<h2 id="4-interactive-schuler-oscillation-simulator">4. Interactive: Schuler Oscillation Simulator</h2>

<p>The panel below integrates the two-state Schuler error equations in real time.
Adjust the initial conditions and sensor errors to observe how position and
tilt errors evolve over one or more Schuler periods.</p>

<style>
/* Schuler simulator — re-uses site CSS variables; adds only layout needed
   for the interactive panel. */
.schuler-panel {
  border: 1px solid var(--border);
  border-radius: var(--card-radius);
  padding: 1.5rem;
  background: var(--bg-alt);
  margin: 2rem 0;
}

.schuler-panel h3 {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 1.25rem;
  padding-bottom: 0.75rem;
  border-bottom: 1px solid var(--border);
}

.schuler-controls {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 1rem 1.5rem;
  margin-bottom: 1.5rem;
}

.schuler-field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.schuler-field label {
  font-size: 0.78rem;
  font-weight: 500;
  color: var(--text-muted);
}

.schuler-field input[type="range"] {
  width: 100%;
  accent-color: var(--accent);
  cursor: pointer;
}

.schuler-field .val {
  font-size: 0.78rem;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}

.schuler-canvas-wrap {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1rem;
}

.schuler-canvas-wrap canvas {
  width: 100%;
  height: 180px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--bg);
  display: block;
}

.schuler-caption {
  font-size: 0.73rem;
  color: var(--text-muted);
  text-align: center;
  margin-top: 0.3rem;
}

.schuler-btn {
  display: inline-block;
  margin-top: 1.25rem;
  padding: 0.5rem 1.5rem;
  font-size: 0.875rem;
  font-weight: 500;
  font-family: inherit;
  color: var(--text-muted);
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
}

.schuler-btn:hover {
  color: var(--text);
  border-color: var(--text-muted);
  background: var(--bg);
}

@media (max-width: 480px) {
  .schuler-canvas-wrap { grid-template-columns: 1fr; }
}
</style>

<div class="schuler-panel">
  <h3>Schuler Error Dynamics Simulator</h3>

  <div class="schuler-controls">
    <div class="schuler-field">
      <label>Initial tilt α₀ (arcsec)</label>
      <input type="range" id="sl-alpha0" min="-60" max="60" step="1" value="30" />
      <span class="val" id="sl-alpha0-val">30 arcsec</span>
    </div>
    <div class="schuler-field">
      <label>Initial velocity error δv₀ (m/s)</label>
      <input type="range" id="sl-dv0" min="-5" max="5" step="0.1" value="0" />
      <span class="val" id="sl-dv0-val">0.0 m/s</span>
    </div>
    <div class="schuler-field">
      <label>Gyro drift ε (°/h)</label>
      <input type="range" id="sl-drift" min="0" max="2" step="0.01" value="0.1" />
      <span class="val" id="sl-drift-val">0.10 °/h</span>
    </div>
    <div class="schuler-field">
      <label>Accel bias b (mGal)</label>
      <input type="range" id="sl-bias" min="0" max="500" step="5" value="0" />
      <span class="val" id="sl-bias-val">0 mGal</span>
    </div>
    <div class="schuler-field">
      <label>Simulation span (periods)</label>
      <input type="range" id="sl-span" min="1" max="4" step="1" value="2" />
      <span class="val" id="sl-span-val">2 × T_S</span>
    </div>
  </div>

  <div class="schuler-canvas-wrap">
    <div>
      <canvas id="cvs-pos"></canvas>
      <div class="schuler-caption">Horizontal position error (m)</div>
    </div>
    <div>
      <canvas id="cvs-tilt"></canvas>
      <div class="schuler-caption">Platform tilt error (arcsec)</div>
    </div>
  </div>

  <button class="schuler-btn" id="sl-reset">Reset to defaults</button>
</div>

<script>
(function () {
  // Physical constants
  const R  = 6_371_000;          // Earth radius (m)
  const g  = 9.80665;            // gravity (m/s²)
  const wS = Math.sqrt(g / R);   // Schuler angular frequency (rad/s)
  const TS = 2 * Math.PI / wS;   // ≈ 5066 s ≈ 84.4 min

  const AS = 1 / 206_265;        // arcsec → rad
  const DH = Math.PI / 180 / 3600; // °/h → rad/s
  const MG = 1e-5;               // mGal → m/s²

  const STEPS = 800;

  // Sliders
  function id(s) { return document.getElementById(s); }
  const sliders = {
    alpha0: id('sl-alpha0'),
    dv0:    id('sl-dv0'),
    drift:  id('sl-drift'),
    bias:   id('sl-bias'),
    span:   id('sl-span'),
  };
  const vals = {
    alpha0: id('sl-alpha0-val'),
    dv0:    id('sl-dv0-val'),
    drift:  id('sl-drift-val'),
    bias:   id('sl-bias-val'),
    span:   id('sl-span-val'),
  };

  const defaults = { alpha0: 30, dv0: 0, drift: 0.1, bias: 0, span: 2 };

  function getParams() {
    return {
      alpha0: +sliders.alpha0.value * AS,         // rad
      dv0:    +sliders.dv0.value,                  // m/s
      drift:  +sliders.drift.value * DH,           // rad/s
      bias:   +sliders.bias.value * MG,            // m/s²
      span:   +sliders.span.value,
    };
  }

  function updateLabels(p) {
    vals.alpha0.textContent = `${sliders.alpha0.value} arcsec`;
    vals.dv0.textContent    = `${(+sliders.dv0.value).toFixed(1)} m/s`;
    vals.drift.textContent  = `${(+sliders.drift.value).toFixed(2)} °/h`;
    vals.bias.textContent   = `${sliders.bias.value} mGal`;
    vals.span.textContent   = `${sliders.span.value} × T_S`;
  }

  // RK4 integration of Schuler error equations
  //   state: [dv, alpha]
  //   d/dt [dv, alpha] = [-g*alpha + b, dv/R + eps]  (bias + drift driven)
  function simulate(p) {
    const T  = p.span * TS;
    const dt = T / STEPS;
    let dv = p.dv0, alpha = p.alpha0;

    const ts   = new Float64Array(STEPS + 1);
    const pos  = new Float64Array(STEPS + 1);
    const tilt = new Float64Array(STEPS + 1);

    // Integrate position separately: dx/dt = dv
    let x = 0;
    ts[0]   = 0;
    pos[0]  = 0;
    tilt[0] = alpha / AS; // → arcsec for display

    function deriv(dv_, alpha_) {
      const ddv   = -g * alpha_ + p.bias;
      const dalpha = dv_ / R + p.drift;
      return [ddv, dalpha];
    }

    for (let i = 0; i < STEPS; i++) {
      const [k1dv, k1a] = deriv(dv, alpha);
      const [k2dv, k2a] = deriv(dv + 0.5*dt*k1dv, alpha + 0.5*dt*k1a);
      const [k3dv, k3a] = deriv(dv + 0.5*dt*k2dv, alpha + 0.5*dt*k2a);
      const [k4dv, k4a] = deriv(dv + dt*k3dv,     alpha + dt*k3a);

      dv    += dt * (k1dv + 2*k2dv + 2*k3dv + k4dv) / 6;
      alpha += dt * (k1a  + 2*k2a  + 2*k3a  + k4a ) / 6;
      x     += dv * dt;

      ts[i+1]   = (i+1) * dt / TS;         // in Schuler periods
      pos[i+1]  = x;
      tilt[i+1] = alpha / AS;              // arcsec
    }
    return { ts, pos, tilt };
  }

  // Canvas drawing
  function getColor(varName) {
    return getComputedStyle(document.documentElement)
      .getPropertyValue(varName).trim();
  }

  function draw(canvasId, ts, ys, yLabel, color) {
    const canvas = id(canvasId);
    const dpr = window.devicePixelRatio || 1;
    const W = canvas.offsetWidth  || 400;
    const H = canvas.offsetHeight || 180;
    canvas.width  = W * dpr;
    canvas.height = H * dpr;
    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);

    const PAD = { top: 12, right: 12, bottom: 28, left: 52 };
    const w = W - PAD.left - PAD.right;
    const h = H - PAD.top  - PAD.bottom;

    // Background
    ctx.clearRect(0, 0, W, H);

    // Axis lines
    const borderCol = getColor('--border') || '#e5e5e5';
    const mutedCol  = getColor('--text-muted') || '#737373';
    const textCol   = getColor('--text') || '#111';

    ctx.strokeStyle = borderCol;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.rect(PAD.left, PAD.top, w, h);
    ctx.stroke();

    // Data range
    let yMin = Math.min(...ys), yMax = Math.max(...ys);
    if (yMin === yMax) { yMin -= 1; yMax += 1; }
    const yPad = (yMax - yMin) * 0.1;
    yMin -= yPad; yMax += yPad;
    const xMax = ts[ts.length - 1];

    function mapX(t)  { return PAD.left + (t / xMax) * w; }
    function mapY(v)  { return PAD.top  + h - ((v - yMin) / (yMax - yMin)) * h; }

    // Zero line
    if (yMin < 0 && yMax > 0) {
      ctx.strokeStyle = borderCol;
      ctx.lineWidth = 0.8;
      ctx.setLineDash([4, 4]);
      ctx.beginPath();
      ctx.moveTo(PAD.left, mapY(0));
      ctx.lineTo(PAD.left + w, mapY(0));
      ctx.stroke();
      ctx.setLineDash([]);
    }

    // Period tick lines
    const nPeriods = Math.round(xMax);
    for (let p = 1; p <= nPeriods; p++) {
      const x = mapX(p);
      ctx.strokeStyle = borderCol;
      ctx.lineWidth = 0.8;
      ctx.setLineDash([3, 5]);
      ctx.beginPath();
      ctx.moveTo(x, PAD.top);
      ctx.lineTo(x, PAD.top + h);
      ctx.stroke();
      ctx.setLineDash([]);
      ctx.fillStyle = mutedCol;
      ctx.font = `${10}px monospace`;
      ctx.textAlign = 'center';
      ctx.fillText(`${p}T`, x, PAD.top + h + 16);
    }
    ctx.fillStyle = mutedCol;
    ctx.font = `${10}px monospace`;
    ctx.textAlign = 'center';
    ctx.fillText('0', mapX(0), PAD.top + h + 16);

    // Y axis labels
    const nTicks = 4;
    ctx.font = `${10}px monospace`;
    ctx.textAlign = 'right';
    for (let i = 0; i <= nTicks; i++) {
      const v = yMin + (yMax - yMin) * i / nTicks;
      const y = mapY(v);
      ctx.fillStyle = mutedCol;
      ctx.fillText(v.toFixed(1), PAD.left - 4, y + 3);
      ctx.strokeStyle = borderCol;
      ctx.lineWidth = 0.5;
      ctx.beginPath();
      ctx.moveTo(PAD.left - 2, y);
      ctx.lineTo(PAD.left, y);
      ctx.stroke();
    }

    // Data line
    ctx.strokeStyle = color;
    ctx.lineWidth = 1.8;
    ctx.lineJoin = 'round';
    ctx.beginPath();
    for (let i = 0; i < ts.length; i++) {
      const x = mapX(ts[i]);
      const y = mapY(ys[i]);
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    }
    ctx.stroke();
  }

  function run() {
    const p = getParams();
    updateLabels(p);
    const { ts, pos, tilt } = simulate(p);
    const accentCol = getColor('--accent') || '#0070f3';
    // Use a warm orange for tilt to distinguish the two channels
    draw('cvs-pos',  ts, pos,  'pos (m)',      accentCol);
    draw('cvs-tilt', ts, tilt, 'tilt (arcsec)', '#f59e0b');
  }

  // Event wiring
  Object.values(sliders).forEach(s => s.addEventListener('input', run));
  id('sl-reset').addEventListener('click', () => {
    Object.entries(defaults).forEach(([k, v]) => { sliders[k].value = v; });
    run();
  });

  // Initial render (defer until layout is stable)
  requestAnimationFrame(run);
  window.addEventListener('resize', run);
})();
</script>

<hr />

<h2 id="5-implications-for-ins-design">5. Implications for INS Design</h2>

<h3 id="51-why-schuler-tuning-matters">5.1 Why Schuler Tuning Matters</h3>

<p>An INS that is <strong>not</strong> Schuler-tuned will have eigenvalues with a non-zero
real part, causing position errors to grow exponentially. The 84.4-minute
period is the unique tuning condition that converts this exponential growth into
bounded oscillation — a form of neutral stability.</p>

<h3 id="52-aided-navigation">5.2 Aided Navigation</h3>

<p>In practice, pure inertial navigation accumulates errors at the Schuler
frequency. GNSS-aided systems (GPS/INS) exploit this: the Kalman filter
estimator observes the oscillating error signature, which allows it to
separate and estimate sensor biases far more effectively than a static test
would permit. The Schuler oscillation thus becomes a calibration signal.</p>

<h3 id="53-latitude-dependence">5.3 Latitude Dependence</h3>

<p>The derivation above assumes a spherical Earth. At latitude \(\varphi\), the
horizontal components of Earth’s rotation rate (\(\Omega\cos\varphi\),
\(\Omega\sin\varphi\)) perturb the error equations, coupling the north and east
channels. The Schuler frequency itself is <strong>latitude-invariant</strong> (it depends
only on \(g\) and \(R\), both weakly latitude-dependent), but the cross-channel
coupling introduces additional oscillatory modes — the <strong>Foucault oscillation</strong>
at approximately 24 h and a combined mode near 12 h.</p>

<h3 id="54-summary-of-error-modes">5.4 Summary of Error Modes</h3>

<table>
  <thead>
    <tr>
      <th>Mode</th>
      <th>Period</th>
      <th>Driven by</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Schuler oscillation</td>
      <td>84.4 min</td>
      <td>Gyro drift, accel bias, initial tilt</td>
    </tr>
    <tr>
      <td>Foucault oscillation</td>
      <td>≈ 24 h / sin φ</td>
      <td>Earth-rate coupling, gyro drift</td>
    </tr>
    <tr>
      <td>Combined (Schuler × Foucault)</td>
      <td>≈ 12 h</td>
      <td>Cross-channel coupling</td>
    </tr>
    <tr>
      <td>Secular (unbounded)</td>
      <td>—</td>
      <td>Only present in un-tuned systems</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="6-conclusion">6. Conclusion</h2>

<p>The Schuler period emerges as a natural consequence of Earth’s gravitational
geometry: it is precisely the orbital period of a satellite skimming Earth’s
surface. Designing an INS to oscillate at this frequency ensures that position
errors remain bounded — a property that no amount of sensor quality alone can
replace. For high-accuracy applications (submarine navigation, inertial
surveying, precision-guided systems), Schuler-period behaviour is the governing
constraint on long-term position error growth, and its interaction with
GNSS-aiding forms the basis of modern integrated navigation filter design.</p>

<hr />

<p><em>The simulator above integrates the linearised Schuler error equations using a
fourth-order Runge–Kutta scheme with 800 steps. Physical constants:</em>
\(R = 6{,}371\text{ km},\; g = 9.807\text{ m/s}^2,\; T_S = 84.4\text{ min}\).</p>]]></content><author><name>Kanishke Gamagedara</name></author><category term="navigation" /><category term="inertial-navigation" /><category term="gyroscopes" /><category term="classical-mechanics" /><summary type="html"><![CDATA[An inertial navigation system that is perfectly tuned to Earth's geometry will oscillate with a period of 84.4 minutes — a consequence of orbital mechanics known as the Schuler period. This post derives the result and explores its practical implications.]]></summary></entry><entry><title type="html">Quick Reference: Converting Latitude/Longitude Differences to Meters</title><link href="https://kanishkegb.github.io/2023/09/26/lat-lon-calc/" rel="alternate" type="text/html" title="Quick Reference: Converting Latitude/Longitude Differences to Meters" /><published>2023-09-26T00:00:00+00:00</published><updated>2023-09-26T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2023/09/26/lat-lon-calc</id><content type="html" xml:base="https://kanishkegb.github.io/2023/09/26/lat-lon-calc/"><![CDATA[<p>Converting angular coordinate differences into metric distances is a routine task in navigation, mapping, and geospatial engineering. This post serves as a self-contained reference, covering three cases in increasing generality: meridional distance (latitude-only change), parallel distance (longitude-only change), and the general two-point case via the Haversine formula.</p>

<hr />

<h2 id="earth-model--notation">Earth Model &amp; Notation</h2>

<p>All derivations here treat the Earth as a <strong>sphere of mean radius</strong></p>

\[R = 6{,}371{,}000 \text{ m}\]

<p>This introduces errors on the order of 0.3 % relative to a WGS-84 ellipsoid — acceptable for most engineering work at scales below a few hundred kilometres. Where sub-metre accuracy is required, consult ellipsoidal formulae (Vincenty, Karney).</p>

<table>
  <thead>
    <tr>
      <th>Symbol</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>\(\varphi\)</td>
      <td>Geodetic latitude (radians, positive North)</td>
    </tr>
    <tr>
      <td>\(\lambda\)</td>
      <td>Longitude (radians, positive East)</td>
    </tr>
    <tr>
      <td>\(\Delta\varphi,\,\Delta\lambda\)</td>
      <td>Small differences in latitude / longitude</td>
    </tr>
    <tr>
      <td>\(R\)</td>
      <td>Mean Earth radius (6 371 000 m)</td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="1--distance-along-a-meridian-latitude-difference">1 — Distance Along a Meridian (Latitude Difference)</h2>

<blockquote>
  <p><strong>Assumption.</strong> The two points share the same longitude, or their longitude separation is negligible compared with their latitude separation. Under the spherical model, a meridian is a great circle of radius \(R\), so arc length is simply proportional to the subtended angle.</p>
</blockquote>

\[\boxed{d_{\varphi} = R \,|\Delta\varphi|}\]

<p>where \(\Delta\varphi\) is in <strong>radians</strong>. In terms of decimal degrees \(\Delta\varphi_{\circ}\):</p>

\[d_{\varphi} = R \cdot \frac{\pi}{180}\,|\Delta\varphi_{\circ}|\]

<p><strong>Scale factor.</strong> One degree of latitude ≈ <strong>111 139 m</strong> (111.1 km), and this value is <em>constant</em> across all latitudes under the spherical assumption — meridians are all great circles of the same radius.</p>

<div class="calc-box">
  <p class="calc-label">Latitude difference → metres</p>
  <div class="calc-row">
    <label for="dlat-deg">Δφ (degrees)</label>
    <input type="number" id="dlat-deg" value="1.0" step="0.001" min="0" />
    <span class="calc-result" id="dlat-result">— m</span>
  </div>
  <p class="calc-note">Uses <em>d</em> = 6 371 000 × |Δφ| × π/180</p>
</div>

<!-- SVG: meridian arc diagram -->
<svg class="geo-diagram" viewBox="0 0 260 220" xmlns="http://www.w3.org/2000/svg" aria-label="Meridian arc diagram">
  <!-- Globe outline -->
  <ellipse cx="130" cy="110" rx="90" ry="90" class="gd-globe" />
  <!-- Equator dashed -->
  <ellipse cx="130" cy="110" rx="90" ry="22" class="gd-equator" />
  <!-- Meridian arc (the highlighted one) -->
  <path d="M 130 20 A 90 90 0 0 1 130 200" class="gd-meridian-bg" />
  <!-- Arc segment representing Δφ -->
  <path d="M 175 68 A 90 90 0 0 1 175 152" class="gd-arc-highlight" />
  <!-- Radius lines -->
  <line x1="130" y1="110" x2="175" y2="68" class="gd-radius" />
  <line x1="130" y1="110" x2="175" y2="152" class="gd-radius" />
  <!-- Angle label -->
  <text x="148" y="115" class="gd-label">Δφ</text>
  <!-- Arc label -->
  <text x="185" y="113" class="gd-label gd-label-arc">d<tspan dy="4" font-size="7">φ</tspan></text>
  <!-- Point dots -->
  <circle cx="175" cy="68" r="3.5" class="gd-dot" />
  <circle cx="175" cy="152" r="3.5" class="gd-dot" />
  <!-- R label -->
  <text x="138" y="85" class="gd-label gd-label-r">R</text>
</svg>

<hr />

<h2 id="2--distance-along-a-parallel-longitude-difference">2 — Distance Along a Parallel (Longitude Difference)</h2>

<blockquote>
  <p><strong>Assumption.</strong> The two points share the same latitude \(\varphi\), or their latitude separation is negligible. A parallel at latitude \(\varphi\) is <strong>not</strong> a great circle; its radius shrinks with the cosine of latitude.</p>
</blockquote>

<p>The radius of a parallel at latitude \(\varphi\) is \(R\cos\varphi\), so the arc length for a longitude change \(\Delta\lambda\) (radians) is:</p>

\[\boxed{d_{\lambda} = R\cos\varphi\,|\Delta\lambda|}\]

<p>In decimal degrees:</p>

\[d_{\lambda} = R\cos\varphi \cdot \frac{\pi}{180}\,|\Delta\lambda_{\circ}|}\]

<p><strong>Key consequence.</strong> One degree of longitude spans ≈ 111.1 km at the equator, but shrinks to zero at the poles. At 45° latitude it is ≈ 78.6 km; at 60° ≈ 55.6 km.</p>

<div class="calc-box">
  <p class="calc-label">Longitude difference → metres</p>
  <div class="calc-row">
    <label for="dlon-deg">Δλ (degrees)</label>
    <input type="number" id="dlon-deg" value="1.0" step="0.001" min="0" />
    <span class="calc-result" id="dlon-result">— m</span>
  </div>
  <div class="calc-row slider-row">
    <label for="lat-slider">Latitude φ</label>
    <input type="range" id="lat-slider" min="-90" max="90" value="0" step="0.5" />
    <span class="slider-val" id="lat-val">0.0°</span>
  </div>
  <p class="calc-note">Uses <em>d</em> = 6 371 000 × cos(φ) × |Δλ| × π/180</p>
</div>

<!-- SVG: parallel radius diagram -->
<svg class="geo-diagram" viewBox="0 0 280 220" xmlns="http://www.w3.org/2000/svg" aria-label="Parallel arc diagram">
  <!-- Globe -->
  <ellipse cx="130" cy="130" rx="90" ry="90" class="gd-globe" />
  <!-- Equator -->
  <ellipse cx="130" cy="130" rx="90" ry="22" class="gd-equator" />
  <!-- Parallel at ~40° N -->
  <ellipse cx="130" cy="72" rx="69" ry="17" class="gd-parallel-highlight" />
  <!-- Axis line -->
  <line x1="130" y1="40" x2="130" y2="220" class="gd-axis" />
  <!-- R to surface -->
  <line x1="130" y1="130" x2="199" y2="72" class="gd-radius" stroke-dasharray="4 3" />
  <!-- r = R cos φ horizontal -->
  <line x1="130" y1="72" x2="199" y2="72" class="gd-radius-r" />
  <!-- φ angle arc -->
  <path d="M 130 105 A 25 25 0 0 0 148 84" class="gd-phi-arc" />
  <text x="138" y="101" class="gd-label">φ</text>
  <!-- Labels -->
  <text x="156" y="67" class="gd-label gd-label-r">r = R cosφ</text>
  <text x="168" y="105" class="gd-label gd-label-r">R</text>
  <!-- Points on parallel -->
  <circle cx="199" cy="72" r="3.5" class="gd-dot" />
  <circle cx="61" cy="72" r="3.5" class="gd-dot" />
  <!-- Arc label -->
  <text x="118" y="56" class="gd-label gd-label-arc">d<tspan dy="4" font-size="7">λ</tspan></text>
</svg>

<hr />

<h2 id="3--general-two-point-distance-haversine-formula">3 — General Two-Point Distance: Haversine Formula</h2>

<p>For two arbitrary positions \((\varphi_1, \lambda_1)\) and \((\varphi_2, \lambda_2)\), the <strong>Haversine formula</strong> computes the great-circle distance on a sphere exactly (within the spherical assumption):</p>

\[a = \sin^2\!\left(\frac{\Delta\varphi}{2}\right) + \cos\varphi_1\,\cos\varphi_2\,\sin^2\!\left(\frac{\Delta\lambda}{2}\right)\]

\[\boxed{d = 2R\,\arctan2\!\left(\sqrt{a},\,\sqrt{1-a}\right)}\]

<p>where all angles are in radians. The use of \(\arctan2\) rather than \(\arcsin\) avoids numerical instability for antipodal points.</p>

<blockquote>
  <p><strong>Why Haversine?</strong> The naïve law of cosines form \(d = R\arccos(\sin\varphi_1\sin\varphi_2 + \cos\varphi_1\cos\varphi_2\cos\Delta\lambda)\) suffers from catastrophic cancellation for small separations in floating-point arithmetic. Haversine remains numerically stable at all scales.</p>
</blockquote>

<div class="calc-box">
  <p class="calc-label">Haversine: two-point great-circle distance</p>
  <div class="calc-row">
    <label for="hav-lat1">φ₁ (°)</label>
    <input type="number" id="hav-lat1" value="48.8566" step="0.0001" />
    <label for="hav-lon1">λ₁ (°)</label>
    <input type="number" id="hav-lon1" value="2.3522" step="0.0001" />
  </div>
  <div class="calc-row">
    <label for="hav-lat2">φ₂ (°)</label>
    <input type="number" id="hav-lat2" value="51.5074" step="0.0001" />
    <label for="hav-lon2">λ₂ (°)</label>
    <input type="number" id="hav-lon2" value="-0.1278" step="0.0001" />
  </div>
  <div class="hav-result-row">
    <span class="calc-result" id="hav-result">— m</span>
    <span class="calc-result-km" id="hav-result-km"></span>
  </div>
  <p class="calc-note">Paris → London by default. Uses Haversine with R = 6 371 000 m.</p>
</div>

<hr />

<h2 id="summary">Summary</h2>

<table>
  <thead>
    <tr>
      <th>Case</th>
      <th>Formula</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Latitude diff only</td>
      <td>\(d = R\,|\Delta\varphi|\)</td>
      <td>~111.1 km / degree, latitude-independent</td>
    </tr>
    <tr>
      <td>Longitude diff only</td>
      <td>\(d = R\cos\varphi\,|\Delta\lambda|\)</td>
      <td>scales with \(\cos\varphi\); zero at poles</td>
    </tr>
    <tr>
      <td>General</td>
      <td>Haversine</td>
      <td>Numerically stable great-circle distance</td>
    </tr>
  </tbody>
</table>

<p>All three converge for small \(\Delta\varphi, \Delta\lambda\) — the first two are simply the component projections of the Haversine result in the limit of small separation.</p>

<hr />

<style>
/* ── Calculator boxes ── */
.calc-box {
  background: var(--bg-alt);
  border: 1px solid var(--border);
  border-radius: var(--card-radius);
  padding: 1.1rem 1.35rem 1rem;
  margin: 1.5rem 0;
}
.calc-label {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 0.75rem;
}
.calc-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 0.75rem;
  margin-bottom: 0.55rem;
}
.calc-row label {
  font-size: 0.85rem;
  color: var(--text-muted);
  min-width: 6rem;
}
.calc-row input[type="number"] {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.9rem;
  width: 9rem;
  padding: 0.3rem 0.55rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  outline: none;
  transition: border-color 0.15s;
}
.calc-row input[type="number"]:focus {
  border-color: var(--accent);
}
.calc-result {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 1rem;
  font-weight: 600;
  color: var(--accent);
}
.calc-note {
  font-size: 0.78rem;
  color: var(--text-muted);
  margin-top: 0.5rem;
  font-style: italic;
}
/* Latitude slider */
.slider-row input[type="range"] {
  flex: 1;
  min-width: 120px;
  accent-color: var(--accent);
  cursor: pointer;
}
.slider-val {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.875rem;
  color: var(--text);
  min-width: 4rem;
  text-align: right;
}
/* Haversine result row */
.hav-result-row {
  display: flex;
  align-items: baseline;
  gap: 0.75rem;
  margin: 0.5rem 0 0.25rem;
}
.calc-result-km {
  font-size: 0.85rem;
  color: var(--text-muted);
}

/* ── SVG Diagrams ── */
.geo-diagram {
  display: block;
  width: 100%;
  max-width: 300px;
  margin: 1.25rem auto 1.75rem;
}
.gd-globe      { fill: none; stroke: var(--border); stroke-width: 1.5; }
.gd-equator    { fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 5 3; }
.gd-meridian-bg{ fill: none; stroke: var(--border); stroke-width: 1; stroke-dasharray: 4 3; }
.gd-arc-highlight{ fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; }
.gd-radius     { stroke: var(--text-muted); stroke-width: 1; fill: none; }
.gd-dot        { fill: var(--accent); }
.gd-label      { font-size: 11px; fill: var(--text); font-family: Georgia, serif; font-style: italic; }
.gd-label-arc  { fill: var(--accent); font-weight: 600; font-style: normal; }
.gd-label-r    { fill: var(--text-muted); }
.gd-parallel-highlight { fill: none; stroke: var(--accent); stroke-width: 2; }
.gd-axis       { stroke: var(--border); stroke-width: 1; stroke-dasharray: 3 3; }
.gd-radius-r   { stroke: var(--accent); stroke-width: 1.5; fill: none; stroke-dasharray: 3 2; }
.gd-phi-arc    { fill: none; stroke: var(--text-muted); stroke-width: 1; }
</style>

<script>
(function () {
  const R = 6371000;
  const toRad = d => d * Math.PI / 180;

  /* --- Section 1: latitude diff --- */
  function updateDLat() {
    const dDeg = parseFloat(document.getElementById('dlat-deg').value) || 0;
    const m = R * Math.abs(toRad(dDeg));
    document.getElementById('dlat-result').textContent =
      m >= 1000 ? (m / 1000).toFixed(3) + ' km' : m.toFixed(1) + ' m';
  }
  document.getElementById('dlat-deg').addEventListener('input', updateDLat);
  updateDLat();

  /* --- Section 2: longitude diff with slider --- */
  function updateDLon() {
    const dDeg = parseFloat(document.getElementById('dlon-deg').value) || 0;
    const lat  = parseFloat(document.getElementById('lat-slider').value) || 0;
    document.getElementById('lat-val').textContent = lat.toFixed(1) + '°';
    const m = R * Math.cos(toRad(lat)) * Math.abs(toRad(dDeg));
    document.getElementById('dlon-result').textContent =
      m >= 1000 ? (m / 1000).toFixed(3) + ' km' : m.toFixed(1) + ' m';
  }
  document.getElementById('dlon-deg').addEventListener('input', updateDLon);
  document.getElementById('lat-slider').addEventListener('input', updateDLon);
  updateDLon();

  /* --- Section 3: Haversine --- */
  function haversine(lat1, lon1, lat2, lon2) {
    const phi1 = toRad(lat1), phi2 = toRad(lat2);
    const dphi = toRad(lat2 - lat1);
    const dlam = toRad(lon2 - lon1);
    const a = Math.sin(dphi/2)**2 +
              Math.cos(phi1) * Math.cos(phi2) * Math.sin(dlam/2)**2;
    return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  }

  function updateHav() {
    const lat1 = parseFloat(document.getElementById('hav-lat1').value);
    const lon1 = parseFloat(document.getElementById('hav-lon1').value);
    const lat2 = parseFloat(document.getElementById('hav-lat2').value);
    const lon2 = parseFloat(document.getElementById('hav-lon2').value);
    if ([lat1, lon1, lat2, lon2].some(isNaN)) return;
    const m = haversine(lat1, lon1, lat2, lon2);
    document.getElementById('hav-result').textContent    = m.toFixed(1) + ' m';
    document.getElementById('hav-result-km').textContent = '(' + (m/1000).toFixed(3) + ' km)';
  }

  ['hav-lat1','hav-lon1','hav-lat2','hav-lon2'].forEach(id =>
    document.getElementById(id).addEventListener('input', updateHav));
  updateHav();
})();
</script>]]></content><author><name>Kanishke Gamagedara</name></author><category term="navigation" /><category term="geodesy" /><category term="reference" /><summary type="html"><![CDATA[A practical reference for converting small angular differences in latitude or longitude into metric distances, with interactive tools and the Haversine formula for arbitrary point pairs.]]></summary></entry><entry><title type="html">Rotation Matrix Construction</title><link href="https://kanishkegb.github.io/2023/08/03/rotation-matrix-construction/" rel="alternate" type="text/html" title="Rotation Matrix Construction" /><published>2023-08-03T00:00:00+00:00</published><updated>2023-08-03T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2023/08/03/rotation-matrix-construction</id><content type="html" xml:base="https://kanishkegb.github.io/2023/08/03/rotation-matrix-construction/"><![CDATA[<p>This is a quick reference for constructing a rotation matrix in \(SO(3)\), primarily for coordinate frame transformations.</p>

<h2 id="basic-introduction">Basic Introduction</h2>

<p>A rotation matrix in \(SO(3)\) is a linear transformation between coordinate frames.
Let \(\{e\}\) and \(\{b\}\) be two arbitrary Euclidean coordinate frames, and let \(x^e\) and \(x^b\) denote the same vector expressed in each frame, respectively.</p>

<p>The rotation matrix \(R^e_b \in SO(3)\) describes the orientation of frame \(\{b\}\) relative to frame \(\{e\}\).
Pre-multiplying a vector expressed in \(\{b\}\) by this matrix yields its representation in \(\{e\}\):</p>

\[x^e = R^e_b \, x^b.\]

<p>Since \(R^e_b \in SO(3)\), its inverse equals its transpose:</p>

\[R^b_e = \left(R^e_b\right)^T.\]

<p>Therefore, \(R^b_e\) transforms a vector from frame \(\{e\}\) into frame \(\{b\}\).</p>

<h2 id="so3-properties">SO(3) Properties</h2>

<p>A matrix \(R\) belongs to \(SO(3)\) if and only if it satisfies two conditions:</p>

\[R^T R = I, \qquad \det(R) = +1.\]

<p>The first condition enforces orthonormality of columns (and rows); the second rules out improper rotations (reflections). These constraints imply that each column of \(R\) is a unit vector, and any two distinct columns are mutually orthogonal.</p>

<h2 id="constructing-the-rotation-matrix">Constructing the Rotation Matrix</h2>

<p>The key insight is:</p>

<blockquote>
  <p>The \(i\)-th column of \(R^e_b\) is the unit vector \(b_i\) expressed in the coordinates of frame \(\{e\}\).</p>
</blockquote>

<p>Consider two frames illustrated below, where each \(e_i\) and \(b_i\) is a unit basis vector.</p>

<p><img src="/assets/images/posts/rotation-matrix-construction/coordinate-frames.png" alt="Coordinate frames" /></p>

<p>To find each column, project \(b_i\) onto each axis of \(\{e\}\) using the dot product:</p>

\[\left(R^e_b\right)_{ji} = e_j \cdot b_i.\]

<p>Inspecting the figure for the example configuration:</p>

<ul>
  <li>\(b_1\) is aligned with \(-e_3\), with no projection onto \(e_1\) or \(e_2\):</li>
</ul>

\[b_1^e = \begin{bmatrix} 0 \\ 0 \\ -1 \end{bmatrix}\]

<ul>
  <li>\(b_2\) is aligned with \(-e_2\):</li>
</ul>

\[b_2^e = \begin{bmatrix} 0 \\ -1 \\ 0 \end{bmatrix}\]

<ul>
  <li>\(b_3\) is aligned with \(-e_1\):</li>
</ul>

\[b_3^e = \begin{bmatrix} -1 \\ 0 \\ 0 \end{bmatrix}\]

<p>Assembling these column vectors gives the rotation matrix:</p>

\[R^e_b = \begin{bmatrix} b_1^e &amp; b_2^e &amp; b_3^e \end{bmatrix} = \begin{bmatrix} 0 &amp; 0 &amp; -1 \\ 0 &amp; -1 &amp; 0 \\ -1 &amp; 0 &amp; 0 \end{bmatrix}.\]

<p>One can verify the \(SO(3)\) conditions: the columns are mutually orthogonal unit vectors, and \(\det(R^e_b) = +1\).</p>

<h2 id="interactive-visualizer">Interactive Visualizer</h2>

<p>The demo below lets you freely orient the body frame \(\{b\}\) relative to the fixed frame \(\{e\}\) using Euler angle sliders. The rotation matrix is updated live, and the bottom panel shows how it transforms an arbitrary vector.</p>

<style>
.rot-demo {
  background: var(--bg-alt);
  border: 1px solid var(--border);
  border-radius: var(--card-radius);
  padding: 1.25rem;
  margin: 1.75rem 0;
  font-size: 0.85rem;
}

.rot-demo-top {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1.25rem;
  align-items: start;
}

.rot-canvas-wrap {
  aspect-ratio: 1 / 1;
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--bg);
  border: 1px solid var(--border);
}

.rot-canvas-wrap canvas {
  width: 100%;
  height: 100%;
  display: block;
}

.rot-right {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.rot-sliders {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.rot-slider-row {
  display: grid;
  grid-template-columns: 80px 1fr 44px;
  align-items: center;
  gap: 0.5rem;
}

.rot-slider-label {
  font-size: 0.78rem;
  color: var(--text-muted);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

.rot-slider-row input[type=range] {
  width: 100%;
  accent-color: var(--accent);
  cursor: pointer;
}

.rot-slider-val {
  font-size: 0.78rem;
  color: var(--text);
  text-align: right;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

.rot-matrix-wrap {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.75rem 1rem;
}

.rot-matrix-title {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 0.6rem;
}

.rot-matrix-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 3px;
}

.rot-cell {
  background: var(--bg-alt);
  border-radius: 3px;
  padding: 0.3rem 0.2rem;
  text-align: center;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.78rem;
  color: var(--text);
  transition: background 0.15s, color 0.15s;
}

.rot-cell.col-1 { color: #e05252; }
.rot-cell.col-2 { color: #4caf76; }
.rot-cell.col-3 { color: #4a9ede; }

[data-theme="dark"] .rot-cell.col-1 { color: #f48080; }
[data-theme="dark"] .rot-cell.col-2 { color: #6dcf96; }
[data-theme="dark"] .rot-cell.col-3 { color: #70b8f0; }

.rot-transform-wrap {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.75rem 1rem;
}

.rot-transform-title {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 0.6rem;
}

.rot-transform-row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

.rot-vec {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.rot-vec-cell {
  background: var(--bg-alt);
  border-radius: 3px;
  padding: 0.25rem 0.5rem;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 0.78rem;
  color: var(--text);
  width: 54px;
  text-align: center;
}

.rot-vec-cell input {
  width: 100%;
  background: none;
  border: none;
  outline: none;
  font-family: inherit;
  font-size: inherit;
  color: inherit;
  text-align: center;
  padding: 0;
}

.rot-vec-label {
  font-size: 0.7rem;
  color: var(--text-muted);
  text-align: center;
  margin-top: 2px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

.rot-op {
  font-size: 1.1rem;
  color: var(--text-muted);
  flex-shrink: 0;
}

.rot-eq { color: var(--accent); font-weight: 600; }

.rot-reset-btn {
  font-size: 0.75rem;
  font-weight: 500;
  font-family: inherit;
  color: var(--text-muted);
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 0.3rem 0.75rem;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s, background 0.15s;
  align-self: flex-start;
}

.rot-reset-btn:hover {
  color: var(--text);
  border-color: var(--text-muted);
  background: var(--bg-alt);
}

.rot-demo-hint {
  font-size: 0.75rem;
  color: var(--text-muted);
  margin-top: 0.75rem;
  font-style: italic;
}

@media (max-width: 560px) {
  .rot-demo-top {
    grid-template-columns: 1fr;
  }
}
</style>

<div class="rot-demo">
  <div class="rot-demo-top">
    <div class="rot-canvas-wrap">
      <canvas id="rotCanvas"></canvas>
    </div>
    <div class="rot-right">
      <div class="rot-sliders">
        <div class="rot-slider-row">
          <span class="rot-slider-label">α (Z-axis)</span>
          <input type="range" id="sliderAlpha" min="-180" max="180" value="0" step="1" />
          <span class="rot-slider-val" id="valAlpha">0°</span>
        </div>
        <div class="rot-slider-row">
          <span class="rot-slider-label">β (Y-axis)</span>
          <input type="range" id="sliderBeta" min="-180" max="180" value="0" step="1" />
          <span class="rot-slider-val" id="valBeta">0°</span>
        </div>
        <div class="rot-slider-row">
          <span class="rot-slider-label">γ (X-axis)</span>
          <input type="range" id="sliderGamma" min="-180" max="180" value="0" step="1" />
          <span class="rot-slider-val" id="valGamma">0°</span>
        </div>
        <button class="rot-reset-btn" id="resetBtn">Reset to identity</button>
      </div>

      <div class="rot-matrix-wrap">
        <div class="rot-matrix-title">R<sup>e</sup><sub>b</sub> — columns are b<sub>i</sub> in {e}</div>
        <div class="rot-matrix-grid" id="matrixGrid">
          <!-- 9 cells filled by JS -->
        </div>
      </div>

      <div class="rot-transform-wrap">
        <div class="rot-transform-title">Vector transform: x<sup>e</sup> = R · x<sup>b</sup></div>
        <div class="rot-transform-row">
          <div class="rot-vec">
            <div class="rot-vec-cell"><input id="vb0" type="number" value="1" step="0.1" /></div>
            <div class="rot-vec-cell"><input id="vb1" type="number" value="0" step="0.1" /></div>
            <div class="rot-vec-cell"><input id="vb2" type="number" value="0" step="0.1" /></div>
            <div class="rot-vec-label">x<sup>b</sup></div>
          </div>
          <div>
            <div class="rot-op">→</div>
          </div>
          <div class="rot-vec">
            <div class="rot-vec-cell" id="ve0">—</div>
            <div class="rot-vec-cell" id="ve1">—</div>
            <div class="rot-vec-cell" id="ve2">—</div>
            <div class="rot-vec-label">x<sup>e</sup></div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <p class="rot-demo-hint">Drag the sliders to rotate frame {b} relative to {e} using ZYX Euler angles. The matrix columns (colour-coded red/green/blue for b₁/b₂/b₃) update live. Edit x<sup>b</sup> to see the transformed vector.</p>
</div>

<script>
(function () {
  /* ── helpers ── */
  const deg = a => a * Math.PI / 180;
  const fmt = v => v.toFixed(2);

  /* ZYX Euler: Rz(α) · Ry(β) · Rx(γ) */
  function eulerToMatrix(alpha, beta, gamma) {
    const ca = Math.cos(deg(alpha)), sa = Math.sin(deg(alpha));
    const cb = Math.cos(deg(beta)),  sb = Math.sin(deg(beta));
    const cg = Math.cos(deg(gamma)), sg = Math.sin(deg(gamma));
    return [
      ca*cb,  ca*sb*sg - sa*cg,  ca*sb*cg + sa*sg,
      sa*cb,  sa*sb*sg + ca*cg,  sa*sb*cg - ca*sg,
      -sb,    cb*sg,             cb*cg
    ];
  }

  /* ── sliders ── */
  let alpha = 0, beta = 0, gamma = 0;
  const sA = document.getElementById('sliderAlpha');
  const sB = document.getElementById('sliderBeta');
  const sG = document.getElementById('sliderGamma');
  const vA = document.getElementById('valAlpha');
  const vB = document.getElementById('valBeta');
  const vG = document.getElementById('valGamma');

  sA.addEventListener('input', () => { alpha = +sA.value; vA.textContent = alpha + '°'; update(); });
  sB.addEventListener('input', () => { beta  = +sB.value; vB.textContent = beta  + '°'; update(); });
  sG.addEventListener('input', () => { gamma = +sG.value; vG.textContent = gamma + '°'; update(); });
  document.getElementById('resetBtn').addEventListener('click', () => {
    sA.value = sB.value = sG.value = 0;
    alpha = beta = gamma = 0;
    vA.textContent = vB.textContent = vG.textContent = '0°';
    update();
  });

  /* ── matrix display ── */
  const grid = document.getElementById('matrixGrid');
  const colClass = ['col-1','col-2','col-3'];
  // create 9 cells, row-major but colour by column
  const cells = [];
  for (let r = 0; r < 3; r++) {
    for (let c = 0; c < 3; c++) {
      const d = document.createElement('div');
      d.className = 'rot-cell ' + colClass[c];
      grid.appendChild(d);
      cells.push(d);
    }
  }

  function updateMatrix(R) {
    for (let r = 0; r < 3; r++)
      for (let c = 0; c < 3; c++)
        cells[r*3+c].textContent = fmt(R[r*3+c]);
  }

  /* ── vector transform ── */
  const vbInputs = [document.getElementById('vb0'), document.getElementById('vb1'), document.getElementById('vb2')];
  const veEls    = [document.getElementById('ve0'),  document.getElementById('ve1'),  document.getElementById('ve2')];
  vbInputs.forEach(el => el.addEventListener('input', update));

  function updateVec(R) {
    const xb = vbInputs.map(el => +el.value || 0);
    for (let r = 0; r < 3; r++) {
      const v = R[r*3]*xb[0] + R[r*3+1]*xb[1] + R[r*3+2]*xb[2];
      veEls[r].textContent = fmt(v);
    }
  }

  /* ── canvas 3D ── */
  const canvas = document.getElementById('rotCanvas');
  const ctx    = canvas.getContext('2d');

  function resize() {
    const rect = canvas.parentElement.getBoundingClientRect();
    canvas.width  = rect.width  * devicePixelRatio;
    canvas.height = rect.height * devicePixelRatio;
    ctx.scale(devicePixelRatio, devicePixelRatio);
  }
  window.addEventListener('resize', () => { resize(); drawScene(eulerToMatrix(alpha, beta, gamma)); });
  resize();

  /* isometric-style projection */
  function project(x, y, z, W, H, scale) {
    /* simple oblique projection tilted for readability */
    const px = W/2 + scale*(x - z*0.45);
    const py = H/2 + scale*(-y + z*0.25);
    return [px, py];
  }

  const AXIS_COLORS = {
    e: ['#e05252','#4caf76','#4a9ede'],
    b: ['#f48080','#6dcf96','#70b8f0']
  };

  function drawArrow(ctx, x0, y0, x1, y1, color, label, dashed) {
    const dx = x1-x0, dy = y1-y0;
    const len = Math.sqrt(dx*dx+dy*dy);
    if (len < 1) return;
    const ux = dx/len, uy = dy/len;
    const headLen = 9, headAng = 0.42;
    ctx.beginPath();
    if (dashed) ctx.setLineDash([4,3]); else ctx.setLineDash([]);
    ctx.moveTo(x0, y0);
    ctx.lineTo(x1, y1);
    ctx.strokeStyle = color;
    ctx.lineWidth = dashed ? 1.5 : 2;
    ctx.stroke();
    ctx.setLineDash([]);
    /* arrowhead */
    ctx.beginPath();
    ctx.moveTo(x1, y1);
    ctx.lineTo(x1 - headLen*(ux*Math.cos(headAng)-uy*Math.sin(headAng)),
               y1 - headLen*(uy*Math.cos(headAng)+ux*Math.sin(headAng)));
    ctx.moveTo(x1, y1);
    ctx.lineTo(x1 - headLen*(ux*Math.cos(-headAng)-uy*Math.sin(-headAng)),
               y1 - headLen*(uy*Math.cos(-headAng)+ux*Math.sin(-headAng)));
    ctx.strokeStyle = color;
    ctx.lineWidth = 2;
    ctx.stroke();
    /* label */
    if (label) {
      ctx.fillStyle = color;
      ctx.font = '600 11px JetBrains Mono, Fira Code, monospace';
      ctx.fillText(label, x1 + ux*6 + 3, y1 + uy*6 + 4);
    }
  }

  function drawScene(R) {
    const W = canvas.width  / devicePixelRatio;
    const H = canvas.height / devicePixelRatio;
    const scale = Math.min(W, H) * 0.28;

    ctx.clearRect(0, 0, W, H);

    /* subtle grid */
    ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#e5e5e5';
    ctx.lineWidth = 0.5;
    ctx.setLineDash([2, 6]);
    for (let g = -2; g <= 2; g++) {
      const [ax, ay] = project(g, 0, -2, W, H, scale);
      const [bx, by] = project(g, 0,  2, W, H, scale);
      ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(bx,by); ctx.stroke();
      const [cx, cy] = project(-2, 0, g, W, H, scale);
      const [dx, dy] = project( 2, 0, g, W, H, scale);
      ctx.beginPath(); ctx.moveTo(cx,cy); ctx.lineTo(dx,dy); ctx.stroke();
    }
    ctx.setLineDash([]);

    /* fixed frame {e} */
    const eAxes = [[1,0,0],[0,1,0],[0,0,1]];
    const eLabels = ['e₁','e₂','e₃'];
    const eColors = AXIS_COLORS.e;
    eAxes.forEach(([x,y,z], i) => {
      const [ox,oy] = project(0,0,0, W,H,scale);
      const [ax,ay] = project(x,y,z, W,H,scale);
      drawArrow(ctx, ox, oy, ax, ay, eColors[i], eLabels[i], false);
    });

    /* body frame {b} — columns of R */
    const bColors = AXIS_COLORS.b;
    const bLabels = ['b₁','b₂','b₃'];
    for (let c = 0; c < 3; c++) {
      const bx = R[0*3+c], by = R[1*3+c], bz = R[2*3+c];
      const [ox,oy] = project(0,0,0, W,H,scale);
      const [ax,ay] = project(bx,by,bz, W,H,scale);
      drawArrow(ctx, ox, oy, ax, ay, bColors[c], bLabels[c], true);
    }

    /* origin dot */
    const [ox,oy] = project(0,0,0,W,H,scale);
    ctx.beginPath();
    ctx.arc(ox,oy,3,0,Math.PI*2);
    ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--text').trim() || '#111';
    ctx.fill();

    /* legend */
    ctx.font = '500 10px JetBrains Mono, Fira Code, monospace';
    const legendY = H - 12;
    ctx.fillStyle = eColors[0]; ctx.fillText('— {e} frame', 10, legendY - 14);
    ctx.fillStyle = bColors[0]; ctx.fillText('⋯ {b} frame', 10, legendY);
  }

  /* ── main update ── */
  function update() {
    const R = eulerToMatrix(alpha, beta, gamma);
    drawScene(R);
    updateMatrix(R);
    updateVec(R);
  }

  update();
})();
</script>

<h2 id="verifying-the-result">Verifying the Result</h2>

<p>Given a valid \(R^e_b\), one can verify the \(SO(3)\) conditions numerically:</p>

<p>\(R^T R = I \quad \Longleftrightarrow \quad \text{columns are orthonormal},\)
\(\det(R) = +1 \quad \Longleftrightarrow \quad \text{proper rotation (no reflection)}.\)</p>

<p>For the example in the figure:</p>

\[R^e_b = \begin{bmatrix} 0 &amp; 0 &amp; -1 \\ 0 &amp; -1 &amp; 0 \\ -1 &amp; 0 &amp; 0 \end{bmatrix}, \qquad \det(R^e_b) = (-1)\bigl[0\cdot 0 - (-1)(-1)\bigr] = (-1)(-1) = +1. \checkmark\]

<p>Checking orthogonality, \(R^T R = I\) holds since each column is a standard basis vector (up to sign), which are trivially orthonormal.</p>]]></content><author><name>Kanishke Gamagedara</name></author><category term="guides" /><category term="tutorials" /><category term="math" /><category term="linear-algebra" /><summary type="html"><![CDATA[A concise guide to constructing a rotation matrix in SO(3) for coordinate frame transformations, with an interactive visualizer.]]></summary></entry><entry><title type="html">Job Search Data - 2023 June</title><link href="https://kanishkegb.github.io/2023/07/01/job-search-sankey-plot/" rel="alternate" type="text/html" title="Job Search Data - 2023 June" /><published>2023-07-01T00:00:00+00:00</published><updated>2023-07-01T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2023/07/01/job-search-sankey-plot</id><content type="html" xml:base="https://kanishkegb.github.io/2023/07/01/job-search-sankey-plot/"><![CDATA[<p>“Shopping” for a job is not always a fun process. 
After a large layoff at my previous employment (a start-up company) in May 2023, I started searching for a new job. 
I decided to make this job search “fun” by collecting some data and creating some cool plots!</p>

<h4 id="-linkedin-was-great">🔑 LinkedIn was great!</h4>

<p>One thing I found interesting the most is how much easy it is to find matching opportunities in LinkedIn, at least for the field I am in.
I tried a few other places including Indeed, but LinkedIn suggestions were the most relevant by miles.
Also, that was true for the recruiters that reached out to me through the LinkedIn profile.</p>

<h4 id="-connections-get-you-far-">🦾 Connections get you far …</h4>

<p>Another thing I noticed is that all opportunities I applied through a connection (a previous co-worker or a friend) always resulted in at least a callback for the initial screening.
On the other hand, a larger percentage (almost 60%) of the jobs I applied through LinkedIn did not provide me any feedback or a response.</p>

<p>However, I do not want to discount the opportunities I had through direct applying through the LinkedIn.
I had four positive mid/final stage interviews, and two of them were opportunities I directly applied through LinkedIn, third was a referral by a friend, and fourth was through a recruiter reached out to me through the LinkedIn profile.
The one I ultimately went with was a job I directly applied through LinkedIn.</p>

<h4 id="-visualizations-are-cool-especially-when-they-are-interactive">📊 Visualizations are cool! (especially when they are interactive)</h4>

<p>You can see all the data I collected in this sankey plot.
This is interactive, and generated with plotly.
Feel free to move things around and see if you want to explore data.
You can always reset the plot by clicking the “Home” icon on the top right of the plot.</p>

<p>You can check <a href="/plotly-with-markdown/">this</a> post to learn about embedding interactive plots.</p>

<div>                        <script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
        <script charset="utf-8" src="https://cdn.plot.ly/plotly-2.20.0.min.js"></script>                <div id="327a2fa1-c432-4c44-807e-7811cad84f98" class="plotly-graph-div" style="height:100%; width:100%;"></div>            <script type="text/javascript">                                    window.PLOTLYENV=window.PLOTLYENV || {};                                    if (document.getElementById("327a2fa1-c432-4c44-807e-7811cad84f98")) {                    Plotly.newPlot(                        "327a2fa1-c432-4c44-807e-7811cad84f98",                        [{"link":{"label":[true,false,true,false,true,false,"Location is too far","Decided not to move forward","Clearance not met","Not relevant","","","Location is too far","Decided not to move forward","Clearance not met","Not relevant",true,true,"Accepted another offer",true,"Accepted another offer","Y"],"source":[0,0,1,1,2,2,4,4,4,4,4,3,3,3,3,3,3,8,8,17,17,19],"target":[3,4,3,4,3,4,12,13,14,15,7,7,12,13,14,15,8,17,16,19,16,6],"value":[6,25,9,2,2,4,0,8,0,1,22,4,4,1,2,2,4,3,1,1,2,1]},"node":{"label":["LinkedIn","Recruiters","Connections","Initial Screening","No Screening","Decision: No","Accepted offer","No Response","Interview 2","Interview 2 Not Scheduled","Decision Owner: Me","Decision Owner: Employer","Location did not workout","Employer decided not to move forward","Security clearance not met","Not relevant","Accepted another Offer","Interview 3","Interview 3 Not Scheduled","Interview 4","Interview 4 Not Scheduled"],"line":{"color":"black","width":0.5},"pad":15,"thickness":15,"x":[0.01,0.01,0.01,0.2,0.2,0.9,0.6,0.3,0.7,0.7,0.7,0.7,0.9,0.45,0.65],"y":[0.1,0.55,0.8,0.6,0.1,0.78,0.06,0.7,0.55,0.45,0.3,0.35,0.7,0.85,0.85]},"valueformat":".0f","valuesuffix":"","type":"sankey"}],                        {"template":{"data":{"histogram2dcontour":[{"type":"histogram2dcontour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"choropleth":[{"type":"choropleth","colorbar":{"outlinewidth":0,"ticks":""}}],"histogram2d":[{"type":"histogram2d","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"heatmap":[{"type":"heatmap","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"heatmapgl":[{"type":"heatmapgl","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"contourcarpet":[{"type":"contourcarpet","colorbar":{"outlinewidth":0,"ticks":""}}],"contour":[{"type":"contour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"surface":[{"type":"surface","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"mesh3d":[{"type":"mesh3d","colorbar":{"outlinewidth":0,"ticks":""}}],"scatter":[{"fillpattern":{"fillmode":"overlay","size":10,"solidity":0.2},"type":"scatter"}],"parcoords":[{"type":"parcoords","line":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolargl":[{"type":"scatterpolargl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"bar":[{"error_x":{"color":"#2a3f5f"},"error_y":{"color":"#2a3f5f"},"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"bar"}],"scattergeo":[{"type":"scattergeo","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolar":[{"type":"scatterpolar","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"histogram":[{"marker":{"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"histogram"}],"scattergl":[{"type":"scattergl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatter3d":[{"type":"scatter3d","line":{"colorbar":{"outlinewidth":0,"ticks":""}},"marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattermapbox":[{"type":"scattermapbox","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterternary":[{"type":"scatterternary","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattercarpet":[{"type":"scattercarpet","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"carpet":[{"aaxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"baxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"type":"carpet"}],"table":[{"cells":{"fill":{"color":"#EBF0F8"},"line":{"color":"white"}},"header":{"fill":{"color":"#C8D4E3"},"line":{"color":"white"}},"type":"table"}],"barpolar":[{"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"barpolar"}],"pie":[{"automargin":true,"type":"pie"}]},"layout":{"autotypenumbers":"strict","colorway":["#636efa","#EF553B","#00cc96","#ab63fa","#FFA15A","#19d3f3","#FF6692","#B6E880","#FF97FF","#FECB52"],"font":{"color":"#2a3f5f"},"hovermode":"closest","hoverlabel":{"align":"left"},"paper_bgcolor":"white","plot_bgcolor":"#E5ECF6","polar":{"bgcolor":"#E5ECF6","angularaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"radialaxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"ternary":{"bgcolor":"#E5ECF6","aaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"baxis":{"gridcolor":"white","linecolor":"white","ticks":""},"caxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"coloraxis":{"colorbar":{"outlinewidth":0,"ticks":""}},"colorscale":{"sequential":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"sequentialminus":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"diverging":[[0,"#8e0152"],[0.1,"#c51b7d"],[0.2,"#de77ae"],[0.3,"#f1b6da"],[0.4,"#fde0ef"],[0.5,"#f7f7f7"],[0.6,"#e6f5d0"],[0.7,"#b8e186"],[0.8,"#7fbc41"],[0.9,"#4d9221"],[1,"#276419"]]},"xaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"yaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"scene":{"xaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"yaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"zaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2}},"shapedefaults":{"line":{"color":"#2a3f5f"}},"annotationdefaults":{"arrowcolor":"#2a3f5f","arrowhead":0,"arrowwidth":1},"geo":{"bgcolor":"white","landcolor":"#E5ECF6","subunitcolor":"white","showland":true,"showlakes":true,"lakecolor":"white"},"title":{"x":0.05},"mapbox":{"style":"light"}}}},                        {"responsive": true}                    )                };                            </script>        </div>]]></content><author><name>Kanishke Gamagedara</name></author><category term="[&quot;projects&quot;, &quot;plots&quot;, &quot;python&quot;, &quot;jupyter&quot;, &quot;plotly&quot;]" /><summary type="html"><![CDATA[“Shopping” for a job is not always a fun process. After a large layoff at my previous employment (a start-up company) in May 2023, I started searching for a new job. I decided to make this job search “fun” by collecting some data and creating some cool plots!]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://kanishkegb.github.io/assets/images/posts/job-search/2023-job-search.png" /><media:content medium="image" url="https://kanishkegb.github.io/assets/images/posts/job-search/2023-job-search.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Simulink Matrix Concatenation: A Quick Reference</title><link href="https://kanishkegb.github.io/2023/06/24/simulink-vector-concatenate/" rel="alternate" type="text/html" title="Simulink Matrix Concatenation: A Quick Reference" /><published>2023-06-24T00:00:00+00:00</published><updated>2023-06-24T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2023/06/24/simulink-vector-concatenate</id><content type="html" xml:base="https://kanishkegb.github.io/2023/06/24/simulink-vector-concatenate/"><![CDATA[<p>Matrix concatenation in Simulink is performed by the <strong>Matrix Concatenate</strong> block (found under <em>Math Operations</em>). The block combines multiple input matrices along a specified dimension, governed by the <strong>Concatenation method</strong> parameter: <code class="language-plaintext highlighter-rouge">Horizontal</code> or <code class="language-plaintext highlighter-rouge">Vertical</code>.</p>

<hr />

<h2 id="horizontal-concatenation">Horizontal Concatenation</h2>

<p>Horizontal concatenation joins matrices <strong>side-by-side</strong>, appending columns. This corresponds to MATLAB’s <code class="language-plaintext highlighter-rouge">[A, B]</code> syntax.</p>

<p><strong>Dimension rule:</strong> all input matrices must share the same number of <strong>rows</strong>. The output column count is the sum of each input’s column count.</p>

\[[A \mid B] \quad \Longrightarrow \quad \text{rows}_A = \text{rows}_B, \quad \text{cols}_{\text{out}} = \text{cols}_A + \text{cols}_B\]

<h3 id="example">Example</h3>

<p>Given:</p>

\[A = \begin{bmatrix} 1 &amp; 2 \\ 3 &amp; 4 \end{bmatrix}_{2 \times 2}
\qquad
B = \begin{bmatrix} 5 \\ 6 \end{bmatrix}_{2 \times 1}\]

<p>Horizontal concatenation yields:</p>

\[[A \mid B] = \begin{bmatrix} 1 &amp; 2 &amp; 5 \\ 3 &amp; 4 &amp; 6 \end{bmatrix}_{2 \times 3}\]

<div class="concat-diagram">
  <div class="concat-matrix" style="--cols: 2; --rows: 2; --color: var(--accent);">
    <div class="concat-label">A &nbsp;<span class="concat-dim">2×2</span></div>
    <div class="concat-cells">
      <span>1</span><span>2</span>
      <span>3</span><span>4</span>
    </div>
  </div>
  <div class="concat-op">+</div>
  <div class="concat-matrix" style="--cols: 1; --rows: 2; --color: #f59e0b;">
    <div class="concat-label">B &nbsp;<span class="concat-dim">2×1</span></div>
    <div class="concat-cells">
      <span>5</span>
      <span>6</span>
    </div>
  </div>
  <div class="concat-op">→</div>
  <div class="concat-matrix" style="--cols: 3; --rows: 2; --color: #10b981;">
    <div class="concat-label">[A | B] &nbsp;<span class="concat-dim">2×3</span></div>
    <div class="concat-cells">
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">1</span>
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">2</span>
      <span style="background: color-mix(in srgb, #f59e0b 18%, transparent);">5</span>
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">3</span>
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">4</span>
      <span style="background: color-mix(in srgb, #f59e0b 18%, transparent);">6</span>
    </div>
  </div>
</div>

<p><strong>Simulink block setting:</strong> set <em>Concatenation method</em> to <code class="language-plaintext highlighter-rouge">Horizontal</code> and <em>Number of inputs</em> to <code class="language-plaintext highlighter-rouge">2</code>.</p>

<hr />

<h2 id="vertical-concatenation">Vertical Concatenation</h2>

<p>Vertical concatenation stacks matrices <strong>one above the other</strong>, appending rows. This corresponds to MATLAB’s <code class="language-plaintext highlighter-rouge">[A; B]</code> syntax.</p>

<p><strong>Dimension rule:</strong> all input matrices must share the same number of <strong>columns</strong>. The output row count is the sum of each input’s row count.</p>

\[\begin{bmatrix} A \\ B \end{bmatrix} \quad \Longrightarrow \quad \text{cols}_A = \text{cols}_B, \quad \text{rows}_{\text{out}} = \text{rows}_A + \text{rows}_B\]

<h3 id="example-1">Example</h3>

<p>Given:</p>

\[A = \begin{bmatrix} 1 &amp; 2 &amp; 3 \end{bmatrix}_{1 \times 3}
\qquad
B = \begin{bmatrix} 4 &amp; 5 &amp; 6 \end{bmatrix}_{1 \times 3}\]

<p>Vertical concatenation yields:</p>

\[\begin{bmatrix} A \\ B \end{bmatrix} = \begin{bmatrix} 1 &amp; 2 &amp; 3 \\ 4 &amp; 5 &amp; 6 \end{bmatrix}_{2 \times 3}\]

<div class="concat-diagram concat-diagram--vert">
  <div class="concat-matrix" style="--cols: 3; --rows: 1; --color: var(--accent);">
    <div class="concat-label">A &nbsp;<span class="concat-dim">1×3</span></div>
    <div class="concat-cells">
      <span>1</span><span>2</span><span>3</span>
    </div>
  </div>
  <div class="concat-op">+</div>
  <div class="concat-matrix" style="--cols: 3; --rows: 1; --color: #f59e0b;">
    <div class="concat-label">B &nbsp;<span class="concat-dim">1×3</span></div>
    <div class="concat-cells">
      <span>4</span><span>5</span><span>6</span>
    </div>
  </div>
  <div class="concat-op">→</div>
  <div class="concat-matrix" style="--cols: 3; --rows: 2; --color: #10b981;">
    <div class="concat-label">[A; B] &nbsp;<span class="concat-dim">2×3</span></div>
    <div class="concat-cells">
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">1</span>
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">2</span>
      <span style="background: color-mix(in srgb, var(--accent) 12%, transparent);">3</span>
      <span style="background: color-mix(in srgb, #f59e0b 18%, transparent);">4</span>
      <span style="background: color-mix(in srgb, #f59e0b 18%, transparent);">5</span>
      <span style="background: color-mix(in srgb, #f59e0b 18%, transparent);">6</span>
    </div>
  </div>
</div>

<p><strong>Simulink block setting:</strong> set <em>Concatenation method</em> to <code class="language-plaintext highlighter-rouge">Vertical</code> and <em>Number of inputs</em> to <code class="language-plaintext highlighter-rouge">2</code>.</p>

<hr />

<h2 id="summary">Summary</h2>

<table>
  <thead>
    <tr>
      <th>Property</th>
      <th>Horizontal <code class="language-plaintext highlighter-rouge">[A, B]</code></th>
      <th>Vertical <code class="language-plaintext highlighter-rouge">[A; B]</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Growth direction</td>
      <td>Along columns (→)</td>
      <td>Along rows (↓)</td>
    </tr>
    <tr>
      <td>Shared constraint</td>
      <td>Row count must match</td>
      <td>Column count must match</td>
    </tr>
    <tr>
      <td>Output rows</td>
      <td>Same as inputs</td>
      <td>Sum of input rows</td>
    </tr>
    <tr>
      <td>Output columns</td>
      <td>Sum of input columns</td>
      <td>Same as inputs</td>
    </tr>
    <tr>
      <td>MATLAB equivalent</td>
      <td><code class="language-plaintext highlighter-rouge">[A, B]</code></td>
      <td><code class="language-plaintext highlighter-rouge">[A; B]</code></td>
    </tr>
  </tbody>
</table>

<hr />

<h2 id="common-pitfall">Common Pitfall</h2>

<p>Simulink will throw a <strong>dimension mismatch error</strong> at simulation start if the constrained dimension is inconsistent across inputs. Check signal dimensions using <em>Simulation → Update Diagram</em> (<code class="language-plaintext highlighter-rouge">Ctrl+D</code>) before running to surface these errors early.</p>

<style>
.concat-diagram {
  display: flex;
  align-items: center;
  gap: 1rem;
  flex-wrap: wrap;
  margin: 1.5rem 0 1rem;
  padding: 1.25rem 1.5rem;
  background: var(--bg-alt);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.concat-diagram--vert {
  align-items: center;
}

.concat-matrix {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
}

.concat-label {
  font-size: 0.72rem;
  font-weight: 600;
  color: var(--text-muted);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

.concat-dim {
  font-weight: 400;
  color: var(--text-muted);
}

.concat-cells {
  display: grid;
  grid-template-columns: repeat(var(--cols), 2rem);
  grid-template-rows: repeat(var(--rows), 2rem);
  gap: 3px;
  border: 1.5px solid var(--color);
  border-radius: 4px;
  padding: 4px;
}

.concat-cells span {
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.8rem;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-weight: 500;
  color: var(--text);
  border-radius: 2px;
  background: transparent;
}

.concat-op {
  font-size: 1.2rem;
  font-weight: 300;
  color: var(--text-muted);
  flex-shrink: 0;
}
</style>]]></content><author><name>Kanishke Gamagedara</name></author><category term="simulink" /><category term="matlab" /><category term="signal-processing" /><category term="reference" /><summary type="html"><![CDATA[A concise reference for horizontal and vertical matrix concatenation in Simulink — covering block behaviour, dimension rules, and worked examples.]]></summary></entry><entry><title type="html">Adding an Interactive Plotly Plot to a Markdown Page</title><link href="https://kanishkegb.github.io/2023/03/12/plotly-with-markdown/" rel="alternate" type="text/html" title="Adding an Interactive Plotly Plot to a Markdown Page" /><published>2023-03-12T00:00:00+00:00</published><updated>2023-03-12T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2023/03/12/plotly-with-markdown</id><content type="html" xml:base="https://kanishkegb.github.io/2023/03/12/plotly-with-markdown/"><![CDATA[<p>If you are using Python for visualizing data, <a href="https://plotly.com/python/">plotly</a> is an awesome open-source library (also, plotly is not limited to Python). 
This let you plot interactive plots instead of the static plots Matplotlib generates by default.</p>

<p>First, we need to install plotly. 
Assuming you already have Python installed in your system, it as simple as:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code>python3 <span class="nt">-m</span> pip  <span class="nb">install </span>plotly
</code></pre></div></div>

<p>Then, we need to generate a plot. 
For that, I am going to use a default dataset that comes with plotly, and an example code.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kn">import</span> <span class="nn">plotly.express</span> <span class="k">as</span> <span class="n">px</span>

<span class="n">df</span> <span class="o">=</span> <span class="n">px</span><span class="p">.</span><span class="n">data</span><span class="p">.</span><span class="n">gapminder</span><span class="p">()</span>
<span class="n">fig</span> <span class="o">=</span> <span class="n">px</span><span class="p">.</span><span class="n">scatter</span><span class="p">(</span>
        <span class="n">df</span><span class="p">.</span><span class="n">query</span><span class="p">(</span><span class="s">"year==2007"</span><span class="p">),</span> 
        <span class="n">x</span><span class="o">=</span><span class="s">"gdpPercap"</span><span class="p">,</span> <span class="n">y</span><span class="o">=</span><span class="s">"lifeExp"</span><span class="p">,</span> 
        <span class="n">size</span><span class="o">=</span><span class="s">"pop"</span><span class="p">,</span> <span class="n">color</span><span class="o">=</span><span class="s">"continent"</span><span class="p">,</span>
        <span class="n">hover_name</span><span class="o">=</span><span class="s">"country"</span><span class="p">,</span> <span class="n">log_x</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">size_max</span><span class="o">=</span><span class="mi">60</span>
    <span class="p">)</span>
<span class="n">fig</span><span class="p">.</span><span class="n">show</span><span class="p">()</span>

<span class="n">fig</span><span class="p">.</span><span class="n">write_html</span><span class="p">(</span><span class="s">'plotly_example.html'</span><span class="p">,</span> <span class="n">full_html</span><span class="o">=</span><span class="bp">False</span><span class="p">,</span> <span class="n">include_plotlyjs</span><span class="o">=</span><span class="s">'cdn'</span><span class="p">)</span>
</code></pre></div></div>

<p>The last line (<code class="language-plaintext highlighter-rouge">write_html</code>) exports the image to an HTML file, and the additional arguments are for optimizing the file size of the HTML file. 
Without those, the <code class="language-plaintext highlighter-rouge">write_html</code> will create a self-contained HTML with the <code class="language-plaintext highlighter-rouge">plotly.js</code> source-code, which is useful for offline use, but not that much relevant for a website.</p>

<p>Once the HTML file is generated, copy/move that file to the directory where your website resides. 
Then, add the following line to your markdown page:</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">{</span> % include_relative relative/path/to/plotly_example.html % <span class="o">}</span> 
</code></pre></div></div>
<p><strong>NOTE</strong>: Make sure to delete the space between the curly braces and the percentage signs. I had to keep the space here to avoid markdown parsing the line.</p>

<p>Of course, replace <code class="language-plaintext highlighter-rouge">relative/path/to/</code> with the actual relative path.
For example, if both markdown and the HTML files are in the same directory, this will be,</p>
<div class="language-sh highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">{</span> % include_relative plotly_example.html % <span class="o">}</span> 
</code></pre></div></div>

<p>Now, you should be able to see the interactive image on your website, similar to the below plot.
You can interact with your mouse, zoom in, check data points, or save the image as a PNG file.</p>

<div>                        <script type="text/javascript">window.PlotlyConfig = {MathJaxConfig: 'local'};</script>
        <script charset="utf-8" src="https://cdn.plot.ly/plotly-2.20.0.min.js"></script>                <div id="35e042da-b7df-423c-8f5a-5ca01f15e355" class="plotly-graph-div" style="height:100%; width:100%;"></div>            <script type="text/javascript">                                    window.PLOTLYENV=window.PLOTLYENV || {};                                    if (document.getElementById("35e042da-b7df-423c-8f5a-5ca01f15e355")) {                    Plotly.newPlot(                        "35e042da-b7df-423c-8f5a-5ca01f15e355",                        [{"hovertemplate":"<b>%{hovertext}</b><br><br>continent=Asia<br>gdpPercap=%{x}<br>lifeExp=%{y}<br>pop=%{marker.size}<extra></extra>","hovertext":["Afghanistan","Bahrain","Bangladesh","Cambodia","China","Hong Kong, China","India","Indonesia","Iran","Iraq","Israel","Japan","Jordan","Korea, Dem. Rep.","Korea, Rep.","Kuwait","Lebanon","Malaysia","Mongolia","Myanmar","Nepal","Oman","Pakistan","Philippines","Saudi Arabia","Singapore","Sri Lanka","Syria","Taiwan","Thailand","Vietnam","West Bank and Gaza","Yemen, Rep."],"legendgroup":"Asia","marker":{"color":"#636efa","size":[31889923,708573,150448339,14131858,1318683096,6980412,1110396331,223547000,69453570,27499638,6426679,127467972,6053193,23301725,49044790,2505559,3921278,24821286,2874127,47761980,28901790,3204897,169270617,91077287,27601038,4553009,20378239,19314747,23174294,65068149,85262356,4018332,22211743],"sizemode":"area","sizeref":366300.86,"symbol":"circle"},"mode":"markers","name":"Asia","orientation":"v","showlegend":true,"x":[974.5803384,29796.04834,1391.253792,1713.778686,4959.114854,39724.97867,2452.210407,3540.651564,11605.71449,4471.061906,25523.2771,31656.06806,4519.461171,1593.06548,23348.139730000006,47306.98978,10461.05868,12451.6558,3095.7722710000007,944.0,1091.359778,22316.19287,2605.94758,3190.481016,21654.83194,47143.17964,3970.095407,4184.548089,28718.27684,7458.396326999998,2441.576404,3025.349798,2280.769906],"xaxis":"x","y":[43.828,75.635,64.062,59.723,72.961,82.208,64.69800000000001,70.65,70.964,59.545,80.745,82.603,72.535,67.297,78.623,77.58800000000002,71.993,74.241,66.803,62.069,63.785,75.64,65.483,71.688,72.777,79.972,72.396,74.143,78.4,70.616,74.249,73.422,62.698],"yaxis":"y","type":"scatter"},{"hovertemplate":"<b>%{hovertext}</b><br><br>continent=Europe<br>gdpPercap=%{x}<br>lifeExp=%{y}<br>pop=%{marker.size}<extra></extra>","hovertext":["Albania","Austria","Belgium","Bosnia and Herzegovina","Bulgaria","Croatia","Czech Republic","Denmark","Finland","France","Germany","Greece","Hungary","Iceland","Ireland","Italy","Montenegro","Netherlands","Norway","Poland","Portugal","Romania","Serbia","Slovak Republic","Slovenia","Spain","Sweden","Switzerland","Turkey","United Kingdom"],"legendgroup":"Europe","marker":{"color":"#EF553B","size":[3600523,8199783,10392226,4552198,7322858,4493312,10228744,5468120,5238460,61083916,82400996,10706290,9956108,301931,4109086,58147733,684736,16570613,4627926,38518241,10642836,22276056,10150265,5447502,2009245,40448191,9031088,7554661,71158647,60776238],"sizemode":"area","sizeref":366300.86,"symbol":"circle"},"mode":"markers","name":"Europe","orientation":"v","showlegend":true,"x":[5937.029525999998,36126.4927,33692.60508,7446.298803,10680.79282,14619.222719999998,22833.30851,35278.41874,33207.0844,30470.0167,32170.37442,27538.41188,18008.94444,36180.78919,40675.99635,28569.7197,9253.896111,36797.93332,49357.19017,15389.924680000002,20509.64777,10808.47561,9786.534714,18678.31435,25768.25759,28821.0637,33859.74835,37506.41907,8458.276384,33203.26128],"xaxis":"x","y":[76.423,79.829,79.441,74.852,73.005,75.748,76.486,78.332,79.313,80.657,79.406,79.483,73.33800000000002,81.757,78.885,80.546,74.543,79.762,80.196,75.563,78.098,72.476,74.002,74.663,77.926,80.941,80.884,81.70100000000002,71.777,79.425],"yaxis":"y","type":"scatter"},{"hovertemplate":"<b>%{hovertext}</b><br><br>continent=Africa<br>gdpPercap=%{x}<br>lifeExp=%{y}<br>pop=%{marker.size}<extra></extra>","hovertext":["Algeria","Angola","Benin","Botswana","Burkina Faso","Burundi","Cameroon","Central African Republic","Chad","Comoros","Congo, Dem. Rep.","Congo, Rep.","Cote d'Ivoire","Djibouti","Egypt","Equatorial Guinea","Eritrea","Ethiopia","Gabon","Gambia","Ghana","Guinea","Guinea-Bissau","Kenya","Lesotho","Liberia","Libya","Madagascar","Malawi","Mali","Mauritania","Mauritius","Morocco","Mozambique","Namibia","Niger","Nigeria","Reunion","Rwanda","Sao Tome and Principe","Senegal","Sierra Leone","Somalia","South Africa","Sudan","Swaziland","Tanzania","Togo","Tunisia","Uganda","Zambia","Zimbabwe"],"legendgroup":"Africa","marker":{"color":"#00cc96","size":[33333216,12420476,8078314,1639131,14326203,8390505,17696293,4369038,10238807,710960,64606759,3800610,18013409,496374,80264543,551201,4906585,76511887,1454867,1688359,22873338,9947814,1472041,35610177,2012649,3193942,6036914,19167654,13327079,12031795,3270065,1250882,33757175,19951656,2055080,12894865,135031164,798094,8860588,199579,12267493,6144562,9118773,43997828,42292929,1133066,38139640,5701579,10276158,29170398,11746035,12311143],"sizemode":"area","sizeref":366300.86,"symbol":"circle"},"mode":"markers","name":"Africa","orientation":"v","showlegend":true,"x":[6223.367465,4797.231267,1441.284873,12569.85177,1217.032994,430.0706916,2042.09524,706.016537,1704.063724,986.1478792,277.5518587,3632.557798,1544.750112,2082.4815670000007,5581.180998,12154.08975,641.3695236000002,690.8055759,13206.48452,752.7497265,1327.60891,942.6542111,579.2317429999998,1463.249282,1569.331442,414.5073415,12057.49928,1044.770126,759.3499101,1042.581557,1803.151496,10956.99112,3820.17523,823.6856205,4811.060429,619.6768923999998,2013.977305,7670.122558,863.0884639000002,1598.435089,1712.472136,862.5407561000002,926.1410683,9269.657808,2602.394995,4513.480643,1107.482182,882.9699437999999,7092.923025,1056.380121,1271.211593,469.70929810000007],"xaxis":"x","y":[72.301,42.731,56.728,50.728,52.295,49.58,50.43,44.74100000000001,50.651,65.152,46.462,55.322,48.328,54.791,71.33800000000002,51.57899999999999,58.04,52.947,56.735,59.448,60.022,56.007,46.38800000000001,54.11,42.592,45.678,73.952,59.44300000000001,48.303,54.467,64.164,72.801,71.164,42.082,52.90600000000001,56.867,46.859,76.442,46.242,65.528,63.062,42.56800000000001,48.159,49.339,58.556,39.613,52.517,58.42,73.923,51.542,42.38399999999999,43.487],"yaxis":"y","type":"scatter"},{"hovertemplate":"<b>%{hovertext}</b><br><br>continent=Americas<br>gdpPercap=%{x}<br>lifeExp=%{y}<br>pop=%{marker.size}<extra></extra>","hovertext":["Argentina","Bolivia","Brazil","Canada","Chile","Colombia","Costa Rica","Cuba","Dominican Republic","Ecuador","El Salvador","Guatemala","Haiti","Honduras","Jamaica","Mexico","Nicaragua","Panama","Paraguay","Peru","Puerto Rico","Trinidad and Tobago","United States","Uruguay","Venezuela"],"legendgroup":"Americas","marker":{"color":"#ab63fa","size":[40301927,9119152,190010647,33390141,16284741,44227550,4133884,11416987,9319622,13755680,6939688,12572928,8502814,7483763,2780132,108700891,5675356,3242173,6667147,28674757,3942491,1056608,301139947,3447496,26084662],"sizemode":"area","sizeref":366300.86,"symbol":"circle"},"mode":"markers","name":"Americas","orientation":"v","showlegend":true,"x":[12779.37964,3822.137084,9065.800825,36319.23501,13171.63885,7006.580419,9645.06142,8948.102923,6025.3747520000015,6873.262326000001,5728.353514,5186.050003,1201.637154,3548.3308460000007,7320.8802620000015,11977.57496,2749.320965,9809.185636,4172.838464,7408.905561,19328.70901,18008.50924,42951.65309,10611.46299,11415.80569],"xaxis":"x","y":[75.32,65.554,72.39,80.653,78.553,72.889,78.782,78.273,72.235,74.994,71.878,70.259,60.916,70.19800000000001,72.567,76.195,72.899,75.53699999999998,71.752,71.421,78.74600000000002,69.819,78.242,76.384,73.747],"yaxis":"y","type":"scatter"},{"hovertemplate":"<b>%{hovertext}</b><br><br>continent=Oceania<br>gdpPercap=%{x}<br>lifeExp=%{y}<br>pop=%{marker.size}<extra></extra>","hovertext":["Australia","New Zealand"],"legendgroup":"Oceania","marker":{"color":"#FFA15A","size":[20434176,4115771],"sizemode":"area","sizeref":366300.86,"symbol":"circle"},"mode":"markers","name":"Oceania","orientation":"v","showlegend":true,"x":[34435.367439999995,25185.00911],"xaxis":"x","y":[81.235,80.204],"yaxis":"y","type":"scatter"}],                        {"template":{"data":{"histogram2dcontour":[{"type":"histogram2dcontour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"choropleth":[{"type":"choropleth","colorbar":{"outlinewidth":0,"ticks":""}}],"histogram2d":[{"type":"histogram2d","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"heatmap":[{"type":"heatmap","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"heatmapgl":[{"type":"heatmapgl","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"contourcarpet":[{"type":"contourcarpet","colorbar":{"outlinewidth":0,"ticks":""}}],"contour":[{"type":"contour","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"surface":[{"type":"surface","colorbar":{"outlinewidth":0,"ticks":""},"colorscale":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]]}],"mesh3d":[{"type":"mesh3d","colorbar":{"outlinewidth":0,"ticks":""}}],"scatter":[{"fillpattern":{"fillmode":"overlay","size":10,"solidity":0.2},"type":"scatter"}],"parcoords":[{"type":"parcoords","line":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolargl":[{"type":"scatterpolargl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"bar":[{"error_x":{"color":"#2a3f5f"},"error_y":{"color":"#2a3f5f"},"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"bar"}],"scattergeo":[{"type":"scattergeo","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterpolar":[{"type":"scatterpolar","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"histogram":[{"marker":{"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"histogram"}],"scattergl":[{"type":"scattergl","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatter3d":[{"type":"scatter3d","line":{"colorbar":{"outlinewidth":0,"ticks":""}},"marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattermapbox":[{"type":"scattermapbox","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scatterternary":[{"type":"scatterternary","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"scattercarpet":[{"type":"scattercarpet","marker":{"colorbar":{"outlinewidth":0,"ticks":""}}}],"carpet":[{"aaxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"baxis":{"endlinecolor":"#2a3f5f","gridcolor":"white","linecolor":"white","minorgridcolor":"white","startlinecolor":"#2a3f5f"},"type":"carpet"}],"table":[{"cells":{"fill":{"color":"#EBF0F8"},"line":{"color":"white"}},"header":{"fill":{"color":"#C8D4E3"},"line":{"color":"white"}},"type":"table"}],"barpolar":[{"marker":{"line":{"color":"#E5ECF6","width":0.5},"pattern":{"fillmode":"overlay","size":10,"solidity":0.2}},"type":"barpolar"}],"pie":[{"automargin":true,"type":"pie"}]},"layout":{"autotypenumbers":"strict","colorway":["#636efa","#EF553B","#00cc96","#ab63fa","#FFA15A","#19d3f3","#FF6692","#B6E880","#FF97FF","#FECB52"],"font":{"color":"#2a3f5f"},"hovermode":"closest","hoverlabel":{"align":"left"},"paper_bgcolor":"white","plot_bgcolor":"#E5ECF6","polar":{"bgcolor":"#E5ECF6","angularaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"radialaxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"ternary":{"bgcolor":"#E5ECF6","aaxis":{"gridcolor":"white","linecolor":"white","ticks":""},"baxis":{"gridcolor":"white","linecolor":"white","ticks":""},"caxis":{"gridcolor":"white","linecolor":"white","ticks":""}},"coloraxis":{"colorbar":{"outlinewidth":0,"ticks":""}},"colorscale":{"sequential":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"sequentialminus":[[0.0,"#0d0887"],[0.1111111111111111,"#46039f"],[0.2222222222222222,"#7201a8"],[0.3333333333333333,"#9c179e"],[0.4444444444444444,"#bd3786"],[0.5555555555555556,"#d8576b"],[0.6666666666666666,"#ed7953"],[0.7777777777777778,"#fb9f3a"],[0.8888888888888888,"#fdca26"],[1.0,"#f0f921"]],"diverging":[[0,"#8e0152"],[0.1,"#c51b7d"],[0.2,"#de77ae"],[0.3,"#f1b6da"],[0.4,"#fde0ef"],[0.5,"#f7f7f7"],[0.6,"#e6f5d0"],[0.7,"#b8e186"],[0.8,"#7fbc41"],[0.9,"#4d9221"],[1,"#276419"]]},"xaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"yaxis":{"gridcolor":"white","linecolor":"white","ticks":"","title":{"standoff":15},"zerolinecolor":"white","automargin":true,"zerolinewidth":2},"scene":{"xaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"yaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2},"zaxis":{"backgroundcolor":"#E5ECF6","gridcolor":"white","linecolor":"white","showbackground":true,"ticks":"","zerolinecolor":"white","gridwidth":2}},"shapedefaults":{"line":{"color":"#2a3f5f"}},"annotationdefaults":{"arrowcolor":"#2a3f5f","arrowhead":0,"arrowwidth":1},"geo":{"bgcolor":"white","landcolor":"#E5ECF6","subunitcolor":"white","showland":true,"showlakes":true,"lakecolor":"white"},"title":{"x":0.05},"mapbox":{"style":"light"}}},"xaxis":{"anchor":"y","domain":[0.0,1.0],"title":{"text":"GDP per Capita"},"type":"log"},"yaxis":{"anchor":"x","domain":[0.0,1.0],"title":{"text":"Life Expectancy"}},"legend":{"title":{"text":"Continent"},"tracegroupgap":0,"itemsizing":"constant"},"margin":{"t":60}},                        {"responsive": true}                    )                };                            </script>        </div>]]></content><author><name>Kanishke Gamagedara</name></author><category term="[&quot;tutorials&quot;, &quot;plots&quot;, &quot;markdown&quot;, &quot;python&quot;, &quot;plotly&quot;]" /><summary type="html"><![CDATA[If you are using Python for visualizing data, plotly is an awesome open-source library (also, plotly is not limited to Python). This let you plot interactive plots instead of the static plots Matplotlib generates by default.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://kanishkegb.github.io/assets/images/posts/interactive-plots/life-expectancy.png" /><media:content medium="image" url="https://kanishkegb.github.io/assets/images/posts/interactive-plots/life-expectancy.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Numerical Instabilities in Kalman Filters: Causes, Consequences, and Mitigations</title><link href="https://kanishkegb.github.io/2022/06/01/kalman-numerical-instability/" rel="alternate" type="text/html" title="Numerical Instabilities in Kalman Filters: Causes, Consequences, and Mitigations" /><published>2022-06-01T00:00:00+00:00</published><updated>2022-06-01T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2022/06/01/kalman-numerical-instability</id><content type="html" xml:base="https://kanishkegb.github.io/2022/06/01/kalman-numerical-instability/"><![CDATA[<style>
  .kf-note {
    background: var(--bg-alt);
    border-left: 3px solid var(--accent);
    border-radius: 0 var(--radius) var(--radius) 0;
    padding: 0.85rem 1.25rem;
    margin: 1.75rem 0;
    font-size: 0.9rem;
    color: var(--text-muted);
  }

  .kf-note strong {
    color: var(--text);
    display: block;
    margin-bottom: 0.3rem;
    font-size: 0.78rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
  }

  .ref-list {
    counter-reset: ref-counter;
    list-style: none;
    padding-left: 0;
    margin: 0;
  }

  .ref-list li {
    counter-increment: ref-counter;
    display: grid;
    grid-template-columns: 2rem 1fr;
    gap: 0.5rem;
    font-size: 0.855rem;
    color: var(--text-muted);
    line-height: 1.65;
    padding: 0.6rem 0;
    border-bottom: 1px solid var(--border);
  }

  .ref-list li:last-child {
    border-bottom: none;
  }

  .ref-list li::before {
    content: "[" counter(ref-counter) "]";
    font-size: 0.78rem;
    color: var(--text-muted);
    padding-top: 0.05rem;
    font-variant-numeric: tabular-nums;
  }

  .ref-list li a {
    color: var(--accent);
    text-decoration: none;
  }

  .ref-list li a:hover {
    text-decoration: underline;
    opacity: 0.8;
  }

  .algo-block {
    background: var(--bg-alt);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: 1.1rem 1.4rem;
    margin: 1.5rem 0;
    font-size: 0.875rem;
    line-height: 1.85;
  }

  .algo-block .algo-title {
    font-size: 0.73rem;
    font-weight: 600;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-muted);
    margin-bottom: 0.75rem;
    padding-bottom: 0.6rem;
    border-bottom: 1px solid var(--border);
  }

  .algo-block .step {
    display: grid;
    grid-template-columns: 1.5rem 1fr;
    gap: 0.4rem;
  }

  .algo-block .step-num {
    color: var(--text-muted);
    font-size: 0.8rem;
    padding-top: 0.05rem;
  }
</style>

<p>The discrete-time Kalman filter [1] provides the minimum-variance unbiased linear estimator for systems with Gaussian process and measurement noise. Its recursive structure makes it computationally attractive for real-time applications, yet this same recursive nature accumulates floating-point errors that can, over extended operation, destroy the estimator’s theoretical properties. The failure modes range from subtle covariance asymmetry to catastrophic filter divergence, and the mitigation strategies span numerical linear algebra, information-theoretic reformulations, and adaptive covariance management.</p>

<h2 id="the-standard-formulation-and-its-arithmetic-vulnerabilities">The Standard Formulation and Its Arithmetic Vulnerabilities</h2>

<p>The conventional time-update and measurement-update equations are, respectively,</p>

\[\hat{x}_{k|k-1} = F_k \hat{x}_{k-1|k-1}\]

\[P_{k|k-1} = F_k P_{k-1|k-1} F_k^\top + Q_k\]

\[K_k = P_{k|k-1} H_k^\top \left( H_k P_{k|k-1} H_k^\top + R_k \right)^{-1}\]

\[\hat{x}_{k|k} = \hat{x}_{k|k-1} + K_k \left( z_k - H_k \hat{x}_{k|k-1} \right)\]

\[P_{k|k} = \left( I - K_k H_k \right) P_{k|k-1}\]

<p>The state covariance matrix \(P\) is the central object of concern. By construction it must remain symmetric positive definite (SPD) at every step; its eigenvalues represent the uncertainty ellipsoid of the estimate. Three arithmetic pathways undermine this requirement in finite-precision arithmetic.</p>

<table>
  <tbody>
    <tr>
      <td><strong>Catastrophic cancellation in the Joseph form.</strong> The gain computation involves subtracting two quantities of potentially similar magnitude — $$P_{k</td>
      <td>k-1}\(and\)K_k H_k P_{k</td>
      <td>k-1}$$ — leading to significant loss of significant bits when the Kalman gain is near unity. In double precision, one can lose 8–10 decimal digits of precision in a single update [2].</td>
    </tr>
  </tbody>
</table>

<table>
  <tbody>
    <tr>
      <td><strong>Asymmetry accumulation.</strong> The simplified covariance update $$(I - K_k H_k) P_{k</td>
      <td>k-1}\(is not symmetric in exact arithmetic due to representation error in\)K_k\(. Repeated application causes\)P\(to drift from symmetry. Once\)P$$ is asymmetric, the eigenstructure degrades and eventually an eigenvalue crosses zero, rendering the matrix indefinite.</td>
    </tr>
  </tbody>
</table>

<table>
  <tbody>
    <tr>
      <td><strong>Ill-conditioning of the innovation covariance.</strong> The matrix inversion $$(H_k P_{k</td>
      <td>k-1} H_k^\top + R_k)^{-1}\(is increasingly ill-conditioned when the state uncertainty is much larger or much smaller than the measurement noise. Condition numbers exceeding\)10^{12}$$ are common in inertial navigation over extended trajectories.</td>
    </tr>
  </tbody>
</table>

<h2 id="long-run-consequences">Long-Run Consequences</h2>

<h3 id="filter-divergence">Filter Divergence</h3>

<table>
  <tbody>
    <tr>
      <td>The most dramatic failure mode is divergence: the filter’s estimated covariance $$P_{k</td>
      <td>k}\(decreases (indicating growing confidence) while the true mean-squared error grows. This occurs because an indefinite\)P\(can produce Kalman gains with incorrect signs, causing the filter to steer the state estimate *away* from the truth. Divergence is notoriously difficult to detect from filter-internal quantities alone, since\)P$$ may appear plausible while being numerically corrupted.</td>
    </tr>
  </tbody>
</table>

<h3 id="covariance-collapse">Covariance Collapse</h3>

<p>In systems with poorly observable states, rounding errors can artificially suppress the diagonal elements of \(P\) corresponding to those states. The filter then assigns near-zero uncertainty to directions it has never actually observed, rejecting future measurements in those directions via near-zero Kalman gains. This is particularly pernicious in GPS-denied navigation where specific error modes are unobservable for extended intervals.</p>

<h3 id="rank-deficiency">Rank Deficiency</h3>

<p>Over hundreds of thousands of recursive steps — common in continuous INS/GNSS integration — \(P\) may lose rank entirely. A rank-deficient covariance matrix has a null space that the filter treats as perfectly known, permanently blocking correction in those directions even when valid measurements arrive.</p>

<div class="kf-note">
  <strong>Illustrative magnitude</strong>
  A 15-state INS error model integrated at 100 Hz for one hour accumulates 360,000 covariance updates. At each step, double-precision arithmetic introduces relative errors of order $$\epsilon_\text{mach} \approx 2.2 \times 10^{-16}$$. Over such a sequence, without mitigation, condition numbers routinely reach $$10^{10}$$–$$10^{14}$$, well into the regime where matrix factorizations become unreliable.
</div>

<h2 id="mitigation-techniques">Mitigation Techniques</h2>

<h3 id="1-joseph-stabilized-form">1. Joseph Stabilized Form</h3>

<p>The numerically robust symmetric form of the covariance update replaces the simple update with</p>

\[P_{k|k} = (I - K_k H_k)\, P_{k|k-1}\, (I - K_k H_k)^\top + K_k R_k K_k^\top\]

<p>This is algebraically equivalent to the standard form but guarantees symmetry by construction, since it is a sum of products of the form \(A M A^\top\). It doubles the number of multiplications but eliminates the asymmetry accumulation pathway [3]. It does not, however, resolve ill-conditioning.</p>

<h3 id="2-square-root-filtering-potter--carlson">2. Square-Root Filtering (Potter / Carlson)</h3>

<p>Rather than propagating \(P\) directly, square-root filters maintain its Cholesky factor \(S\) such that \(P = S S^\top\). The time and measurement updates are formulated in terms of orthogonal triangularization (QR decomposition or Givens rotations applied to \(S\)), ensuring that \(P\) remains SPD by the fundamental property that \(S S^\top \succeq 0\) whenever \(S\) exists.</p>

<p>The square-root time update uses the QR decomposition:</p>

\[\begin{bmatrix} S_{k|k-1} \end{bmatrix} = \text{qr}\!\left(\begin{bmatrix} S_{k-1|k-1} F_k^\top \\ S_Q \end{bmatrix}\right)\]

<p>where \(S_Q\) is the Cholesky factor of \(Q_k\). The effective condition number of \(S\) is the square root of that of \(P\), providing roughly 8 additional decimal digits of headroom in double precision [4]. The Carlson [5] and Bierman [6] UD-factorization variants operate on the unit lower-triangular times diagonal factorization \(P = U D U^\top\), which avoids square roots and is particularly efficient for sequential scalar measurement processing.</p>

<div class="algo-block">
  <div class="algo-title">Square-Root Measurement Update (Scalar Observation)</div>
  <div class="step"><span class="step-num">1.</span><span>Compute innovation variance: $$\alpha = h^\top P_{k|k-1} h + r$$</span></div>
  <div class="step"><span class="step-num">2.</span><span>Compute gain: $$K = P_{k|k-1} h \,/\, \alpha$$</span></div>
  <div class="step"><span class="step-num">3.</span><span>Rank-1 update of Cholesky factor via Givens or hyperbolic rotation: $$S_{k|k} = \text{cholupdate}(S_{k|k-1},\, K\sqrt{\alpha},\, \text{`−'})$$</span></div>
  <div class="step"><span class="step-num">4.</span><span>Update mean: $$\hat{x}_{k|k} = \hat{x}_{k|k-1} + K(z_k - h^\top \hat{x}_{k|k-1})$$</span></div>
</div>

<h3 id="3-information-filter-formulation">3. Information Filter Formulation</h3>

<p>The information filter propagates the Fisher information matrix \(\Omega = P^{-1}\) and the information vector \(\xi = P^{-1}\hat{x}\). The measurement update becomes purely additive:</p>

\[\Omega_{k|k} = \Omega_{k|k-1} + H_k^\top R_k^{-1} H_k\]

\[\xi_{k|k} = \xi_{k|k-1} + H_k^\top R_k^{-1} z_k\]

<table>
  <tbody>
    <tr>
      <td>This eliminates matrix inversion from the measurement step entirely. The time update is the less convenient side: it requires $$\Omega_{k</td>
      <td>k-1}^{-1}\(to form\)P_{k</td>
      <td>k-1}$$. The information form is therefore preferable in sensor fusion architectures with many simultaneous measurements (e.g., SLAM with dense feature maps) and in distributed estimation, where additive information fusion across nodes is structurally clean [7].</td>
    </tr>
  </tbody>
</table>

<h3 id="4-covariance-symmetrization">4. Covariance Symmetrization</h3>

<p>A pragmatic and computationally cheap intervention is to enforce symmetry explicitly after each update:</p>

\[P \leftarrow \tfrac{1}{2}(P + P^\top)\]

<p>This is \(O(n^2)\) and adds negligible overhead. While it does not address ill-conditioning, it prevents the asymmetry pathway from corrupting the eigenstructure and is universally recommended as a baseline hygiene measure [8]. Some implementations combine symmetrization with a periodic eigenvalue check: if the minimum eigenvalue of \(P\) falls below a threshold (e.g., \(10^{-12}\) times the trace), the matrix is projected back to the SPD cone via \(P \leftarrow P + \delta I\).</p>

<h3 id="5-sequential-measurement-processing">5. Sequential Measurement Processing</h3>

<p>When the measurement noise covariance \(R_k\) is diagonal, each scalar component of \(z_k\) can be processed independently. This reduces the simultaneous update to a sequence of rank-1 updates, each of which requires only a scalar division rather than a full matrix inversion. Sequential processing is thus both numerically safer (smaller, better-conditioned operations) and enables early termination when measurements fail consistency checks [6].</p>

<h3 id="6-adaptive-covariance-inflation">6. Adaptive Covariance Inflation</h3>

<p>Filter divergence driven by model mismatch — unmodeled dynamics, incorrect noise statistics — is not a purely numerical problem, but its symptoms are indistinguishable from arithmetic degradation from the filter’s perspective. Adaptive methods [9] estimate the innovation sequence statistics online:</p>

\[\hat{R}_k = \frac{1}{N}\sum_{i=k-N+1}^{k} \nu_i \nu_i^\top - H_k P_{k|k-1} H_k^\top\]

<table>
  <tbody>
    <tr>
      <td>where $$\nu_i = z_i - H_i \hat{x}_{i</td>
      <td>i-1}\(is the innovation. When\)\hat{R}_k\(is inconsistent with the filter's predicted innovation covariance,\)Q\(or\)R$$ are adjusted to restore normalized innovation squared (NIS) consistency. This prevents the covariance collapse that follows from overly optimistic noise models.</td>
    </tr>
  </tbody>
</table>

<h2 id="practical-recommendations">Practical Recommendations</h2>

<p>The choice of implementation should be driven by the system’s observability structure and the expected duration of operation:</p>

<p>For <strong>short-duration, well-conditioned</strong> systems (e.g., tightly coupled GPS/INS over minutes), the Joseph form with explicit symmetrization provides a good balance of simplicity and robustness.</p>

<p>For <strong>long-duration or poorly observable</strong> systems (e.g., submarine INS, deep-space navigation), square-root or UD-factored implementations are essentially mandatory. The Bierman-Thornton UD filter [6] remains the standard in precision navigation due to its numerical stability, computational efficiency, and amenability to sequential processing.</p>

<p>For <strong>distributed or high-measurement-rate</strong> architectures (e.g., multi-sensor SLAM), the information filter’s additive fusion structure offers structural advantages that outweigh its more expensive time update.</p>

<p>In all cases, the normalized innovation squared,</p>

\[\text{NIS}_k = \nu_k^\top S_k^{-1} \nu_k \sim \chi^2(m)\]

<p>where \(m\) is the measurement dimension, provides an online diagnostic. Sustained NIS values outside the \(95\%\) confidence interval \([\chi^2_{m,0.025},\, \chi^2_{m,0.975}]\) indicate either filter divergence or model mismatch and should trigger corrective action.</p>

<hr />

<h2 id="references">References</h2>

<ol class="ref-list">
  <li>R. E. Kalman, "A new approach to linear filtering and prediction problems," <em>Journal of Basic Engineering</em>, vol. 82, no. 1, pp. 35–45, 1960.</li>
  <li>G. J. Bierman, <em>Factorization Methods for Discrete Sequential Estimation</em>. Academic Press, 1977. (Dover reprint, 2006.)</li>
  <li>P. D. Joseph, as cited in A. H. Jazwinski, <em>Stochastic Processes and Filtering Theory</em>. Academic Press, 1970.</li>
  <li>A. Andrews, "A square root formulation of the Kalman covariance equations," <em>AIAA Journal</em>, vol. 6, no. 6, pp. 1165–1166, 1968.</li>
  <li>G. L. Carlson, "Fast triangular factorization of the square root filter," <em>AIAA Journal</em>, vol. 11, no. 9, pp. 1259–1265, 1973.</li>
  <li>G. J. Bierman and C. L. Thornton, "Numerical comparison of Kalman filter algorithms: Orbit determination case study," <em>Automatica</em>, vol. 13, no. 1, pp. 23–35, 1977.</li>
  <li>H. E. Durrant-Whyte and T. Bailey, "Simultaneous localization and mapping: Part I," <em>IEEE Robotics &amp; Automation Magazine</em>, vol. 13, no. 2, pp. 99–108, 2006.</li>
  <li>R. G. Brown and P. Y. C. Hwang, <em>Introduction to Random Signals and Applied Kalman Filtering</em>, 4th ed. Wiley, 2012.</li>
  <li>R. Mehra, "Approaches to adaptive filtering," <em>IEEE Transactions on Automatic Control</em>, vol. 17, no. 5, pp. 693–698, 1972.</li>
</ol>]]></content><author><name>Kanishke Gamagedara</name></author><category term="estimation" /><category term="kalman-filter" /><category term="numerical-methods" /><category term="signal-processing" /><summary type="html"><![CDATA[The Kalman filter, despite its optimality guarantees under Gaussian assumptions, is susceptible to numerical degradation in practice. This post examines the root causes of instability, their long-term consequences on state estimation, and the principal algorithmic remedies developed over five decades of research.]]></summary></entry><entry><title type="html">A Linear PID Controlled UAV with ROS + Gazebo</title><link href="https://kanishkegb.github.io/2022/04/15/gazbo-uav-pid-control/" rel="alternate" type="text/html" title="A Linear PID Controlled UAV with ROS + Gazebo" /><published>2022-04-15T00:00:00+00:00</published><updated>2022-04-15T00:00:00+00:00</updated><id>https://kanishkegb.github.io/2022/04/15/gazbo-uav-pid-control</id><content type="html" xml:base="https://kanishkegb.github.io/2022/04/15/gazbo-uav-pid-control/"><![CDATA[<p>The repository <a href="https://github.com/fdcl-gwu/gazebo_uav_control">fdcl-uav/gazebo_uav_control</a> is a basic example for using ROS and Gazebo to position control a quadrotor UAV, using C++. 
A simple PID controller (not the internal ROS controller) is used as the controller.
The objective here is for someone new to the UAV control with ROS + Gazebo to figure out where to start.</p>

<p>There are two main modules here:</p>
<ol>
  <li>uav_gazebo</li>
  <li>uav_control</li>
</ol>

<p>The <code class="language-plaintext highlighter-rouge">uav_gazebo</code> includes all the necessary file to create and launch a Gazebo environment with the UAV.
The <code class="language-plaintext highlighter-rouge">uav_control</code> includes the control plugin that will act as the PID controller, and the relevant custom messages.</p>

<p>The default behavior is as follows:</p>
<ol>
  <li>When the Gazebo environment is launched, the UAV will start at the origin.</li>
  <li>Desired position is defined inside the <code class="language-plaintext highlighter-rouge">control_plugin.cpp</code>.</li>
  <li>The UAV controller will wait for 2 seconds for the system to settle in.</li>
  <li>Then, the UAV forces and torques will be set using the PID controller such the UAV converges to the desired position.</li>
  <li>After the convergence, the UAV will stay there.</li>
</ol>

<p>The gains, desired position, and other physical parameters are hard-coded.
Again, the objective here is to familiar with the followings:</p>
<ol>
  <li>Reading states of the UAV in the Gazebo environment</li>
  <li>Defining a basic PID controller</li>
  <li>Transformation of desired forces and torques to the coordinate frame defined in the Gazebo environment</li>
</ol>

<p>If are already familiar with the basics and you want to have a more interactive and in-depth ROS + Gazebo UAV control, please check <a href="https://github.com/fdcl-gwu/uav_simulator">fdcl-gwu/uav_simulator</a>.</p>]]></content><author><name>Kanishke Gamagedara</name></author><category term="[&quot;research&quot;, &quot;uav&quot;, &quot;tutorial&quot;, &quot;projects&quot;]" /><summary type="html"><![CDATA[The repository fdcl-uav/gazebo_uav_control is a basic example for using ROS and Gazebo to position control a quadrotor UAV, using C++. A simple PID controller (not the internal ROS controller) is used as the controller. The objective here is for someone new to the UAV control with ROS + Gazebo to figure out where to start.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://kanishkegb.github.io/assets/images/posts/uav-simulations/gazebo-pid-control.png" /><media:content medium="image" url="https://kanishkegb.github.io/assets/images/posts/uav-simulations/gazebo-pid-control.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>