<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Mathematical Modeling | Shaoyang Cui</title><link>https://spidermonk7.github.io/tags/mathematical-modeling/</link><atom:link href="https://spidermonk7.github.io/tags/mathematical-modeling/index.xml" rel="self" type="application/rss+xml"/><description>Mathematical Modeling</description><generator>Hugo Blox Builder (https://hugoblox.com)</generator><language>en-us</language><lastBuildDate>Tue, 12 May 2026 00:00:00 +0000</lastBuildDate><image><url>https://spidermonk7.github.io/media/icon_hu7729264130191091259.png</url><title>Mathematical Modeling</title><link>https://spidermonk7.github.io/tags/mathematical-modeling/</link></image><item><title>Grid Cell Notes 01: Oscillatory Interference Along a Preferred Direction</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-lattice-intuition/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-lattice-intuition/</guid><description>&lt;p>This column is meant to be a running notebook. I do not want it to be limited to prose. For grid cells, intuition often becomes much clearer when formulas and visual structure are placed side by side, and even clearer when some parameters can be manipulated directly.&lt;/p>
&lt;p>This first note focuses on the simplest case of the oscillatory-interference picture: the animal moves along the preferred direction of one wave, that wave changes frequency relative to a background pacemaker, and the drifting phase difference generates a fixed firing spacing.&lt;/p>
&lt;h2 id="symbol-setup">Symbol setup&lt;/h2>
&lt;p>To keep the notation fixed, I will use:&lt;/p>
&lt;ul>
&lt;li>$\theta_i$: the preferred direction of wave $i$&lt;/li>
&lt;li>$\Phi_0$: the phase of the background pacemaker in the brain&lt;/li>
&lt;li>$\Phi_i$: the phase of wave $i$&lt;/li>
&lt;li>$\omega_0$: the baseline angular frequency of the pacemaker&lt;/li>
&lt;li>$\beta$: the gain that converts motion into frequency shift&lt;/li>
&lt;li>$v(t)$: the animal&amp;rsquo;s instantaneous speed&lt;/li>
&lt;/ul>
&lt;p>In this note, I only consider the case where the animal is moving exactly along the preferred direction of wave $i$. Under that assumption, the frequency of the wave changes according to&lt;/p>
$$
\omega_i(t)=\omega_0+\beta v(t).
$$&lt;p>So the directional dependence is absorbed into the assumption itself: I am already on the preferred axis of this wave.&lt;/p>
&lt;h2 id="why-focus-on-relative-phase">Why focus on relative phase&lt;/h2>
&lt;p>Before writing down formulas, the key intuition is this: in an oscillatory-interference view of grid cells, what matters is not an isolated wave by itself, but how one oscillation lines up against another.&lt;/p>
&lt;p>Here the background pacemaker provides one oscillatory reference, and the direction-tuned wave provides another. If their peaks keep overlapping, the interference is strong. If they drift apart, the interference weakens.&lt;/p>
&lt;p>So if we assume that a grid cell tends to fire when wave peaks align strongly enough, then the central object to track is not the absolute phase of either oscillation alone, but their &lt;strong>relative phase&lt;/strong>.&lt;/p>
&lt;p>That is why the quantity&lt;/p>
$$
\Phi_i - \Phi_0
$$&lt;p>becomes the natural variable. Once motion changes the frequency of the direction-tuned wave, this relative phase starts to drift. Every time the two waves come back into peak alignment, the interference becomes strong again, and that repeated re-alignment is what sets a spatial firing period.&lt;/p>
&lt;h2 id="phase-drift-relative-to-the-pacemaker">Phase drift relative to the pacemaker&lt;/h2>
&lt;p>The key quantity is not just the absolute phase of a wave, but the phase difference relative to the background pacemaker:&lt;/p>
$$
\Delta \Phi_i(t)=\Phi_i(t)-\Phi_0(t).
$$&lt;p>Its time derivative is&lt;/p>
$$
\frac{d}{dt}\Delta \Phi_i(t)=\omega_i(t)-\omega_0.
$$&lt;p>Therefore,&lt;/p>
$$
\frac{d}{dt}\Delta \Phi_i(t)=\beta\, v(t).
$$&lt;p>So your basic intuition is correct: once movement changes the frequency of wave $i$, the phase difference $\Phi_i-\Phi_0$ is no longer fixed and starts drifting over time.&lt;/p>
&lt;p>If the speed is constant, this becomes&lt;/p>
$$
\Delta \Phi_i(t)=\Delta \Phi_i(0)+\beta vt.
$$&lt;h2 id="re-alignment-first-think-with-constant-speed">Re-alignment: first think with constant speed&lt;/h2>
&lt;p>The next question is: when do the two waves meet again at their peaks?&lt;/p>
&lt;p>The clean way to say it is not “when $\Delta\Phi_i(t)=2\pi$”, because the initial relative phase may not be zero. What matters is that the &lt;strong>change&lt;/strong> in relative phase reaches another full cycle:&lt;/p>
$$
\Delta\Phi_i(t)-\Delta\Phi_i(0)=2\pi.
$$&lt;p>Under the constant-speed assumption,&lt;/p>
$$
\Delta \Phi_i(t)=\Delta \Phi_i(0)+\beta vt,
$$&lt;p>so the re-alignment condition becomes&lt;/p>
$$
\beta vt = 2\pi.
$$&lt;p>From this, the time between successive peak re-alignments is&lt;/p>
$$
T_i = \frac{2\pi}{\beta v}.
$$&lt;p>This is the first way to think about the problem:&lt;/p>
&lt;ol>
&lt;li>Assume $v$ is constant.&lt;/li>
&lt;li>Solve for the time it takes the relative phase to accumulate another $2\pi$.&lt;/li>
&lt;li>Convert that time interval into a traveled distance.&lt;/li>
&lt;/ol>
&lt;p>In one such period, the animal moves&lt;/p>
$$
d=vT_i=v\frac{2\pi}{\beta v}=\frac{2\pi}{\beta}.
$$&lt;p>So under constant speed, the firing spacing is already a constant determined only by $\beta$.&lt;/p>
&lt;h2 id="re-alignment-now-remove-the-constant-speed-assumption">Re-alignment: now remove the constant-speed assumption&lt;/h2>
&lt;p>The more interesting question is whether this result depends on $v$ being constant.&lt;/p>
&lt;p>It turns out that it does not.&lt;/p>
&lt;p>Instead of solving for a fixed period $T_i$, we can directly track accumulated phase drift:&lt;/p>
$$
\frac{d}{dt}\Delta\Phi_i(t)=\beta v(t).
$$&lt;p>Integrating from time $0$ to time $t$ gives&lt;/p>
$$
\Delta\Phi_i(t)-\Delta\Phi_i(0)=\int_0^t \beta v(\tau)\, d\tau.
$$&lt;p>If we ask for the next firing event, we impose the same re-alignment condition:&lt;/p>
$$
\Delta\Phi_i(t)-\Delta\Phi_i(0)=2\pi.
$$&lt;p>So we obtain&lt;/p>
$$
\int_0^t \beta v(\tau)\, d\tau = 2\pi.
$$&lt;p>Pulling $\beta$ out,&lt;/p>
$$
\beta \int_0^t v(\tau)\, d\tau = 2\pi.
$$&lt;p>But&lt;/p>
$$
\int_0^t v(\tau)\, d\tau
$$&lt;p>is exactly the distance traveled along this direction during that interval. Therefore the firing distance satisfies&lt;/p>
$$
d=\int_0^t v(\tau)\, d\tau=\frac{2\pi}{\beta}.
$$&lt;p>This is the more robust formulation:&lt;/p>
&lt;ul>
&lt;li>it does not require constant speed&lt;/li>
&lt;li>it only requires that movement stays along the preferred direction&lt;/li>
&lt;li>it shows directly that the relevant spatial spacing is fixed by $\beta$&lt;/li>
&lt;/ul>
&lt;p>So the realignment story can be understood in two equivalent ways:&lt;/p>
&lt;ol>
&lt;li>Constant-speed view: solve for the re-alignment period first, then multiply by speed.&lt;/li>
&lt;li>Integral view: accumulate phase drift until it reaches $2\pi$, and read off the distance directly.&lt;/li>
&lt;/ol>
&lt;p>The second view is the stronger one, because the conclusion survives even when $v(t)$ changes over time.&lt;/p>
&lt;h2 id="interpretation">Interpretation&lt;/h2>
&lt;p>The logic can now be summarized very compactly:&lt;/p>
&lt;ol>
&lt;li>The background pacemaker oscillates at $\omega_0$.&lt;/li>
&lt;li>Motion along the preferred direction changes the wave frequency to $\omega_0+\beta v$.&lt;/li>
&lt;li>That creates a phase drift rate of $\beta v(t)$ relative to the pacemaker.&lt;/li>
&lt;li>Each time the accumulated phase drift reaches another $2\pi$, the peaks re-align.&lt;/li>
&lt;li>The traveled distance associated with that re-alignment is
$$
d=\frac{2\pi}{\beta}.
$$&lt;/li>
&lt;/ol>
&lt;p>So one wave already gives a stripe-like periodic firing structure with a spacing fixed by $\beta$. The next conceptual step is then to ask how several direction-tuned waves, each with its own preferred direction, can combine to form a 2D grid-like firing pattern.&lt;/p>
&lt;h2 id="interactive-sketch">Interactive sketch&lt;/h2>
&lt;p>The demo for this note is intentionally restricted to the one-wave case. It keeps only one preferred direction and assumes the movement direction is parallel to it, matching the assumptions of the derivation above.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;p class="gridcell-demo-stage-note">
This demo keeps only one wave, and assumes the movement direction is parallel to that wave's preferred direction.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>beta&lt;/span>
&lt;input type="range" min="0.06" max="0.28" value="0.14" step="0.005" data-role="beta">
&lt;output data-role="beta-value">0.140&lt;/output>
&lt;/label>
&lt;label>
&lt;span>theta_1&lt;/span>
&lt;input type="range" min="0" max="180" value="0" step="1" data-role="theta1">
&lt;output data-role="theta1-value">0 deg&lt;/output>
&lt;/label>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Wave&lt;/h4>
&lt;canvas width="520" height="320" data-role="wave-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
A single oscillatory component along the preferred direction.
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Grid Cell Firing&lt;/h4>
&lt;canvas width="520" height="520" data-role="firing-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
With only one wave, the firing pattern is a stripe-like grating rather than a full 2D hexagonal grid.
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const controls = {
beta: root.querySelector('[data-role="beta"]'),
theta1: root.querySelector('[data-role="theta1"]')
};
const outputs = {
beta: root.querySelector('[data-role="beta-value"]'),
theta1: root.querySelector('[data-role="theta1-value"]')
};
const waveCanvas = root.querySelector('[data-role="wave-canvas"]');
const waveCtx = waveCanvas.getContext('2d');
const firingCanvas = root.querySelector('[data-role="firing-canvas"]');
const firingCtx = firingCanvas.getContext('2d');
const firingImage = firingCtx.createImageData(firingCanvas.width, firingCanvas.height);
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function degToRad(deg) {
return deg * Math.PI / 180;
}
function waveValue(x, y, beta, theta) {
const ux = Math.cos(theta);
const uy = Math.sin(theta);
return Math.cos(beta * (x * ux + y * uy));
}
function firingValue(raw) {
const normalized = clamp01((raw + 1) / 2);
return Math.pow(normalized, 1.9);
}
function colorizeFiring(value) {
const t = clamp01(value);
const r = Math.round(247 - 182 * t);
const g = Math.round(242 - 132 * t);
const b = Math.round(231 - 24 * t);
return [r, g, b];
}
function renderFiringMap(beta, theta) {
const width = firingImage.width;
const height = firingImage.height;
const data = firingImage.data;
const xCenter = width / 2;
const yCenter = height / 2;
let ptr = 0;
for (let py = 0; py &lt; height; py += 1) {
for (let px = 0; px &lt; width; px += 1) {
const x = px - xCenter;
const y = py - yCenter;
const raw = waveValue(x, y, beta, theta);
const value = firingValue(raw);
const [r, g, b] = colorizeFiring(value);
data[ptr] = r;
data[ptr + 1] = g;
data[ptr + 2] = b;
data[ptr + 3] = 255;
ptr += 4;
}
}
firingCtx.putImageData(firingImage, 0, 0);
firingCtx.save();
firingCtx.strokeStyle = 'rgba(15, 23, 42, 0.16)';
firingCtx.lineWidth = 1;
firingCtx.beginPath();
firingCtx.moveTo(width / 2, 0);
firingCtx.lineTo(width / 2, height);
firingCtx.moveTo(0, height / 2);
firingCtx.lineTo(width, height / 2);
firingCtx.stroke();
firingCtx.restore();
}
function drawWavePlot(beta, theta) {
const width = waveCanvas.width;
const height = waveCanvas.height;
const paddingX = 36;
const paddingY = 28;
const plotWidth = width - paddingX * 2;
const plotHeight = height - paddingY * 2;
const baselineY = paddingY + plotHeight / 2;
const xMin = -120;
const xMax = 120;
waveCtx.clearRect(0, 0, width, height);
waveCtx.fillStyle = '#f9f6f0';
waveCtx.fillRect(0, 0, width, height);
waveCtx.strokeStyle = 'rgba(19, 21, 24, 0.12)';
waveCtx.lineWidth = 1;
waveCtx.beginPath();
waveCtx.moveTo(paddingX, baselineY);
waveCtx.lineTo(width - paddingX, baselineY);
waveCtx.moveTo(width / 2, paddingY);
waveCtx.lineTo(width / 2, height - paddingY);
waveCtx.stroke();
waveCtx.fillStyle = 'rgba(19, 21, 24, 0.58)';
waveCtx.font = '12px Instrument Sans, Segoe UI, sans-serif';
waveCtx.fillText('x', width - paddingX + 8, baselineY + 4);
waveCtx.fillText('wave', width / 2 + 8, paddingY - 4);
function projectX(x) {
return paddingX + ((x - xMin) / (xMax - xMin)) * plotWidth;
}
function projectY(v) {
return baselineY - v * (plotHeight * 0.36);
}
waveCtx.save();
waveCtx.strokeStyle = '#153a63';
waveCtx.lineWidth = 3;
waveCtx.beginPath();
for (let i = 0; i &lt;= 240; i += 1) {
const x = xMin + (i / 240) * (xMax - xMin);
const y = waveValue(x, 0, beta, theta);
const px = projectX(x);
const py = projectY(y);
if (i === 0) {
waveCtx.moveTo(px, py);
} else {
waveCtx.lineTo(px, py);
}
}
waveCtx.stroke();
waveCtx.restore();
}
function render() {
const beta = Number(controls.beta.value);
const theta = degToRad(Number(controls.theta1.value));
outputs.beta.textContent = beta.toFixed(3);
outputs.theta1.textContent = `${controls.theta1.value} deg`;
drawWavePlot(beta, theta);
renderFiringMap(beta, theta);
}
Object.values(controls).forEach((input) => {
input.addEventListener('input', render);
});
render();
})();
&lt;/script>
&lt;p>Later, I want to add a more faithful demo: control a moving rat in a 2D scene, update several direction-tuned waves according to velocity-dependent phase drift, and then simulate the resulting grid-cell firing pattern over the trajectory.&lt;/p>
&lt;h2 id="why-this-is-only-a-starting-point">Why this is only a starting point&lt;/h2>
&lt;p>This picture is still incomplete, even in the aligned-direction case.&lt;/p>
&lt;p>Real grid-cell theory quickly brings in questions such as:&lt;/p>
&lt;ul>
&lt;li>How should the pacemaker itself be modeled?&lt;/li>
&lt;li>How exactly do several direction-tuned oscillators interact?&lt;/li>
&lt;li>Why are multiple modules needed?&lt;/li>
&lt;li>How does path integration update phase over time?&lt;/li>
&lt;li>What dynamical mechanism stabilizes the lattice?&lt;/li>
&lt;li>How does a population of grid cells support decoding?&lt;/li>
&lt;/ul>
&lt;p>Those are the questions I want this column to gradually move toward. For now, the point is narrower: make the one-direction phase-drift logic precise before moving into a full spatial simulation.&lt;/p></description></item><item><title>Grid Cell Notes 02: Multiple Waves and Velocity Projection</title><link>https://spidermonk7.github.io/grid-cell-notes/grid-cell-multiwave-projections/</link><pubDate>Tue, 12 May 2026 00:00:00 +0000</pubDate><guid>https://spidermonk7.github.io/grid-cell-notes/grid-cell-multiwave-projections/</guid><description>&lt;p>In the first note, I only kept the simplest aligned case: one wave, one preferred direction, and motion parallel to that direction. That already gives a clean stripe-like firing periodicity with spacing&lt;/p>
$$
d=\frac{2\pi}{\beta}.
$$&lt;p>Now I want to move one step closer to the full grid-cell picture by introducing multiple waves.&lt;/p>
&lt;h2 id="fixed-movement-direction-multiple-preferred-directions">Fixed movement direction, multiple preferred directions&lt;/h2>
&lt;p>I now assume:&lt;/p>
&lt;ul>
&lt;li>the animal moves at constant speed $v$&lt;/li>
&lt;li>the movement direction is fixed&lt;/li>
&lt;li>wave $i$ has preferred direction $\theta_i$&lt;/li>
&lt;li>$\Phi_0$ is still the background pacemaker phase&lt;/li>
&lt;/ul>
&lt;p>The projection of velocity onto wave $i$ is&lt;/p>
$$
v\cos\theta_i,
$$&lt;p>where $\theta_i$ is the angular difference between the fixed movement direction and the preferred direction of wave $i$.&lt;/p>
&lt;p>So the frequency of wave $i$ becomes&lt;/p>
$$
\omega_i=\omega_0+\beta v\cos\theta_i.
$$&lt;p>The relative phase drift satisfies&lt;/p>
$$
\frac{d}{dt}\Delta\Phi_i=\omega_i-\omega_0=\beta v\cos\theta_i.
$$&lt;p>Therefore, the re-alignment period of wave $i$ is&lt;/p>
$$
T_i=\frac{2\pi}{\beta v\cos\theta_i}.
$$&lt;p>If I care about the distance measured along wave $i$&amp;rsquo;s own preferred direction, then during one re-alignment period the effective displacement is the projected velocity multiplied by the period:&lt;/p>
$$
d_i=(v\cos\theta_i)T_i=\frac{2\pi}{\beta}.
$$&lt;p>So the key point is:&lt;/p>
&lt;ul>
&lt;li>the time between two re-alignments depends on $\cos\theta_i$&lt;/li>
&lt;li>but the distance measured along the preferred direction of that wave is still the same constant $2\pi/\beta$&lt;/li>
&lt;/ul>
&lt;p>The projection factor changes how quickly phase accumulates in time, not the preferred-direction spacing set by $\beta$.&lt;/p>
&lt;h2 id="one-wave">One wave&lt;/h2>
&lt;p>With only one preferred direction, the picture is still the stripe case from Note 01. The wave repeatedly re-aligns with the pacemaker after every projected distance&lt;/p>
$$
\frac{2\pi}{\beta},
$$&lt;p>so the firing pattern is a family of parallel bands orthogonal to that wave vector.&lt;/p>
&lt;h2 id="two-waves">Two waves&lt;/h2>
&lt;p>Now add a second preferred direction. Each wave has its own projected phase drift,&lt;/p>
$$
\frac{d}{dt}\Delta\Phi_1=\beta v\cos\theta_1,\qquad
\frac{d}{dt}\Delta\Phi_2=\beta v\cos\theta_2.
$$&lt;p>Each wave alone would define its own stripe family. When both are present, the firing pattern is strongest where the two stripe families cross. So the geometry is no longer a single banded pattern; it becomes a lattice of pairwise crossings.&lt;/p>
&lt;p>The main role of $\theta_1$ and $\theta_2$ is to control the angle between those two stripe families, and therefore the shape and spacing of the crossing structure.&lt;/p>
&lt;h2 id="three-waves">Three waves&lt;/h2>
&lt;p>Adding a third preferred direction gives a third stripe family. Now the most salient firing locations are the points where all three families come into alignment at once.&lt;/p>
&lt;p>This is the step that moves the picture from simple stripe crossings toward the familiar grid-like arrangement. In the symmetric case, when the preferred directions are separated in a balanced way, the triple intersections become distributed across space in a regular pattern.&lt;/p>
&lt;p>So in this note the logic is:&lt;/p>
&lt;ol>
&lt;li>each wave contributes a stripe family&lt;/li>
&lt;li>each stripe family is controlled by its projected phase drift&lt;/li>
&lt;li>pairwise crossings already create localized structure&lt;/li>
&lt;li>three-wave crossings push that structure toward a grid-cell-like firing pattern&lt;/li>
&lt;/ol>
&lt;h2 id="a-geometric-superposition-picture">A geometric superposition picture&lt;/h2>
&lt;p>A simple geometric way to write the multi-wave field is&lt;/p>
$$
w_i(x)=\cos(k_i^\top x+\phi_i),
$$&lt;p>where the orientation of $k_i$ is tied to $\theta_i$.&lt;/p>
&lt;p>Then the combined field is&lt;/p>
$$
s(x)=\frac{1}{N}\sum_{i=1}^{N} w_i(x),
$$&lt;p>and the firing map is produced through a readout&lt;/p>
$$
g(x)=f(s(x)).
$$&lt;p>This is still a simplified spatial picture, but it is a useful bridge between the oscillatory phase-drift intuition and the 2D firing pattern we want to understand.&lt;/p>
&lt;h2 id="interactive-sketch">Interactive sketch&lt;/h2>
&lt;p>The demo below keeps the model at this geometric-superposition level. You can introduce one wave, then two, then three, while adjusting the key parameters:&lt;/p>
&lt;ul>
&lt;li>$\beta$&lt;/li>
&lt;li>$\theta_1$&lt;/li>
&lt;li>$\theta_2$&lt;/li>
&lt;li>$\theta_3$&lt;/li>
&lt;/ul>
&lt;p>The left panel shows the active waves and their superposition in a 1D slice. The right panel shows each wave&amp;rsquo;s stripe family in its own color, together with highlighted intersections.&lt;/p>
&lt;div class="gridcell-demo">
&lt;div class="gridcell-demo-intro">
&lt;div class="gridcell-demo-tabs" role="tablist" aria-label="Wave introduction stages">
&lt;button type="button" class="gridcell-demo-tab is-active" data-role="stage" data-stage="1">Introduce wave 1&lt;/button>
&lt;button type="button" class="gridcell-demo-tab" data-role="stage" data-stage="2">Introduce wave 2&lt;/button>
&lt;button type="button" class="gridcell-demo-tab" data-role="stage" data-stage="3">Introduce wave 3&lt;/button>
&lt;/div>
&lt;p class="gridcell-demo-stage-note" data-role="stage-note">
With only wave 1 active, the model produces a stripe-like periodic structure set by beta and theta_1.
&lt;/p>
&lt;/div>
&lt;div class="gridcell-demo-controls">
&lt;label>
&lt;span>beta&lt;/span>
&lt;input type="range" min="0.06" max="0.28" value="0.14" step="0.005" data-role="beta">
&lt;output data-role="beta-value">0.140&lt;/output>
&lt;/label>
&lt;label>
&lt;span>wave grids&lt;/span>
&lt;button type="button" class="gridcell-demo-tab" data-role="grid-toggle">Fade grids&lt;/button>
&lt;/label>
&lt;/div>
&lt;div class="gridcell-demo-wave-grid">
&lt;section class="gridcell-demo-wave-card" data-wave-card="1">
&lt;h4>Wave 1&lt;/h4>
&lt;label>
&lt;span>theta_1&lt;/span>
&lt;input type="range" min="0" max="180" value="0" step="1" data-role="theta1">
&lt;output data-role="theta1-value">0 deg&lt;/output>
&lt;/label>
&lt;/section>
&lt;section class="gridcell-demo-wave-card is-muted" data-wave-card="2">
&lt;h4>Wave 2&lt;/h4>
&lt;label>
&lt;span>theta_2&lt;/span>
&lt;input type="range" min="0" max="180" value="60" step="1" data-role="theta2">
&lt;output data-role="theta2-value">60 deg&lt;/output>
&lt;/label>
&lt;/section>
&lt;section class="gridcell-demo-wave-card is-muted" data-wave-card="3">
&lt;h4>Wave 3&lt;/h4>
&lt;label>
&lt;span>theta_3&lt;/span>
&lt;input type="range" min="0" max="180" value="120" step="1" data-role="theta3">
&lt;output data-role="theta3-value">120 deg&lt;/output>
&lt;/label>
&lt;/section>
&lt;/div>
&lt;div class="gridcell-demo-stage gridcell-demo-stage-split">
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Wave&lt;/h4>
&lt;canvas width="520" height="320" data-role="wave-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
A 1D spatial slice showing the active waves and their superposition under the current beta and theta_i values.
&lt;/div>
&lt;/div>
&lt;div class="gridcell-demo-visual-block">
&lt;h4>Grid Cell Firing&lt;/h4>
&lt;canvas width="520" height="520" data-role="firing-canvas">&lt;/canvas>
&lt;div class="gridcell-demo-caption">
Each active wave contributes one colored stripe family. Bright highlights mark where the currently introduced stripe families intersect.
&lt;/div>
&lt;/div>
&lt;/div>
&lt;/div>
&lt;script>
(() => {
const root = document.currentScript.previousElementSibling;
if (!root || !root.classList.contains('gridcell-demo')) return;
const stageButtons = Array.from(root.querySelectorAll('[data-role="stage"]'));
const stageNote = root.querySelector('[data-role="stage-note"]');
const waveCards = [1, 2, 3].map((index) => root.querySelector(`[data-wave-card="${index}"]`));
const controls = {
beta: root.querySelector('[data-role="beta"]'),
theta1: root.querySelector('[data-role="theta1"]'),
theta2: root.querySelector('[data-role="theta2"]'),
theta3: root.querySelector('[data-role="theta3"]')
};
const gridToggle = root.querySelector('[data-role="grid-toggle"]');
const outputs = {
beta: root.querySelector('[data-role="beta-value"]'),
theta1: root.querySelector('[data-role="theta1-value"]'),
theta2: root.querySelector('[data-role="theta2-value"]'),
theta3: root.querySelector('[data-role="theta3-value"]')
};
const stageNotes = {
1: 'With only wave 1 active, the model produces a stripe-like periodic structure set by beta and theta_1.',
2: 'After adding wave 2, the interference geometry depends on beta together with the angle between theta_1 and theta_2.',
3: 'After adding wave 3, the three preferred directions theta_1, theta_2, and theta_3 can support a grid-like firing layout.'
};
const waveCanvas = root.querySelector('[data-role="wave-canvas"]');
const waveCtx = waveCanvas.getContext('2d');
const firingCanvas = root.querySelector('[data-role="firing-canvas"]');
const firingCtx = firingCanvas.getContext('2d');
const firingImage = firingCtx.createImageData(firingCanvas.width, firingCanvas.height);
let activeStage = 1;
let fadeWaveGrids = false;
const stripeColors = [
[21, 58, 99],
[176, 106, 43],
[47, 106, 79]
];
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function degToRad(deg) {
return deg * Math.PI / 180;
}
function readWaves() {
return [1, 2, 3].map((index) => ({
angle: degToRad(Number(controls[`theta${index}`].value))
}));
}
function waveValue(x, y, beta, wave) {
const ux = Math.cos(wave.angle);
const uy = Math.sin(wave.angle);
return Math.cos(beta * (x * ux + y * uy));
}
function interferenceValue(x, y, beta, waves, count) {
let sum = 0;
for (let i = 0; i &lt; count; i += 1) {
sum += waveValue(x, y, beta, waves[i]);
}
return sum / count;
}
function stripeStrength(raw) {
const threshold = 0.92;
return Math.pow(clamp01((raw - threshold) / (1 - threshold)), 0.8);
}
function renderFiringMap(beta, waves, count) {
const width = firingImage.width;
const height = firingImage.height;
const data = firingImage.data;
const xCenter = width / 2;
const yCenter = height / 2;
let ptr = 0;
for (let py = 0; py &lt; height; py += 1) {
for (let px = 0; px &lt; width; px += 1) {
const x = px - xCenter;
const y = py - yCenter;
let r = 246;
let g = 241;
let b = 232;
const strengths = [];
for (let i = 0; i &lt; count; i += 1) {
const strength = stripeStrength(waveValue(x, y, beta, waves[i]));
strengths.push(strength);
if (strength > 0) {
const stripeOpacity = fadeWaveGrids ? 0.22 : 0.68;
r -= stripeColors[i][0] * strength * stripeOpacity;
g -= stripeColors[i][1] * strength * stripeOpacity;
b -= stripeColors[i][2] * strength * stripeOpacity;
}
}
let intersection = 0;
if (count === 2) {
intersection = Math.min(strengths[0] || 0, strengths[1] || 0);
} else if (count >= 3) {
intersection = Math.min(strengths[0] || 0, strengths[1] || 0, strengths[2] || 0);
}
if (intersection > 0) {
const boost = Math.pow(intersection, 0.8);
r = r * (1 - 0.88 * boost) + 255 * 0.88 * boost;
g = g * (1 - 0.78 * boost) + 246 * 0.78 * boost;
b = b * (1 - 0.35 * boost) + 196 * 0.35 * boost;
}
if (count >= 3 &amp;&amp; intersection > 0.62) {
const marker = Math.pow((intersection - 0.62) / 0.38, 1.05);
r = r * (1 - marker) + 255 * marker;
g = g * (1 - marker) + 236 * marker;
b = b * (1 - marker) + 120 * marker;
}
data[ptr] = Math.round(clamp01(r / 255) * 255);
data[ptr + 1] = Math.round(clamp01(g / 255) * 255);
data[ptr + 2] = Math.round(clamp01(b / 255) * 255);
data[ptr + 3] = 255;
ptr += 4;
}
}
firingCtx.putImageData(firingImage, 0, 0);
firingCtx.save();
firingCtx.strokeStyle = 'rgba(15, 23, 42, 0.07)';
firingCtx.lineWidth = 1;
firingCtx.beginPath();
firingCtx.moveTo(width / 2, 0);
firingCtx.lineTo(width / 2, height);
firingCtx.moveTo(0, height / 2);
firingCtx.lineTo(width, height / 2);
firingCtx.stroke();
firingCtx.strokeStyle = 'rgba(15, 23, 42, 0.04)';
firingCtx.beginPath();
for (let step = 1; step &lt; 4; step += 1) {
const x = (width / 4) * step;
const y = (height / 4) * step;
firingCtx.moveTo(x, 0);
firingCtx.lineTo(x, height);
firingCtx.moveTo(0, y);
firingCtx.lineTo(width, y);
}
firingCtx.stroke();
firingCtx.restore();
}
function drawWavePlot(beta, waves, count) {
const ctx = waveCtx;
const canvas = waveCanvas;
const width = canvas.width;
const height = canvas.height;
const paddingX = 36;
const paddingY = 28;
const plotWidth = width - paddingX * 2;
const plotHeight = height - paddingY * 2;
const baselineY = paddingY + plotHeight / 2;
const xMin = -120;
const xMax = 120;
const waveColors = ['#153a63', '#b06a2b', '#2f6a4f'];
const sumColor = '#8f1d1d';
ctx.clearRect(0, 0, width, height);
ctx.fillStyle = '#f9f6f0';
ctx.fillRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(19, 21, 24, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(paddingX, baselineY);
ctx.lineTo(width - paddingX, baselineY);
ctx.moveTo(width / 2, paddingY);
ctx.lineTo(width / 2, height - paddingY);
ctx.stroke();
ctx.fillStyle = 'rgba(19, 21, 24, 0.58)';
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
ctx.fillText('x', width - paddingX + 8, baselineY + 4);
ctx.fillText('wave', width / 2 + 8, paddingY - 4);
function projectX(x) {
return paddingX + ((x - xMin) / (xMax - xMin)) * plotWidth;
}
function projectY(v) {
return baselineY - v * (plotHeight * 0.36);
}
function drawCurve(sampleFn, color, lineWidth, alpha) {
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = lineWidth;
ctx.globalAlpha = alpha;
ctx.beginPath();
for (let i = 0; i &lt;= 240; i += 1) {
const x = xMin + (i / 240) * (xMax - xMin);
const y = sampleFn(x);
const px = projectX(x);
const py = projectY(y);
if (i === 0) {
ctx.moveTo(px, py);
} else {
ctx.lineTo(px, py);
}
}
ctx.stroke();
ctx.restore();
}
for (let i = 0; i &lt; count; i += 1) {
drawCurve((x) => waveValue(x, 0, beta, waves[i]), waveColors[i], 2, 0.95);
}
drawCurve((x) => interferenceValue(x, 0, beta, waves, count), sumColor, 3, 1);
const legend = [
...Array.from({ length: count }, (_, index) => ({ label: `wave ${index + 1}`, color: waveColors[index] })),
{ label: 'sum', color: sumColor }
];
let legendX = paddingX;
const legendY = height - 10;
ctx.font = '12px Instrument Sans, Segoe UI, sans-serif';
legend.forEach((item) => {
ctx.fillStyle = item.color;
ctx.fillRect(legendX, legendY - 10, 14, 3);
ctx.fillStyle = 'rgba(19, 21, 24, 0.68)';
ctx.fillText(item.label, legendX + 20, legendY - 6);
legendX += 68;
});
}
function refreshLabels() {
outputs.beta.textContent = Number(controls.beta.value).toFixed(3);
[1, 2, 3].forEach((index) => {
outputs[`theta${index}`].textContent = `${controls[`theta${index}`].value} deg`;
});
}
function refreshStageUI() {
stageButtons.forEach((button) => {
const isActive = Number(button.dataset.stage) === activeStage;
button.classList.toggle('is-active', isActive);
});
waveCards.forEach((card, index) => {
const enabled = index + 1 &lt;= activeStage;
card.classList.toggle('is-muted', !enabled);
Array.from(card.querySelectorAll('input')).forEach((input) => {
input.disabled = !enabled;
});
});
stageNote.textContent = stageNotes[activeStage];
gridToggle.classList.toggle('is-active', fadeWaveGrids);
gridToggle.textContent = fadeWaveGrids ? 'Show grids' : 'Fade grids';
}
function render() {
refreshLabels();
refreshStageUI();
const beta = Number(controls.beta.value);
const waves = readWaves();
drawWavePlot(beta, waves, activeStage);
renderFiringMap(beta, waves, activeStage);
}
stageButtons.forEach((button) => {
button.addEventListener('click', () => {
activeStage = Number(button.dataset.stage);
render();
});
});
gridToggle.addEventListener('click', () => {
fadeWaveGrids = !fadeWaveGrids;
render();
});
Object.values(controls).forEach((input) => {
input.addEventListener('input', render);
});
render();
})();
&lt;/script>
&lt;h2 id="what-this-note-adds">What this note adds&lt;/h2>
&lt;p>Compared with Note 01, the conceptual change is small but important:&lt;/p>
&lt;ol>
&lt;li>Motion is no longer assumed to be aligned with every wave.&lt;/li>
&lt;li>Each wave sees only the projected component $v\cos\theta_i$.&lt;/li>
&lt;li>Different waves therefore drift against the pacemaker at different rates in time.&lt;/li>
&lt;li>Each wave still carries the same preferred-direction spacing $2\pi/\beta$.&lt;/li>
&lt;li>Their combined interference can move from stripes to crossings and eventually to grid-like firing.&lt;/li>
&lt;/ol>
&lt;p>The next step after this note is to stop treating the pattern as static geometry and instead directly simulate phase accumulation while a rat moves through space.&lt;/p></description></item></channel></rss>