All checks were successful
Build & Deploy Shannon / 🏗️ Build & Deploy Shannon (push) Successful in 3m1s
- Implemented bandwidth sensitivity and power sensitivity plots. - Created a contour map for bit rate multiplying factors. - Added input parameters for C/N and bandwidth with validation. - Displayed computed results and sensitivity analysis metrics. - Integrated interactive graphs for user exploration. - Included background information section for user guidance.
763 lines
25 KiB
Python
763 lines
25 KiB
Python
"""
|
||
Interactive Satellite Link Animation
|
||
=====================================
|
||
HTML5 Canvas animation showing a satellite communicating with a ground station,
|
||
with toggleable impairment layers (atmosphere, rain, free-space loss, noise…).
|
||
"""
|
||
|
||
import streamlit as st
|
||
import streamlit.components.v1 as components
|
||
|
||
|
||
_ANIMATION_HTML = """
|
||
<!DOCTYPE html>
|
||
<html>
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<style>
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body { background: transparent; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||
canvas { display: block; }
|
||
|
||
/* ── Control Panel ── */
|
||
#controls {
|
||
position: absolute; top: 14px; right: 18px;
|
||
background: rgba(13,27,42,0.92); border: 1px solid rgba(79,195,247,0.3);
|
||
border-radius: 14px; padding: 16px 18px; min-width: 220px;
|
||
backdrop-filter: blur(12px); box-shadow: 0 8px 32px rgba(0,0,0,0.45);
|
||
}
|
||
#controls h3 {
|
||
color: #4FC3F7; font-size: 13px; text-transform: uppercase;
|
||
letter-spacing: 1.5px; margin-bottom: 12px; text-align: center;
|
||
}
|
||
.toggle-row {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
margin: 7px 0; cursor: pointer; padding: 4px 6px; border-radius: 8px;
|
||
transition: background 0.2s;
|
||
}
|
||
.toggle-row:hover { background: rgba(79,195,247,0.08); }
|
||
.toggle-row label { color: #cbd5e1; font-size: 12.5px; cursor: pointer; flex: 1; }
|
||
.toggle-row .dot {
|
||
width: 10px; height: 10px; border-radius: 50%; margin-right: 10px; flex-shrink: 0;
|
||
}
|
||
|
||
/* Toggle switch */
|
||
.switch { position: relative; width: 36px; height: 20px; flex-shrink: 0; }
|
||
.switch input { opacity: 0; width: 0; height: 0; }
|
||
.slider {
|
||
position: absolute; cursor: pointer; inset: 0;
|
||
background: #334155; border-radius: 20px; transition: 0.3s;
|
||
}
|
||
.slider::before {
|
||
content: ""; position: absolute; height: 14px; width: 14px;
|
||
left: 3px; bottom: 3px; background: #94a3b8;
|
||
border-radius: 50%; transition: 0.3s;
|
||
}
|
||
.switch input:checked + .slider { background: #0f3460; }
|
||
.switch input:checked + .slider::before { transform: translateX(16px); background: #4FC3F7; }
|
||
|
||
/* Legend box */
|
||
#legend {
|
||
position: absolute; bottom: 14px; left: 18px;
|
||
background: rgba(13,27,42,0.88); border: 1px solid rgba(79,195,247,0.2);
|
||
border-radius: 12px; padding: 14px 16px; max-width: 340px;
|
||
backdrop-filter: blur(10px); color: #e2e8f0; font-size: 12px; line-height: 1.6;
|
||
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
|
||
transition: opacity 0.4s;
|
||
}
|
||
#legend h4 { color: #4FC3F7; margin-bottom: 6px; font-size: 13px; }
|
||
#legend .info-text { color: #94a3b8; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<canvas id="c"></canvas>
|
||
|
||
<!-- Control toggles -->
|
||
<div id="controls">
|
||
<h3>⚡ Impairments</h3>
|
||
|
||
<div class="toggle-row" onclick="this.querySelector('input').click()">
|
||
<span class="dot" style="background:#ff6b6b"></span>
|
||
<label>Free-Space Loss</label>
|
||
<label class="switch"><input type="checkbox" id="tFSL" checked><span class="slider"></span></label>
|
||
</div>
|
||
|
||
<div class="toggle-row" onclick="this.querySelector('input').click()">
|
||
<span class="dot" style="background:#ffd93d"></span>
|
||
<label>Atmospheric Attn.</label>
|
||
<label class="switch"><input type="checkbox" id="tAtm" checked><span class="slider"></span></label>
|
||
</div>
|
||
|
||
<div class="toggle-row" onclick="this.querySelector('input').click()">
|
||
<span class="dot" style="background:#6bcbff"></span>
|
||
<label>Rain Attenuation</label>
|
||
<label class="switch"><input type="checkbox" id="tRain" checked><span class="slider"></span></label>
|
||
</div>
|
||
|
||
<div class="toggle-row" onclick="this.querySelector('input').click()">
|
||
<span class="dot" style="background:#c084fc"></span>
|
||
<label>Ionospheric Effects</label>
|
||
<label class="switch"><input type="checkbox" id="tIono" checked><span class="slider"></span></label>
|
||
</div>
|
||
|
||
<div class="toggle-row" onclick="this.querySelector('input').click()">
|
||
<span class="dot" style="background:#fb923c"></span>
|
||
<label>Thermal Noise</label>
|
||
<label class="switch"><input type="checkbox" id="tNoise" checked><span class="slider"></span></label>
|
||
</div>
|
||
|
||
<div class="toggle-row" onclick="this.querySelector('input').click()">
|
||
<span class="dot" style="background:#34d399"></span>
|
||
<label>Pointing Loss</label>
|
||
<label class="switch"><input type="checkbox" id="tPoint" checked><span class="slider"></span></label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Info legend -->
|
||
<div id="legend">
|
||
<h4 id="legendTitle">📡 Satellite Link Overview</h4>
|
||
<span id="legendText" class="info-text">
|
||
Toggle each impairment to see how it affects the signal.
|
||
<b style="color:#4FC3F7">Cyan</b> = Downlink (sat → ground).
|
||
<b style="color:#34d399">Green</b> = Uplink (ground → sat).
|
||
The signal strength bar shows the cumulative effect.
|
||
</span>
|
||
</div>
|
||
|
||
<script>
|
||
// ── Canvas setup ──
|
||
const canvas = document.getElementById('c');
|
||
const ctx = canvas.getContext('2d');
|
||
let W, H;
|
||
function resize() {
|
||
W = canvas.width = window.innerWidth;
|
||
H = canvas.height = window.innerHeight;
|
||
}
|
||
window.addEventListener('resize', resize);
|
||
resize();
|
||
|
||
// ── Toggle states ──
|
||
const ids = ['tFSL','tAtm','tRain','tIono','tNoise','tPoint'];
|
||
function isOn(id) { return document.getElementById(id).checked; }
|
||
|
||
// Legend descriptions
|
||
const legendData = {
|
||
tFSL: { title: '📉 Free-Space Path Loss (FSPL)',
|
||
text: 'Signal power decreases with the square of distance (1/r²). At 36,000 km (GEO), FSPL ≈ 200 dB at Ku-band. This is the dominant loss in any satellite link.' },
|
||
tAtm: { title: '🌫️ Atmospheric Attenuation',
|
||
text: 'Oxygen and water vapour molecules absorb RF energy. Attenuation increases with frequency: ~0.2 dB at C-band to >1 dB at Ka-band for a 30° elevation.' },
|
||
tRain: { title: '🌧️ Rain Attenuation',
|
||
text: 'Raindrops scatter and absorb signals, especially above 10 GHz. A heavy storm can add 10–20 dB of loss at Ka-band. ITU-R P.618 models this statistically.' },
|
||
tIono: { title: '🔮 Ionospheric Effects',
|
||
text: 'The ionosphere causes Faraday rotation, scintillation and group delay. Effects are strongest below 3 GHz and vary with solar activity (11-year cycle).' },
|
||
tNoise: { title: '🌡️ Thermal Noise',
|
||
text: 'Every component adds noise (N = kTB). The system noise temperature combines antenna noise, LNB noise figure, and sky temperature. Lower C/N = fewer bits/s.' },
|
||
tPoint: { title: '🎯 Pointing Loss',
|
||
text: 'Misalignment between the antenna boresight and satellite direction. A 0.1° error on a 1.2 m dish at Ku-band ≈ 0.5 dB loss. Wind and thermal expansion are causes.' },
|
||
};
|
||
|
||
// Hover / click detection on toggles
|
||
ids.forEach(id => {
|
||
const row = document.getElementById(id).closest('.toggle-row');
|
||
row.addEventListener('mouseenter', () => {
|
||
const d = legendData[id];
|
||
document.getElementById('legendTitle').textContent = d.title;
|
||
document.getElementById('legendText').textContent = d.text;
|
||
});
|
||
row.addEventListener('mouseleave', () => {
|
||
document.getElementById('legendTitle').textContent = '📡 Satellite Link Overview';
|
||
document.getElementById('legendText').innerHTML = 'Toggle each impairment to see how it affects the signal. <b style=\"color:#4FC3F7\">Cyan</b> = Downlink (sat → ground). <b style=\"color:#34d399\">Green</b> = Uplink (ground → sat). The signal strength bar shows the cumulative effect.';
|
||
});
|
||
});
|
||
|
||
// ── Stars ──
|
||
const stars = Array.from({length: 200}, () => ({
|
||
x: Math.random(), y: Math.random() * 0.65,
|
||
r: Math.random() * 1.3 + 0.3,
|
||
twinkle: Math.random() * Math.PI * 2,
|
||
speed: 0.3 + Math.random() * 1.5,
|
||
}));
|
||
|
||
// ── Rain drops ──
|
||
const drops = Array.from({length: 120}, () => ({
|
||
x: Math.random(), y: Math.random(),
|
||
len: 8 + Math.random() * 18,
|
||
speed: 4 + Math.random() * 6,
|
||
opacity: 0.15 + Math.random() * 0.35,
|
||
}));
|
||
|
||
// ── Animation state ──
|
||
let t = 0;
|
||
const satOrbitAngle = { v: 0 };
|
||
|
||
// ── Signal packets (fixed pool, bidirectional) ──
|
||
const MAX_DOWN = 8; // downlink packets (sat → ground)
|
||
const MAX_UP = 5; // uplink packets (ground → sat)
|
||
let downPackets = [];
|
||
let upPackets = [];
|
||
|
||
function initPackets() {
|
||
downPackets = Array.from({length: MAX_DOWN}, (_, i) => ({
|
||
progress: i / MAX_DOWN, // spread evenly
|
||
speed: 0.003 + Math.random() * 0.002,
|
||
}));
|
||
upPackets = Array.from({length: MAX_UP}, (_, i) => ({
|
||
progress: i / MAX_UP,
|
||
speed: 0.0025 + Math.random() * 0.002,
|
||
}));
|
||
}
|
||
initPackets();
|
||
|
||
// ── Drawing helpers ──
|
||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||
|
||
function drawEarth() {
|
||
const earthY = H * 0.88;
|
||
const earthR = W * 0.9;
|
||
|
||
// Atmosphere glow
|
||
const atmGrad = ctx.createRadialGradient(W/2, earthY + earthR*0.3, earthR * 0.85, W/2, earthY + earthR*0.3, earthR * 1.15);
|
||
atmGrad.addColorStop(0, 'rgba(56,189,248,0.08)');
|
||
atmGrad.addColorStop(0.5, 'rgba(56,189,248,0.03)');
|
||
atmGrad.addColorStop(1, 'transparent');
|
||
ctx.fillStyle = atmGrad;
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Earth body
|
||
const grad = ctx.createRadialGradient(W/2, earthY + earthR*0.3, earthR * 0.2, W/2, earthY + earthR*0.3, earthR);
|
||
grad.addColorStop(0, '#1a6b4a');
|
||
grad.addColorStop(0.4, '#0f4c75');
|
||
grad.addColorStop(0.8, '#0b3d6b');
|
||
grad.addColorStop(1, '#062a4d');
|
||
ctx.beginPath();
|
||
ctx.arc(W/2, earthY + earthR * 0.3, earthR, 0, Math.PI * 2);
|
||
ctx.fillStyle = grad;
|
||
ctx.fill();
|
||
|
||
// Atmosphere rim
|
||
if (isOn('tAtm')) {
|
||
ctx.beginPath();
|
||
ctx.arc(W/2, earthY + earthR * 0.3, earthR + 8, Math.PI, 2 * Math.PI);
|
||
ctx.strokeStyle = 'rgba(56,189,248,0.25)';
|
||
ctx.lineWidth = 18;
|
||
ctx.stroke();
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(W/2, earthY + earthR * 0.3, earthR + 25, Math.PI * 1.1, Math.PI * 1.9);
|
||
ctx.strokeStyle = 'rgba(255,217,61,0.12)';
|
||
ctx.lineWidth = 12;
|
||
ctx.stroke();
|
||
|
||
// Label
|
||
ctx.fillStyle = 'rgba(255,217,61,0.7)';
|
||
ctx.font = '11px system-ui';
|
||
ctx.fillText('Troposphere', W/2 + earthR * 0.25, earthY - 30);
|
||
}
|
||
|
||
// Ionosphere
|
||
if (isOn('tIono')) {
|
||
for (let i = 0; i < 3; i++) {
|
||
ctx.beginPath();
|
||
ctx.arc(W/2, earthY + earthR * 0.3, earthR + 55 + i * 18, Math.PI * 1.05, Math.PI * 1.95);
|
||
ctx.strokeStyle = `rgba(192,132,252,${0.08 + Math.sin(t * 0.8 + i) * 0.05})`;
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
}
|
||
ctx.fillStyle = 'rgba(192,132,252,0.6)';
|
||
ctx.font = '11px system-ui';
|
||
ctx.fillText('Ionosphere', W/2 - earthR * 0.35, earthY - 85);
|
||
}
|
||
|
||
return earthY;
|
||
}
|
||
|
||
function drawGroundStation(earthY) {
|
||
const gx = W * 0.38;
|
||
const gy = earthY - 18;
|
||
|
||
// Dish base
|
||
ctx.fillStyle = '#475569';
|
||
ctx.fillRect(gx - 4, gy - 5, 8, 20);
|
||
|
||
// Dish
|
||
ctx.beginPath();
|
||
ctx.ellipse(gx, gy - 12, 22, 28, -0.4, -Math.PI * 0.5, Math.PI * 0.5);
|
||
ctx.strokeStyle = '#94a3b8';
|
||
ctx.lineWidth = 3;
|
||
ctx.stroke();
|
||
|
||
// Feed
|
||
ctx.beginPath();
|
||
ctx.moveTo(gx, gy - 12);
|
||
ctx.lineTo(gx + 18, gy - 30);
|
||
ctx.strokeStyle = '#64748b';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
|
||
// LNB
|
||
ctx.beginPath();
|
||
ctx.arc(gx + 18, gy - 31, 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = '#f59e0b';
|
||
ctx.fill();
|
||
|
||
// Label
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.font = 'bold 11px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Ground Station', gx, gy + 28);
|
||
ctx.textAlign = 'start';
|
||
|
||
return { x: gx + 18, y: gy - 31 };
|
||
}
|
||
|
||
function drawSatellite() {
|
||
const cx = W * 0.62;
|
||
const cy = H * 0.12;
|
||
const bob = Math.sin(t * 0.5) * 4;
|
||
|
||
const sx = cx;
|
||
const sy = cy + bob;
|
||
|
||
// Solar panels
|
||
ctx.fillStyle = '#1e3a5f';
|
||
ctx.strokeStyle = '#38bdf8';
|
||
ctx.lineWidth = 1;
|
||
for (const side of [-1, 1]) {
|
||
ctx.save();
|
||
ctx.translate(sx + side * 22, sy);
|
||
ctx.fillRect(side > 0 ? 4 : -38, -14, 34, 28);
|
||
ctx.strokeRect(side > 0 ? 4 : -38, -14, 34, 28);
|
||
// Panel lines
|
||
for (let i = 1; i < 4; i++) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(side > 0 ? 4 + i * 8.5 : -38 + i * 8.5, -14);
|
||
ctx.lineTo(side > 0 ? 4 + i * 8.5 : -38 + i * 8.5, 14);
|
||
ctx.strokeStyle = 'rgba(56,189,248,0.3)';
|
||
ctx.stroke();
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// Body
|
||
ctx.fillStyle = '#334155';
|
||
ctx.strokeStyle = '#64748b';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.fillRect(sx - 12, sy - 16, 24, 32);
|
||
ctx.strokeRect(sx - 12, sy - 16, 24, 32);
|
||
|
||
// Antenna dish
|
||
ctx.beginPath();
|
||
ctx.ellipse(sx, sy + 22, 10, 5, 0, 0, Math.PI);
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.fill();
|
||
|
||
// Antenna feed
|
||
ctx.beginPath();
|
||
ctx.moveTo(sx, sy + 16);
|
||
ctx.lineTo(sx, sy + 28);
|
||
ctx.strokeStyle = '#cbd5e1';
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
|
||
// Status LED
|
||
ctx.beginPath();
|
||
ctx.arc(sx, sy - 10, 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(52,211,153,${0.5 + Math.sin(t * 2) * 0.5})`;
|
||
ctx.fill();
|
||
|
||
// Label
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.font = 'bold 11px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('GEO Satellite', sx, sy - 34);
|
||
ctx.font = '10px system-ui';
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.fillText('36 000 km', sx, sy - 22);
|
||
ctx.textAlign = 'start';
|
||
|
||
return { x: sx, y: sy + 28 };
|
||
}
|
||
|
||
function drawSignalBeam(from, to) {
|
||
// Count active impairments
|
||
const active = ids.filter(isOn).length;
|
||
const strength = Math.max(0.15, 1 - active * 0.12);
|
||
|
||
const dx = to.x - from.x;
|
||
const dy = to.y - from.y;
|
||
const len = Math.sqrt(dx*dx + dy*dy);
|
||
const nx = -dy / len;
|
||
const ny = dx / len;
|
||
const spreadTop = 8;
|
||
const spreadBot = 45;
|
||
|
||
// ── Downlink beam cone (sat → ground) ──
|
||
const beamGradDown = ctx.createLinearGradient(from.x, from.y, to.x, to.y);
|
||
beamGradDown.addColorStop(0, `rgba(79,195,247,${0.20 * strength})`);
|
||
beamGradDown.addColorStop(0.5, `rgba(79,195,247,${0.08 * strength})`);
|
||
beamGradDown.addColorStop(1, `rgba(79,195,247,${0.03 * strength})`);
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(from.x + nx * spreadTop, from.y + ny * spreadTop);
|
||
ctx.lineTo(to.x + nx * spreadBot, to.y + ny * spreadBot);
|
||
ctx.lineTo(to.x - nx * spreadBot * 0.3, to.y - ny * spreadBot * 0.3);
|
||
ctx.lineTo(from.x - nx * spreadTop * 0.3, from.y - ny * spreadTop * 0.3);
|
||
ctx.closePath();
|
||
ctx.fillStyle = beamGradDown;
|
||
ctx.fill();
|
||
|
||
// ── Uplink beam cone (ground → sat) — offset, different color ──
|
||
const beamGradUp = ctx.createLinearGradient(to.x, to.y, from.x, from.y);
|
||
beamGradUp.addColorStop(0, `rgba(52,211,153,${0.14 * strength})`);
|
||
beamGradUp.addColorStop(0.5, `rgba(52,211,153,${0.06 * strength})`);
|
||
beamGradUp.addColorStop(1, `rgba(52,211,153,${0.02 * strength})`);
|
||
|
||
const offX = nx * 20; // lateral offset so beams don't overlap
|
||
const offY = ny * 20;
|
||
ctx.beginPath();
|
||
ctx.moveTo(from.x - nx * spreadTop + offX, from.y - ny * spreadTop + offY);
|
||
ctx.lineTo(to.x - nx * spreadBot * 0.6 + offX, to.y - ny * spreadBot * 0.6 + offY);
|
||
ctx.lineTo(to.x + nx * spreadBot * 0.1 + offX, to.y + ny * spreadBot * 0.1 + offY);
|
||
ctx.lineTo(from.x + nx * spreadTop * 0.1 + offX, from.y + ny * spreadTop * 0.1 + offY);
|
||
ctx.closePath();
|
||
ctx.fillStyle = beamGradUp;
|
||
ctx.fill();
|
||
|
||
// ── Downlink packets (sat → ground) — cyan ──
|
||
downPackets.forEach(p => {
|
||
p.progress += p.speed;
|
||
if (p.progress > 1) p.progress -= 1; // wrap around, never accumulate
|
||
const px = lerp(from.x, to.x, p.progress);
|
||
const py = lerp(from.y, to.y, p.progress);
|
||
const sz = 2.5 + Math.sin(p.progress * Math.PI) * 3;
|
||
const alpha = Math.sin(p.progress * Math.PI) * 0.8 * strength;
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, sz, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(79,195,247,${alpha})`;
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, sz + 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(79,195,247,${alpha * 0.25})`;
|
||
ctx.fill();
|
||
});
|
||
|
||
// ── Uplink packets (ground → sat) — green, offset path ──
|
||
upPackets.forEach(p => {
|
||
p.progress += p.speed;
|
||
if (p.progress > 1) p.progress -= 1;
|
||
const px = lerp(to.x, from.x, p.progress) + offX;
|
||
const py = lerp(to.y, from.y, p.progress) + offY;
|
||
const sz = 2 + Math.sin(p.progress * Math.PI) * 2.5;
|
||
const alpha = Math.sin(p.progress * Math.PI) * 0.7 * strength;
|
||
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, sz, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(52,211,153,${alpha})`;
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.arc(px, py, sz + 3, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(52,211,153,${alpha * 0.25})`;
|
||
ctx.fill();
|
||
});
|
||
|
||
// ── Beam labels ──
|
||
const midX = lerp(from.x, to.x, 0.12);
|
||
const midY = lerp(from.y, to.y, 0.12);
|
||
ctx.font = '10px system-ui';
|
||
ctx.fillStyle = 'rgba(79,195,247,0.7)';
|
||
ctx.fillText('▼ Downlink', midX + 12, midY);
|
||
ctx.fillStyle = 'rgba(52,211,153,0.7)';
|
||
ctx.fillText('▲ Uplink', midX + offX - 50, midY + offY);
|
||
|
||
// ── Impairment markers along the beam ──
|
||
const impairments = [
|
||
{ id: 'tFSL', pos: 0.25, color: '#ff6b6b', label: 'FSPL –200 dB', symbol: '📉' },
|
||
{ id: 'tIono', pos: 0.55, color: '#c084fc', label: 'Iono scintillation', symbol: '🔮' },
|
||
{ id: 'tAtm', pos: 0.72, color: '#ffd93d', label: 'Gas absorption', symbol: '🌫️' },
|
||
{ id: 'tRain', pos: 0.82, color: '#6bcbff', label: 'Rain fade', symbol: '🌧️' },
|
||
{ id: 'tNoise', pos: 0.92, color: '#fb923c', label: 'N = kTB', symbol: '🌡️' },
|
||
{ id: 'tPoint', pos: 0.96, color: '#34d399', label: 'Pointing err.', symbol: '🎯' },
|
||
];
|
||
|
||
impairments.forEach(imp => {
|
||
if (!isOn(imp.id)) return;
|
||
const ix = lerp(from.x, to.x, imp.pos);
|
||
const iy = lerp(from.y, to.y, imp.pos);
|
||
|
||
// Pulse ring
|
||
const pulse = Math.sin(t * 2 + imp.pos * 10) * 0.3 + 0.7;
|
||
ctx.beginPath();
|
||
ctx.arc(ix, iy, 14 * pulse, 0, Math.PI * 2);
|
||
ctx.strokeStyle = imp.color;
|
||
ctx.lineWidth = 1.5;
|
||
ctx.globalAlpha = 0.6;
|
||
ctx.stroke();
|
||
ctx.globalAlpha = 1;
|
||
|
||
// Cross mark
|
||
const cs = 5;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ix - cs, iy - cs); ctx.lineTo(ix + cs, iy + cs);
|
||
ctx.moveTo(ix + cs, iy - cs); ctx.lineTo(ix - cs, iy + cs);
|
||
ctx.strokeStyle = imp.color;
|
||
ctx.lineWidth = 2;
|
||
ctx.stroke();
|
||
|
||
// Label
|
||
ctx.fillStyle = imp.color;
|
||
ctx.font = '10px system-ui';
|
||
const labelX = ix + (imp.pos < 0.5 ? 20 : -ctx.measureText(imp.label).width - 20);
|
||
ctx.fillText(imp.label, labelX, iy + 4);
|
||
});
|
||
|
||
return strength;
|
||
}
|
||
|
||
function drawSignalBar(strength) {
|
||
const barX = 18;
|
||
const barY = H * 0.15;
|
||
const barW = 12;
|
||
const barH = H * 0.45;
|
||
|
||
// Background
|
||
ctx.fillStyle = 'rgba(30,58,95,0.5)';
|
||
ctx.strokeStyle = 'rgba(79,195,247,0.3)';
|
||
ctx.lineWidth = 1;
|
||
roundRect(ctx, barX, barY, barW, barH, 6);
|
||
ctx.fill();
|
||
ctx.stroke();
|
||
|
||
// Fill
|
||
const fillH = barH * strength;
|
||
const fillGrad = ctx.createLinearGradient(0, barY + barH - fillH, 0, barY + barH);
|
||
if (strength > 0.6) {
|
||
fillGrad.addColorStop(0, '#34d399');
|
||
fillGrad.addColorStop(1, '#059669');
|
||
} else if (strength > 0.3) {
|
||
fillGrad.addColorStop(0, '#fbbf24');
|
||
fillGrad.addColorStop(1, '#d97706');
|
||
} else {
|
||
fillGrad.addColorStop(0, '#f87171');
|
||
fillGrad.addColorStop(1, '#dc2626');
|
||
}
|
||
roundRect(ctx, barX + 1, barY + barH - fillH, barW - 2, fillH, 5);
|
||
ctx.fillStyle = fillGrad;
|
||
ctx.fill();
|
||
|
||
// Label
|
||
ctx.save();
|
||
ctx.translate(barX + barW + 6, barY + barH / 2);
|
||
ctx.rotate(-Math.PI / 2);
|
||
ctx.fillStyle = '#64748b';
|
||
ctx.font = '10px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Signal Strength', 0, 0);
|
||
ctx.restore();
|
||
|
||
// Percentage
|
||
ctx.fillStyle = '#e2e8f0';
|
||
ctx.font = 'bold 12px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(Math.round(strength * 100) + '%', barX + barW/2, barY - 8);
|
||
ctx.textAlign = 'start';
|
||
}
|
||
|
||
function drawRain(earthY) {
|
||
if (!isOn('tRain')) return;
|
||
const rainZoneTop = earthY - 140;
|
||
const rainZoneBot = earthY - 10;
|
||
|
||
// Cloud
|
||
const cloudAlpha = 0.25 + Math.sin(t * 0.3) * 0.08;
|
||
ctx.fillStyle = `rgba(148,163,184,${cloudAlpha})`;
|
||
drawCloud(W * 0.42, rainZoneTop - 5, 60, 25);
|
||
drawCloud(W * 0.55, rainZoneTop + 10, 45, 20);
|
||
|
||
// Drops
|
||
drops.forEach(d => {
|
||
const dx = d.x * W * 0.5 + W * 0.25;
|
||
d.y += d.speed / H;
|
||
if (d.y > 1) { d.y = 0; d.x = Math.random(); }
|
||
const dy = rainZoneTop + d.y * (rainZoneBot - rainZoneTop);
|
||
ctx.beginPath();
|
||
ctx.moveTo(dx, dy);
|
||
ctx.lineTo(dx - 0.5, dy + d.len);
|
||
ctx.strokeStyle = `rgba(107,203,255,${d.opacity})`;
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
});
|
||
}
|
||
|
||
function drawCloud(cx, cy, w, h) {
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx, cy, w, h, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx - w * 0.45, cy + 5, w * 0.55, h * 0.7, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.beginPath();
|
||
ctx.ellipse(cx + w * 0.4, cy + 3, w * 0.5, h * 0.65, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
|
||
function drawNoise(gndPos) {
|
||
if (!isOn('tNoise')) return;
|
||
for (let i = 0; i < 25; i++) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const dist = 15 + Math.random() * 30;
|
||
const nx = gndPos.x + Math.cos(angle) * dist;
|
||
const ny = gndPos.y + Math.sin(angle) * dist;
|
||
ctx.beginPath();
|
||
ctx.arc(nx, ny, 1 + Math.random() * 1.5, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(251,146,60,${0.2 + Math.random() * 0.4})`;
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function drawPointingError(satPos, gndPos) {
|
||
if (!isOn('tPoint')) return;
|
||
const offset = Math.sin(t * 1.2) * 12;
|
||
ctx.setLineDash([4, 6]);
|
||
ctx.beginPath();
|
||
ctx.moveTo(gndPos.x, gndPos.y);
|
||
ctx.lineTo(gndPos.x + offset * 3, gndPos.y - 60);
|
||
ctx.strokeStyle = 'rgba(52,211,153,0.35)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
function roundRect(ctx, x, y, w, h, r) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.lineTo(x + w - r, y);
|
||
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
||
ctx.lineTo(x + w, y + h - r);
|
||
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
||
ctx.lineTo(x + r, y + h);
|
||
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
||
ctx.lineTo(x, y + r);
|
||
ctx.quadraticCurveTo(x, y, x + r, y);
|
||
ctx.closePath();
|
||
}
|
||
|
||
// ── Main loop ──
|
||
function draw() {
|
||
t += 0.016;
|
||
ctx.clearRect(0, 0, W, H);
|
||
|
||
// Background gradient (space)
|
||
const bg = ctx.createLinearGradient(0, 0, 0, H);
|
||
bg.addColorStop(0, '#020617');
|
||
bg.addColorStop(0.6, '#0a1628');
|
||
bg.addColorStop(1, '#0d1b2a');
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, W, H);
|
||
|
||
// Stars
|
||
stars.forEach(s => {
|
||
const alpha = 0.3 + Math.sin(t * s.speed + s.twinkle) * 0.35 + 0.35;
|
||
ctx.beginPath();
|
||
ctx.arc(s.x * W, s.y * H, s.r, 0, Math.PI * 2);
|
||
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
|
||
ctx.fill();
|
||
});
|
||
|
||
// Earth
|
||
const earthY = drawEarth();
|
||
|
||
// Ground station
|
||
const gndPos = drawGroundStation(earthY);
|
||
|
||
// Satellite
|
||
const satPos = drawSatellite();
|
||
|
||
// Rain (behind beam)
|
||
drawRain(earthY);
|
||
|
||
// Signal beam
|
||
const strength = drawSignalBeam(satPos, gndPos);
|
||
|
||
// Noise sparkles
|
||
drawNoise(gndPos);
|
||
|
||
// Pointing error
|
||
drawPointingError(satPos, gndPos);
|
||
|
||
// Signal bar
|
||
drawSignalBar(strength);
|
||
|
||
// Title
|
||
ctx.fillStyle = '#e2e8f0';
|
||
ctx.font = 'bold 16px system-ui';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText('Satellite Communication Link — Signal Impairments', W/2, 30);
|
||
ctx.textAlign = 'start';
|
||
|
||
requestAnimationFrame(draw);
|
||
}
|
||
draw();
|
||
</script>
|
||
</body>
|
||
</html>
|
||
"""
|
||
|
||
|
||
def render():
|
||
"""Render the satellite link animation page."""
|
||
st.markdown("## 🛰️ Satellite Link — Interactive Animation")
|
||
st.markdown(
|
||
"Visualise how a signal travels from a **GEO satellite** to a "
|
||
"**ground station** and discover the impairments that degrade it. "
|
||
"Toggle each effect on/off to see the impact on signal strength."
|
||
)
|
||
st.divider()
|
||
|
||
components.html(_ANIMATION_HTML, height=680, scrolling=False)
|
||
|
||
# ── Pedagogical summary below the animation ──
|
||
st.divider()
|
||
st.markdown("### 📚 Understanding the Link Budget")
|
||
|
||
col1, col2 = st.columns(2)
|
||
|
||
with col1:
|
||
st.markdown("""
|
||
**Uplink & Downlink path:**
|
||
|
||
The signal travels ~36 000 km from a geostationary satellite to Earth.
|
||
Along the way it encounters multiple sources of degradation:
|
||
|
||
1. **Free-Space Path Loss** — the dominant factor, purely geometric (1/r²)
|
||
2. **Atmospheric gases** — O₂ and H₂O absorption (ITU-R P.676)
|
||
3. **Rain** — scattering & absorption, worst at Ka-band (ITU-R P.618)
|
||
4. **Ionosphere** — Faraday rotation, scintillation (ITU-R P.531)
|
||
""")
|
||
|
||
with col2:
|
||
st.markdown("""
|
||
**At the receiver:**
|
||
|
||
Even after the signal arrives, further degradation occurs:
|
||
|
||
5. **Thermal noise** — every component adds noise: $N = k \\cdot T_{sys} \\cdot B$
|
||
6. **Pointing loss** — antenna misalignment reduces gain
|
||
7. **Implementation losses** — ADC quantisation, filter roll-off, etc.
|
||
|
||
The **Shannon limit** $C = B \\log_2(1 + C/N)$ tells us the maximum
|
||
bit rate achievable given the remaining signal-to-noise ratio.
|
||
""")
|
||
|
||
with st.expander("🔗 Key ITU-R Recommendations"):
|
||
st.markdown("""
|
||
| Recommendation | Topic |
|
||
|:---:|:---|
|
||
| **P.618** | Rain attenuation & propagation effects for satellite links |
|
||
| **P.676** | Gaseous attenuation on terrestrial and slant paths |
|
||
| **P.531** | Ionospheric effects on radiowave propagation |
|
||
| **P.837** | Rainfall rate statistics for prediction models |
|
||
| **P.839** | Rain height model for prediction methods |
|
||
| **S.1428** | Reference satellite link for system design |
|
||
""")
|