@mytec: iter1.6.1 ready for testing
This commit is contained in:
@@ -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(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
549
RFCP-Iteration-1.6.1-Extra-Factors.md
Normal file
549
RFCP-Iteration-1.6.1-Extra-Factors.md
Normal file
@@ -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
|
||||
<div className="settings-section">
|
||||
<h4>Weather Conditions</h4>
|
||||
|
||||
<select
|
||||
value={settings.rain_rate}
|
||||
onChange={(e) => updateSettings({ rain_rate: Number(e.target.value) })}
|
||||
>
|
||||
<option value={0}>No Rain</option>
|
||||
<option value={2.5}>Drizzle</option>
|
||||
<option value={5}>Light Rain</option>
|
||||
<option value={12.5}>Moderate Rain</option>
|
||||
<option value={25}>Heavy Rain</option>
|
||||
<option value={50}>Very Heavy Rain</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
// Indoor section
|
||||
<div className="settings-section">
|
||||
<h4>Indoor Coverage</h4>
|
||||
|
||||
<select
|
||||
value={settings.indoor_loss_type}
|
||||
onChange={(e) => updateSettings({ indoor_loss_type: e.target.value })}
|
||||
>
|
||||
<option value="none">Outdoor Only</option>
|
||||
<option value="light">Light Building (wood, glass)</option>
|
||||
<option value="medium">Medium Building (brick)</option>
|
||||
<option value="heavy">Heavy Building (concrete)</option>
|
||||
<option value="vehicle">Inside Vehicle</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 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** 🚀
|
||||
@@ -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
|
||||
|
||||
98
backend/app/services/atmospheric_service.py
Normal file
98
backend/app/services/atmospheric_service.py
Normal file
@@ -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()
|
||||
@@ -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(
|
||||
|
||||
82
backend/app/services/indoor_service.py
Normal file
82
backend/app/services/indoor_service.py
Normal file
@@ -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()
|
||||
102
backend/app/services/weather_service.py
Normal file
102
backend/app/services/weather_service.py
Normal file
@@ -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()
|
||||
@@ -803,6 +803,118 @@ export default function App() {
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Atmospheric absorption toggle */}
|
||||
<label
|
||||
className={`flex items-center gap-2 cursor-pointer text-sm ${
|
||||
isCalculating
|
||||
? 'text-gray-400 dark:text-dark-muted cursor-not-allowed'
|
||||
: 'text-gray-700 dark:text-dark-text'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.use_atmospheric ?? false}
|
||||
onChange={(e) => {
|
||||
useCoverageStore.getState().updateSettings({
|
||||
use_atmospheric: e.target.checked,
|
||||
preset: undefined,
|
||||
});
|
||||
}}
|
||||
disabled={isCalculating}
|
||||
className="w-3.5 h-3.5 rounded border-gray-300 dark:border-dark-border accent-blue-600"
|
||||
/>
|
||||
Atmospheric Absorption
|
||||
</label>
|
||||
{settings.use_atmospheric && (
|
||||
<div className="mt-1.5 pl-5 space-y-1.5">
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">Temperature</label>
|
||||
<select
|
||||
value={settings.temperature_c ?? 15}
|
||||
onChange={(e) =>
|
||||
useCoverageStore.getState().updateSettings({
|
||||
temperature_c: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
disabled={isCalculating}
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
|
||||
>
|
||||
<option value={-10}>-10°C (cold)</option>
|
||||
<option value={0}>0°C (freezing)</option>
|
||||
<option value={15}>15°C (mild)</option>
|
||||
<option value={25}>25°C (warm)</option>
|
||||
<option value={35}>35°C (hot)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">Humidity</label>
|
||||
<select
|
||||
value={settings.humidity_percent ?? 50}
|
||||
onChange={(e) =>
|
||||
useCoverageStore.getState().updateSettings({
|
||||
humidity_percent: Number(e.target.value),
|
||||
})
|
||||
}
|
||||
disabled={isCalculating}
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
|
||||
>
|
||||
<option value={20}>20% (dry)</option>
|
||||
<option value={50}>50% (normal)</option>
|
||||
<option value={70}>70% (humid)</option>
|
||||
<option value={90}>90% (very humid)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weather / Rain section */}
|
||||
<div className="mt-2 pt-2 border-t border-gray-200 dark:border-dark-border">
|
||||
<p className="text-[10px] font-semibold text-gray-400 dark:text-dark-muted uppercase mb-1.5">Environment</p>
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">Rain Conditions</label>
|
||||
<select
|
||||
value={settings.rain_rate ?? 0}
|
||||
onChange={(e) =>
|
||||
useCoverageStore.getState().updateSettings({
|
||||
rain_rate: Number(e.target.value),
|
||||
preset: undefined,
|
||||
})
|
||||
}
|
||||
disabled={isCalculating}
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
|
||||
>
|
||||
<option value={0}>No Rain</option>
|
||||
<option value={2.5}>Drizzle</option>
|
||||
<option value={5}>Light Rain</option>
|
||||
<option value={12.5}>Moderate Rain</option>
|
||||
<option value={25}>Heavy Rain</option>
|
||||
<option value={50}>Very Heavy Rain</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Indoor penetration section */}
|
||||
<div className="mt-1.5">
|
||||
<label className="text-xs text-gray-500 dark:text-dark-muted">Indoor Coverage</label>
|
||||
<select
|
||||
value={settings.indoor_loss_type ?? 'none'}
|
||||
onChange={(e) =>
|
||||
useCoverageStore.getState().updateSettings({
|
||||
indoor_loss_type: e.target.value,
|
||||
preset: undefined,
|
||||
})
|
||||
}
|
||||
disabled={isCalculating}
|
||||
className="w-full mt-0.5 px-2 py-1 text-xs bg-white dark:bg-dark-border border border-gray-300 dark:border-dark-border rounded text-gray-700 dark:text-dark-text disabled:opacity-50"
|
||||
>
|
||||
<option value="none">Outdoor Only</option>
|
||||
<option value="light">Light Building (wood, glass)</option>
|
||||
<option value="medium">Medium Building (brick)</option>
|
||||
<option value="heavy">Heavy Building (concrete)</option>
|
||||
<option value="vehicle">Inside Vehicle</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,6 +44,14 @@ export const useCoverageStore = create<CoverageState>((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<CoverageState>((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<CoverageState>((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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user