From 375a78f5b9535730575f12adf688a7918960f838 Mon Sep 17 00:00:00 2001 From: mytec Date: Sat, 31 Jan 2026 13:19:36 +0200 Subject: [PATCH] @mytec: iter1.6.1 ready for testing --- .claude/settings.local.json | 13 +- RFCP-Iteration-1.6.1-Extra-Factors.md | 549 ++++++++++++++++++++ backend/app/api/routes/coverage.py | 9 + backend/app/services/atmospheric_service.py | 98 ++++ backend/app/services/coverage_service.py | 53 +- backend/app/services/indoor_service.py | 82 +++ backend/app/services/weather_service.py | 102 ++++ frontend/src/App.tsx | 112 ++++ frontend/src/services/api.ts | 11 + frontend/src/store/coverage.ts | 16 + frontend/src/types/coverage.ts | 14 + 11 files changed, 1055 insertions(+), 4 deletions(-) create mode 100644 RFCP-Iteration-1.6.1-Extra-Factors.md create mode 100644 backend/app/services/atmospheric_service.py create mode 100644 backend/app/services/indoor_service.py create mode 100644 backend/app/services/weather_service.py diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 61f41ab..3f81947 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,16 @@ "Bash(python:*)", "Bash(pip --version:*)", "Bash(pip install:*)", - "Bash(npx vite build:*)" + "Bash(npx vite build:*)", + "Bash(git:*)", + "Bash(cat:*)", + "Bash(ls:*)", + "Bash(cd:*)", + "Bash(mkdir:*)", + "Bash(cp:*)", + "Bash(mv:*)", + "Read(*)", + "Write(*)" ] } -} +} \ No newline at end of file diff --git a/RFCP-Iteration-1.6.1-Extra-Factors.md b/RFCP-Iteration-1.6.1-Extra-Factors.md new file mode 100644 index 0000000..2cc8f58 --- /dev/null +++ b/RFCP-Iteration-1.6.1-Extra-Factors.md @@ -0,0 +1,549 @@ +# RFCP Iteration 1.6.1: Extra Factors + +**Date:** January 31, 2025 +**Type:** Backend Enhancement +**Estimated:** 4-6 hours +**Location:** `/opt/rfcp/backend/` +**Priority:** Low โ€” nice to have for realism + +--- + +## ๐ŸŽฏ Goal + +Add weather effects, indoor penetration loss, and atmospheric absorption for more realistic RF propagation modeling. + +--- + +## โœ… Features + +### 1. Rain Attenuation (ITU-R P.838) + +**Theory:** Rain causes signal attenuation, especially at higher frequencies (>10 GHz). Even at LTE frequencies (700-2600 MHz), heavy rain can add 1-3 dB loss. + +**app/services/weather_service.py:** +```python +from typing import Optional +import math + +class WeatherService: + """ITU-R P.838 rain attenuation model""" + + # ITU-R P.838-3 coefficients for horizontal polarization + # Format: (frequency_GHz, k, alpha) + RAIN_COEFFICIENTS = { + 0.7: (0.0000387, 0.912), + 1.0: (0.0000887, 0.949), + 1.8: (0.000292, 1.021), + 2.1: (0.000425, 1.052), + 2.6: (0.000683, 1.091), + 3.5: (0.00138, 1.149), + 5.0: (0.00361, 1.206), + 10.0: (0.0245, 1.200), + 20.0: (0.0906, 1.099), + 30.0: (0.175, 1.021), + } + + def calculate_rain_attenuation( + self, + frequency_mhz: float, + distance_km: float, + rain_rate: float, # mm/h + polarization: str = "horizontal" + ) -> float: + """ + Calculate rain attenuation in dB + + Args: + frequency_mhz: Frequency in MHz + distance_km: Path length in km + rain_rate: Rain rate in mm/h (0=none, 5=light, 25=moderate, 50=heavy) + polarization: "horizontal" or "vertical" + + Returns: + Attenuation in dB + """ + if rain_rate <= 0: + return 0.0 + + freq_ghz = frequency_mhz / 1000 + + # Get interpolated coefficients + k, alpha = self._get_coefficients(freq_ghz) + + # Specific attenuation (dB/km) + gamma_r = k * (rain_rate ** alpha) + + # Effective path length reduction for longer paths + # Rain cells are typically 2-5 km + if distance_km > 2: + reduction_factor = 1 / (1 + distance_km / 35) + effective_distance = distance_km * reduction_factor + else: + effective_distance = distance_km + + attenuation = gamma_r * effective_distance + + return min(attenuation, 30.0) # Cap at 30 dB + + def _get_coefficients(self, freq_ghz: float) -> tuple[float, float]: + """Interpolate rain coefficients for frequency""" + freqs = sorted(self.RAIN_COEFFICIENTS.keys()) + + # Find surrounding frequencies + if freq_ghz <= freqs[0]: + return self.RAIN_COEFFICIENTS[freqs[0]] + if freq_ghz >= freqs[-1]: + return self.RAIN_COEFFICIENTS[freqs[-1]] + + for i in range(len(freqs) - 1): + if freqs[i] <= freq_ghz <= freqs[i + 1]: + f1, f2 = freqs[i], freqs[i + 1] + k1, a1 = self.RAIN_COEFFICIENTS[f1] + k2, a2 = self.RAIN_COEFFICIENTS[f2] + + # Linear interpolation + t = (freq_ghz - f1) / (f2 - f1) + k = k1 + t * (k2 - k1) + alpha = a1 + t * (a2 - a1) + + return k, alpha + + return self.RAIN_COEFFICIENTS[freqs[0]] + + @staticmethod + def rain_rate_from_description(description: str) -> float: + """Convert rain description to rate""" + rates = { + "none": 0.0, + "drizzle": 2.5, + "light": 5.0, + "moderate": 12.5, + "heavy": 25.0, + "very_heavy": 50.0, + "extreme": 100.0, + } + return rates.get(description.lower(), 0.0) + + +weather_service = WeatherService() +``` + +--- + +### 2. Indoor Penetration Loss (ITU-R P.2109) + +**Theory:** Signals lose strength when entering buildings. Loss depends on building type, frequency, and wall materials. + +**app/services/indoor_service.py:** +```python +from typing import Optional +import math + +class IndoorService: + """ITU-R P.2109 building entry loss model""" + + # Building Entry Loss (BEL) by construction type at 2 GHz + # Format: (median_loss_dB, std_dev_dB) + BUILDING_TYPES = { + "none": (0.0, 0.0), # Outdoor only + "light": (8.0, 4.0), # Wood frame, large windows + "medium": (15.0, 6.0), # Brick, standard windows + "heavy": (22.0, 8.0), # Concrete, small windows + "basement": (30.0, 10.0), # Underground + "vehicle": (6.0, 3.0), # Inside car + "train": (20.0, 5.0), # Inside train + } + + # Frequency correction factor (dB per octave above 2 GHz) + FREQ_CORRECTION = 2.5 + + def calculate_indoor_loss( + self, + frequency_mhz: float, + building_type: str = "medium", + floor_number: int = 0, + depth_m: float = 0.0, + ) -> float: + """ + Calculate building entry/indoor penetration loss + + Args: + frequency_mhz: Frequency in MHz + building_type: Type of building construction + floor_number: Floor number (0=ground, negative=basement) + depth_m: Distance from exterior wall in meters + + Returns: + Loss in dB + """ + if building_type == "none": + return 0.0 + + base_loss, _ = self.BUILDING_TYPES.get(building_type, (15.0, 6.0)) + + # Frequency correction + freq_ghz = frequency_mhz / 1000 + if freq_ghz > 2.0: + octaves = math.log2(freq_ghz / 2.0) + freq_correction = self.FREQ_CORRECTION * octaves + else: + freq_correction = 0.0 + + # Floor correction (higher floors = less loss due to better angle) + if floor_number > 0: + floor_correction = -1.5 * min(floor_number, 10) + elif floor_number < 0: + # Basement - additional loss + floor_correction = 5.0 * abs(floor_number) + else: + floor_correction = 0.0 + + # Depth correction (signal attenuates inside building) + # Approximately 0.5 dB per meter for first 10m + depth_correction = 0.5 * min(depth_m, 20) + + total_loss = base_loss + freq_correction + floor_correction + depth_correction + + return max(0.0, min(total_loss, 50.0)) # Clamp 0-50 dB + + def calculate_outdoor_to_indoor_coverage( + self, + outdoor_rsrp: float, + building_type: str, + frequency_mhz: float, + ) -> float: + """Calculate expected indoor RSRP from outdoor signal""" + indoor_loss = self.calculate_indoor_loss(frequency_mhz, building_type) + return outdoor_rsrp - indoor_loss + + +indoor_service = IndoorService() +``` + +--- + +### 3. Atmospheric Absorption (ITU-R P.676) + +**Theory:** Oxygen and water vapor absorb RF energy. Significant at mmWave (>10 GHz), minor at LTE bands. + +**app/services/atmospheric_service.py:** +```python +import math + +class AtmosphericService: + """ITU-R P.676 atmospheric absorption model""" + + # Simplified model for frequencies < 50 GHz + # Standard atmosphere: T=15ยฐC, P=1013 hPa, humidity=50% + + def calculate_atmospheric_loss( + self, + frequency_mhz: float, + distance_km: float, + temperature_c: float = 15.0, + humidity_percent: float = 50.0, + altitude_m: float = 0.0, + ) -> float: + """ + Calculate atmospheric absorption loss + + Args: + frequency_mhz: Frequency in MHz + distance_km: Path length in km + temperature_c: Temperature in Celsius + humidity_percent: Relative humidity (0-100) + altitude_m: Altitude above sea level + + Returns: + Loss in dB + """ + freq_ghz = frequency_mhz / 1000 + + # Below 1 GHz - negligible + if freq_ghz < 1.0: + return 0.0 + + # Calculate specific attenuation (dB/km) + gamma = self._specific_attenuation(freq_ghz, temperature_c, humidity_percent) + + # Altitude correction (less atmosphere at higher altitudes) + altitude_factor = math.exp(-altitude_m / 8500) # Scale height ~8.5km + + loss = gamma * distance_km * altitude_factor + + return min(loss, 20.0) # Cap for reasonable distances + + def _specific_attenuation( + self, + freq_ghz: float, + temperature_c: float, + humidity_percent: float, + ) -> float: + """ + Calculate specific attenuation in dB/km + + Simplified ITU-R P.676 model + """ + # Water vapor density (g/mยณ) - simplified + # Saturation vapor pressure (hPa) + es = 6.1121 * math.exp((18.678 - temperature_c / 234.5) * + (temperature_c / (257.14 + temperature_c))) + rho = (humidity_percent / 100) * es * 216.7 / (273.15 + temperature_c) + + # Oxygen absorption (dominant at 60 GHz, minor below 10 GHz) + if freq_ghz < 10: + gamma_o = 0.001 * freq_ghz ** 2 # Very small + elif freq_ghz < 57: + gamma_o = 0.001 * (freq_ghz / 10) ** 2.5 + else: + # Near 60 GHz resonance + gamma_o = 15.0 # Peak absorption + + # Water vapor absorption (peaks at 22 GHz and 183 GHz) + if freq_ghz < 10: + gamma_w = 0.0001 * rho * freq_ghz ** 2 + elif freq_ghz < 50: + gamma_w = 0.001 * rho * (freq_ghz / 22) ** 2 + else: + gamma_w = 0.01 * rho + + return gamma_o + gamma_w + + @staticmethod + def get_weather_description(loss_db: float) -> str: + """Describe atmospheric conditions based on loss""" + if loss_db < 0.1: + return "clear" + elif loss_db < 0.5: + return "normal" + elif loss_db < 2.0: + return "humid" + else: + return "foggy" + + +atmospheric_service = AtmosphericService() +``` + +--- + +### 4. Integration into Coverage Service + +**Update app/services/coverage_service.py:** +```python +# Add imports +from app.services.weather_service import weather_service +from app.services.indoor_service import indoor_service +from app.services.atmospheric_service import atmospheric_service + +# Update CoverageSettings +class CoverageSettings(BaseModel): + # ... existing fields ... + + # Weather + rain_rate: float = 0.0 # mm/h (0=none, 5=light, 25=heavy) + + # Indoor + indoor_loss_type: str = "none" # none, light, medium, heavy + + # Atmospheric + use_atmospheric: bool = False + temperature_c: float = 15.0 + humidity_percent: float = 50.0 + +# Update _calculate_point() +async def _calculate_point(self, ...): + # ... existing calculations ... + + # Rain attenuation + rain_loss = 0.0 + if settings.rain_rate > 0: + rain_loss = weather_service.calculate_rain_attenuation( + site.frequency, + distance / 1000, # km + settings.rain_rate + ) + + # Indoor penetration + indoor_loss = 0.0 + if settings.indoor_loss_type != "none": + indoor_loss = indoor_service.calculate_indoor_loss( + site.frequency, + settings.indoor_loss_type + ) + + # Atmospheric absorption + atmo_loss = 0.0 + if settings.use_atmospheric: + atmo_loss = atmospheric_service.calculate_atmospheric_loss( + site.frequency, + distance / 1000, + settings.temperature_c, + settings.humidity_percent + ) + + # Final RSRP + rsrp = (site.power + site.gain - path_loss - antenna_loss + - terrain_loss - building_loss - vegetation_loss + - rain_loss - indoor_loss - atmo_loss + + reflection_gain + water_reflection_gain) + + return CoveragePoint( + # ... existing fields ... + rain_loss=rain_loss, + indoor_loss=indoor_loss, + atmospheric_loss=atmo_loss, + ) +``` + +--- + +### 5. Update API Response + +**Update CoveragePoint model:** +```python +class CoveragePoint(BaseModel): + lat: float + lon: float + rsrp: float + distance: float + has_los: bool + terrain_loss: float = 0.0 + building_loss: float = 0.0 + vegetation_loss: float = 0.0 + reflection_gain: float = 0.0 + rain_loss: float = 0.0 # NEW + indoor_loss: float = 0.0 # NEW + atmospheric_loss: float = 0.0 # NEW +``` + +--- + +### 6. Frontend UI Updates + +**Add to CoverageSettings panel:** +```typescript +// Weather section +
+

Weather Conditions

+ + +
+ +// Indoor section +
+

Indoor Coverage

+ + +
+``` + +--- + +## ๐Ÿ“ Files Summary + +**New Files:** +``` +backend/app/services/ +โ”œโ”€โ”€ weather_service.py # Rain attenuation +โ”œโ”€โ”€ indoor_service.py # Building entry loss +โ””โ”€โ”€ atmospheric_service.py # Atmospheric absorption +``` + +**Modified Files:** +``` +backend/app/ +โ”œโ”€โ”€ services/coverage_service.py # Integration +โ”œโ”€โ”€ api/routes/coverage.py # New settings +โ””โ”€โ”€ models/coverage.py # New fields + +frontend/src/ +โ”œโ”€โ”€ types/coverage.ts # New fields +โ”œโ”€โ”€ services/api.ts # New settings +โ”œโ”€โ”€ store/coverage.ts # New state +โ””โ”€โ”€ components/panels/CoverageSettingsPanel.tsx # UI +``` + +--- + +## ๐Ÿงช Testing + +```bash +# Test rain attenuation +curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \ + -H "Content-Type: application/json" \ + -d '{ + "sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}], + "settings":{ + "radius":1000, + "resolution":100, + "preset":"fast", + "rain_rate":25 + } + }' + +# Test indoor penetration +curl -X POST "https://api.rfcp.eliah.one/api/coverage/calculate" \ + -H "Content-Type: application/json" \ + -d '{ + "sites":[{"lat":48.46,"lon":35.05,"height":30,"power":43,"gain":15,"frequency":1800}], + "settings":{ + "radius":1000, + "resolution":100, + "preset":"fast", + "indoor_loss_type":"medium" + } + }' +``` + +--- + +## โœ… Success Criteria + +- [ ] Rain attenuation calculated correctly (0 dB at 0 mm/h, ~5 dB at 25 mm/h for 1km) +- [ ] Indoor loss applied uniformly (~15 dB for medium building) +- [ ] Atmospheric loss minimal at LTE frequencies (<0.5 dB for 10km) +- [ ] Frontend shows weather/indoor selectors +- [ ] All presets still work +- [ ] Tests pass + +--- + +## ๐Ÿ“ Notes + +- Rain attenuation most significant at mmWave (5G NR FR2) +- Indoor loss is uniform for coverage area (all points affected equally) +- Atmospheric loss negligible below 6 GHz for typical distances +- These are "what-if" analysis features, not real-time weather integration + +--- + +## ๐Ÿ”œ Next: 2.1 โ€” Desktop Installer + +After 1.6.1: +- Electron packaging +- Offline mode with local data +- GPU acceleration for fast calculations + +--- + +**Ready for Claude Code** ๐Ÿš€ diff --git a/backend/app/api/routes/coverage.py b/backend/app/api/routes/coverage.py index 582a1cb..5b7f236 100644 --- a/backend/app/api/routes/coverage.py +++ b/backend/app/api/routes/coverage.py @@ -96,6 +96,9 @@ async def calculate_coverage(request: CoverageRequest) -> CoverageResponse: "points_with_terrain_loss": sum(1 for p in points if p.terrain_loss > 0), "points_with_reflection_gain": sum(1 for p in points if p.reflection_gain > 0), "points_with_vegetation_loss": sum(1 for p in points if p.vegetation_loss > 0), + "points_with_rain_loss": sum(1 for p in points if p.rain_loss > 0), + "points_with_indoor_loss": sum(1 for p in points if p.indoor_loss > 0), + "points_with_atmospheric_loss": sum(1 for p in points if p.atmospheric_loss > 0), } return CoverageResponse( @@ -183,5 +186,11 @@ def _get_active_models(settings: CoverageSettings) -> List[str]: models.append("water_reflection") if settings.use_vegetation: models.append("vegetation") + if settings.rain_rate > 0: + models.append("rain_attenuation") + if settings.indoor_loss_type != "none": + models.append("indoor_penetration") + if settings.use_atmospheric: + models.append("atmospheric") return models diff --git a/backend/app/services/atmospheric_service.py b/backend/app/services/atmospheric_service.py new file mode 100644 index 0000000..8a24013 --- /dev/null +++ b/backend/app/services/atmospheric_service.py @@ -0,0 +1,98 @@ +import math + + +class AtmosphericService: + """ITU-R P.676 atmospheric absorption model""" + + # Simplified model for frequencies < 50 GHz + # Standard atmosphere: T=15C, P=1013 hPa, humidity=50% + + def calculate_atmospheric_loss( + self, + frequency_mhz: float, + distance_km: float, + temperature_c: float = 15.0, + humidity_percent: float = 50.0, + altitude_m: float = 0.0, + ) -> float: + """ + Calculate atmospheric absorption loss + + Args: + frequency_mhz: Frequency in MHz + distance_km: Path length in km + temperature_c: Temperature in Celsius + humidity_percent: Relative humidity (0-100) + altitude_m: Altitude above sea level + + Returns: + Loss in dB + """ + freq_ghz = frequency_mhz / 1000 + + # Below 1 GHz - negligible + if freq_ghz < 1.0: + return 0.0 + + # Calculate specific attenuation (dB/km) + gamma = self._specific_attenuation(freq_ghz, temperature_c, humidity_percent) + + # Altitude correction (less atmosphere at higher altitudes) + altitude_factor = math.exp(-altitude_m / 8500) # Scale height ~8.5km + + loss = gamma * distance_km * altitude_factor + + return min(loss, 20.0) # Cap for reasonable distances + + def _specific_attenuation( + self, + freq_ghz: float, + temperature_c: float, + humidity_percent: float, + ) -> float: + """ + Calculate specific attenuation in dB/km + + Simplified ITU-R P.676 model + """ + # Water vapor density (g/m3) - simplified + # Saturation vapor pressure (hPa) + es = 6.1121 * math.exp( + (18.678 - temperature_c / 234.5) + * (temperature_c / (257.14 + temperature_c)) + ) + rho = (humidity_percent / 100) * es * 216.7 / (273.15 + temperature_c) + + # Oxygen absorption (dominant at 60 GHz, minor below 10 GHz) + if freq_ghz < 10: + gamma_o = 0.001 * freq_ghz ** 2 # Very small + elif freq_ghz < 57: + gamma_o = 0.001 * (freq_ghz / 10) ** 2.5 + else: + # Near 60 GHz resonance + gamma_o = 15.0 # Peak absorption + + # Water vapor absorption (peaks at 22 GHz and 183 GHz) + if freq_ghz < 10: + gamma_w = 0.0001 * rho * freq_ghz ** 2 + elif freq_ghz < 50: + gamma_w = 0.001 * rho * (freq_ghz / 22) ** 2 + else: + gamma_w = 0.01 * rho + + return gamma_o + gamma_w + + @staticmethod + def get_weather_description(loss_db: float) -> str: + """Describe atmospheric conditions based on loss""" + if loss_db < 0.1: + return "clear" + elif loss_db < 0.5: + return "normal" + elif loss_db < 2.0: + return "humid" + else: + return "foggy" + + +atmospheric_service = AtmosphericService() diff --git a/backend/app/services/coverage_service.py b/backend/app/services/coverage_service.py index e239ea0..b438ca2 100644 --- a/backend/app/services/coverage_service.py +++ b/backend/app/services/coverage_service.py @@ -12,6 +12,9 @@ from app.services.reflection_service import reflection_service from app.services.spatial_index import get_spatial_index, SpatialIndex from app.services.water_service import water_service, WaterBody from app.services.vegetation_service import vegetation_service, VegetationArea +from app.services.weather_service import weather_service +from app.services.indoor_service import indoor_service +from app.services.atmospheric_service import atmospheric_service class CoveragePoint(BaseModel): @@ -24,6 +27,9 @@ class CoveragePoint(BaseModel): building_loss: float # dB reflection_gain: float = 0.0 # dB vegetation_loss: float = 0.0 # dB + rain_loss: float = 0.0 # dB + indoor_loss: float = 0.0 # dB + atmospheric_loss: float = 0.0 # dB class CoverageSettings(BaseModel): @@ -44,6 +50,17 @@ class CoverageSettings(BaseModel): # Vegetation season season: str = "summer" + # Weather + rain_rate: float = 0.0 # mm/h (0=none, 5=light, 25=heavy) + + # Indoor + indoor_loss_type: str = "none" # none, light, medium, heavy, vehicle + + # Atmospheric + use_atmospheric: bool = False + temperature_c: float = 15.0 + humidity_percent: float = 50.0 + # Preset preset: Optional[str] = None # fast, standard, detailed, full @@ -419,9 +436,38 @@ class CoverageService: if is_over_water: reflection_gain = 3.0 # ~3dB boost over water + # Rain attenuation + rain_loss = 0.0 + if settings.rain_rate > 0: + rain_loss = weather_service.calculate_rain_attenuation( + site.frequency, + distance / 1000, # km + settings.rain_rate + ) + + # Indoor penetration loss + indoor_loss = 0.0 + if settings.indoor_loss_type != "none": + indoor_loss = indoor_service.calculate_indoor_loss( + site.frequency, + settings.indoor_loss_type + ) + + # Atmospheric absorption + atmo_loss = 0.0 + if settings.use_atmospheric: + atmo_loss = atmospheric_service.calculate_atmospheric_loss( + site.frequency, + distance / 1000, + settings.temperature_c, + settings.humidity_percent + ) + # Final RSRP rsrp = (site.power + site.gain - path_loss - antenna_loss - - terrain_loss - building_loss - veg_loss + reflection_gain) + - terrain_loss - building_loss - veg_loss + - rain_loss - indoor_loss - atmo_loss + + reflection_gain) return CoveragePoint( lat=lat, @@ -432,7 +478,10 @@ class CoverageService: terrain_loss=terrain_loss, building_loss=building_loss, reflection_gain=reflection_gain, - vegetation_loss=veg_loss + vegetation_loss=veg_loss, + rain_loss=rain_loss, + indoor_loss=indoor_loss, + atmospheric_loss=atmo_loss, ) def _okumura_hata( diff --git a/backend/app/services/indoor_service.py b/backend/app/services/indoor_service.py new file mode 100644 index 0000000..be2a0af --- /dev/null +++ b/backend/app/services/indoor_service.py @@ -0,0 +1,82 @@ +import math + + +class IndoorService: + """ITU-R P.2109 building entry loss model""" + + # Building Entry Loss (BEL) by construction type at 2 GHz + # Format: (median_loss_dB, std_dev_dB) + BUILDING_TYPES = { + "none": (0.0, 0.0), # Outdoor only + "light": (8.0, 4.0), # Wood frame, large windows + "medium": (15.0, 6.0), # Brick, standard windows + "heavy": (22.0, 8.0), # Concrete, small windows + "basement": (30.0, 10.0), # Underground + "vehicle": (6.0, 3.0), # Inside car + "train": (20.0, 5.0), # Inside train + } + + # Frequency correction factor (dB per octave above 2 GHz) + FREQ_CORRECTION = 2.5 + + def calculate_indoor_loss( + self, + frequency_mhz: float, + building_type: str = "medium", + floor_number: int = 0, + depth_m: float = 0.0, + ) -> float: + """ + Calculate building entry/indoor penetration loss + + Args: + frequency_mhz: Frequency in MHz + building_type: Type of building construction + floor_number: Floor number (0=ground, negative=basement) + depth_m: Distance from exterior wall in meters + + Returns: + Loss in dB + """ + if building_type == "none": + return 0.0 + + base_loss, _ = self.BUILDING_TYPES.get(building_type, (15.0, 6.0)) + + # Frequency correction + freq_ghz = frequency_mhz / 1000 + if freq_ghz > 2.0: + octaves = math.log2(freq_ghz / 2.0) + freq_correction = self.FREQ_CORRECTION * octaves + else: + freq_correction = 0.0 + + # Floor correction (higher floors = less loss due to better angle) + if floor_number > 0: + floor_correction = -1.5 * min(floor_number, 10) + elif floor_number < 0: + # Basement - additional loss + floor_correction = 5.0 * abs(floor_number) + else: + floor_correction = 0.0 + + # Depth correction (signal attenuates inside building) + # Approximately 0.5 dB per meter for first 10m + depth_correction = 0.5 * min(depth_m, 20) + + total_loss = base_loss + freq_correction + floor_correction + depth_correction + + return max(0.0, min(total_loss, 50.0)) # Clamp 0-50 dB + + def calculate_outdoor_to_indoor_coverage( + self, + outdoor_rsrp: float, + building_type: str, + frequency_mhz: float, + ) -> float: + """Calculate expected indoor RSRP from outdoor signal""" + indoor_loss = self.calculate_indoor_loss(frequency_mhz, building_type) + return outdoor_rsrp - indoor_loss + + +indoor_service = IndoorService() diff --git a/backend/app/services/weather_service.py b/backend/app/services/weather_service.py new file mode 100644 index 0000000..fd66919 --- /dev/null +++ b/backend/app/services/weather_service.py @@ -0,0 +1,102 @@ +import math + + +class WeatherService: + """ITU-R P.838 rain attenuation model""" + + # ITU-R P.838-3 coefficients for horizontal polarization + # Format: (frequency_GHz, k, alpha) + RAIN_COEFFICIENTS = { + 0.7: (0.0000387, 0.912), + 1.0: (0.0000887, 0.949), + 1.8: (0.000292, 1.021), + 2.1: (0.000425, 1.052), + 2.6: (0.000683, 1.091), + 3.5: (0.00138, 1.149), + 5.0: (0.00361, 1.206), + 10.0: (0.0245, 1.200), + 20.0: (0.0906, 1.099), + 30.0: (0.175, 1.021), + } + + def calculate_rain_attenuation( + self, + frequency_mhz: float, + distance_km: float, + rain_rate: float, # mm/h + ) -> float: + """ + Calculate rain attenuation in dB + + Args: + frequency_mhz: Frequency in MHz + distance_km: Path length in km + rain_rate: Rain rate in mm/h (0=none, 5=light, 25=moderate, 50=heavy) + + Returns: + Attenuation in dB + """ + if rain_rate <= 0: + return 0.0 + + freq_ghz = frequency_mhz / 1000 + + # Get interpolated coefficients + k, alpha = self._get_coefficients(freq_ghz) + + # Specific attenuation (dB/km) + gamma_r = k * (rain_rate ** alpha) + + # Effective path length reduction for longer paths + # Rain cells are typically 2-5 km + if distance_km > 2: + reduction_factor = 1 / (1 + distance_km / 35) + effective_distance = distance_km * reduction_factor + else: + effective_distance = distance_km + + attenuation = gamma_r * effective_distance + + return min(attenuation, 30.0) # Cap at 30 dB + + def _get_coefficients(self, freq_ghz: float) -> tuple[float, float]: + """Interpolate rain coefficients for frequency""" + freqs = sorted(self.RAIN_COEFFICIENTS.keys()) + + # Find surrounding frequencies + if freq_ghz <= freqs[0]: + return self.RAIN_COEFFICIENTS[freqs[0]] + if freq_ghz >= freqs[-1]: + return self.RAIN_COEFFICIENTS[freqs[-1]] + + for i in range(len(freqs) - 1): + if freqs[i] <= freq_ghz <= freqs[i + 1]: + f1, f2 = freqs[i], freqs[i + 1] + k1, a1 = self.RAIN_COEFFICIENTS[f1] + k2, a2 = self.RAIN_COEFFICIENTS[f2] + + # Linear interpolation + t = (freq_ghz - f1) / (f2 - f1) + k = k1 + t * (k2 - k1) + alpha = a1 + t * (a2 - a1) + + return k, alpha + + return self.RAIN_COEFFICIENTS[freqs[0]] + + @staticmethod + def rain_rate_from_description(description: str) -> float: + """Convert rain description to rate""" + rates = { + "none": 0.0, + "drizzle": 2.5, + "light": 5.0, + "moderate": 12.5, + "heavy": 25.0, + "very_heavy": 50.0, + "extreme": 100.0, + } + return rates.get(description.lower(), 0.0) + + +weather_service = WeatherService() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b604c7a..b847623 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -803,6 +803,118 @@ export default function App() { )} + + {/* Atmospheric absorption toggle */} + + {settings.use_atmospheric && ( +
+
+ + +
+
+ + +
+
+ )} + + {/* Weather / Rain section */} +
+

Environment

+
+ + +
+
+ + {/* Indoor penetration section */} +
+ + +
)} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b9f840d..dc93047 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -31,6 +31,11 @@ export interface ApiCoverageSettings { use_water_reflection?: boolean; use_vegetation?: boolean; season?: 'summer' | 'winter' | 'spring' | 'autumn'; + rain_rate?: number; + indoor_loss_type?: string; + use_atmospheric?: boolean; + temperature_c?: number; + humidity_percent?: number; } export interface CoverageRequest { @@ -50,6 +55,9 @@ export interface ApiCoveragePoint { building_loss: number; reflection_gain: number; vegetation_loss: number; + rain_loss: number; + indoor_loss: number; + atmospheric_loss: number; } export interface ApiCoverageStats { @@ -61,6 +69,9 @@ export interface ApiCoverageStats { points_with_terrain_loss: number; points_with_reflection_gain: number; points_with_vegetation_loss: number; + points_with_rain_loss: number; + points_with_indoor_loss: number; + points_with_atmospheric_loss: number; } export interface CoverageResponse { diff --git a/frontend/src/store/coverage.ts b/frontend/src/store/coverage.ts index 0e54f4e..4f79f34 100644 --- a/frontend/src/store/coverage.ts +++ b/frontend/src/store/coverage.ts @@ -44,6 +44,14 @@ export const useCoverageStore = create((set, get) => ({ use_water_reflection: false, use_vegetation: false, season: 'summer', + // Weather + rain_rate: 0, + // Indoor + indoor_loss_type: 'none', + // Atmospheric + use_atmospheric: false, + temperature_c: 15, + humidity_percent: 50, }, heatmapVisible: true, error: null, @@ -107,6 +115,11 @@ export const useCoverageStore = create((set, get) => ({ use_water_reflection: settings.use_water_reflection, use_vegetation: settings.use_vegetation, season: settings.season, + rain_rate: settings.rain_rate, + indoor_loss_type: settings.indoor_loss_type, + use_atmospheric: settings.use_atmospheric, + temperature_c: settings.temperature_c, + humidity_percent: settings.humidity_percent, }, }); @@ -122,6 +135,9 @@ export const useCoverageStore = create((set, get) => ({ building_loss: p.building_loss, reflection_gain: p.reflection_gain, vegetation_loss: p.vegetation_loss, + rain_loss: p.rain_loss, + indoor_loss: p.indoor_loss, + atmospheric_loss: p.atmospheric_loss, })), calculationTime: response.computation_time, totalPoints: response.count, diff --git a/frontend/src/types/coverage.ts b/frontend/src/types/coverage.ts index 6ee887d..ff5782d 100644 --- a/frontend/src/types/coverage.ts +++ b/frontend/src/types/coverage.ts @@ -10,6 +10,9 @@ export interface CoveragePoint { building_loss?: number; // dB building penetration loss reflection_gain?: number; // dB reflection signal gain vegetation_loss?: number; // dB vegetation attenuation + rain_loss?: number; // dB rain attenuation + indoor_loss?: number; // dB indoor penetration loss + atmospheric_loss?: number; // dB atmospheric absorption } export interface CoverageResult { @@ -31,6 +34,9 @@ export interface CoverageApiStats { points_with_terrain_loss: number; points_with_reflection_gain: number; points_with_vegetation_loss: number; + points_with_rain_loss: number; + points_with_indoor_loss: number; + points_with_atmospheric_loss: number; } export interface CoverageSettings { @@ -50,6 +56,14 @@ export interface CoverageSettings { use_water_reflection?: boolean; use_vegetation?: boolean; season?: 'summer' | 'winter' | 'spring' | 'autumn'; + // Weather + rain_rate?: number; // mm/h (0=none, 5=light, 25=heavy) + // Indoor + indoor_loss_type?: string; // none, light, medium, heavy, vehicle + // Atmospheric + use_atmospheric?: boolean; + temperature_c?: number; + humidity_percent?: number; } export interface GridPoint {