Skip to content

Commit 75dbd9f

Browse files
Add validated land clutter models
1 parent 9edd904 commit 75dbd9f

8 files changed

Lines changed: 385 additions & 106 deletions

File tree

docs/MODEL_FIDELITY.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ runtime model:
4545
- PRF, pulse width, receiver noise bandwidth, noise figure, system temperature and losses;
4646
- probability of false alarm and number of integrated pulses;
4747
- atmospheric temperature, pressure and water-vapour density;
48-
- clutter enablement, terrain class, sea state and rain rate;
48+
- clutter enablement, terrain class, land model and its calibrated surface parameters, sea
49+
state and rain rate;
4950
- target kinematics, mean RCS, Swerling case and onboard jammer power.
5051

5152
Values omitted by a scenario use documented defaults. A saved run must record both supplied and
@@ -83,15 +84,22 @@ validation, input pedigree, uncertainty characterization, and result robustness.
8384
- V. Gregers-Hansen and R. Mital, "An Improved Empirical Model for Radar Sea Clutter
8485
Reflectivity," IEEE Transactions on Aerospace and Electronic Systems, 2012,
8586
DOI: 10.1109/TAES.2012.6324732.
87+
- Y. Oh, K. Sarabandi, and F. T. Ulaby, "An Empirical Model and an Inversion Technique for
88+
Radar Scattering from Bare Soil Surfaces," IEEE Transactions on Geoscience and Remote
89+
Sensing, 1992, DOI: 10.1109/36.134086.
90+
- J. B. Billingsley, *Low-Angle Radar Land Clutter: Measurements and Empirical Models*,
91+
William Andrew Publishing, 2002.
8692

8793
## Known baseline limitations
8894

8995
- Gaseous attenuation uses the P.676-13 Annex 1 line-by-line equations for a homogeneous
9096
terrestrial path. Slant paths through height-dependent atmospheric profiles are not yet
9197
represented.
92-
- Land-clutter terrain-class coefficients remain an empirical screening model and require
93-
site-specific measurement or calibrated soil/vegetation inputs for quantitative use. Sea clutter
94-
uses the NRL model within its stated frequency, grazing-angle, and sea-state domain.
98+
- The constant-gamma land model is a screening model. Terrain-class gamma values are nominal
99+
priors; quantitative work requires measured or site-calibrated gamma. Oh-1992 is available for
100+
bare soil only within its measured 10-70 degree incidence, L/C/X-band, and 0.1 <= ks <= 6
101+
domain. It must not be substituted for a low-grazing Billingsley model. Sea clutter uses the NRL
102+
model within its stated frequency, grazing-angle, and sea-state domain.
95103
- Scenario-level ECM does not yet model receiver saturation or waveform-dependent processing.
96104
- Pulse-Doppler synthesis starts from a range-compressed representation.
97105
- SAR/ISAR processing is not yet quantitatively validated.

docs/physics.md

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -230,15 +230,39 @@ $$ V_{blind} = n \cdot \frac{\lambda \cdot PRF}{2} $$
230230

231231
## Environmental Clutter
232232

233-
RadarSim models clutter as a statistical process that degrades the effective SNR: $SNR_{eff} = SNR - CNR$.
233+
Surface clutter is formed from normalized backscatter and the common range-antenna resolution
234+
cell:
234235

235-
### Clutter Models
236+
$$\sigma_c = \sigma^0 A_c$$
236237

237-
| Type | Distribution | Typical $\sigma^0$ |
238-
|------|--------------|-------------------|
239-
| **Ground** | Weibull | -15 to -25 dB |
240-
| **Sea** | GIT Model (Douglas Sea State) | -20 to -45 dB |
241-
| **Rain** | Marshall-Palmer | Variable ($Z=200R^{1.6}$) |
238+
Thermal noise and clutter are combined as powers rather than subtracting CNR in decibels:
239+
240+
$$\frac{1}{\mathrm{SINR}} = \frac{1}{\mathrm{SNR}} + \frac{\sigma_c}{\sigma_t}$$
241+
242+
The result record exposes the selected model, $\sigma^0$, cell area, clutter RCS, and resulting
243+
SINR loss.
244+
245+
### Land clutter
246+
247+
The default constant-gamma screening model is
248+
249+
$$\sigma^0 = \gamma\sin\psi$$
250+
251+
where $\psi$ is grazing angle. Terrain-name gamma values are nominal priors and do not encode
252+
site moisture, season, cultivation, frequency, or spatial resolution. A measured `gamma_db`
253+
should be supplied for quantitative studies.
254+
255+
The optional Oh-Sarabandi-Ulaby 1992 model computes HH, VV, and HV normalized backscatter from
256+
complex relative permittivity and RMS surface height. Its enforced domain is bare soil, L/C/X
257+
band, 10-70 degree incidence, and $0.1 \le ks \le 6$. It is not used below that incidence-domain
258+
boundary and is not presented as a low-angle land-clutter model.
259+
260+
### Sea and rain clutter
261+
262+
Sea reflectivity uses the NRL five-parameter model for HH/VV polarization, 0.5-35 GHz,
263+
0.1-60 degree grazing, Douglas sea states 0-6. Rain volume reflectivity uses the
264+
Marshall-Palmer $Z=200R^{1.6}$ relationship. These amplitude models are separate from
265+
ITU-R P.838 path attenuation.
242266

243267
**Implementation:** `src/physics/clutter.py`
244268

@@ -260,6 +284,13 @@ RadarSim models clutter as a statistical process that degrades the effective SNR
260284
4. **Blake, L.V.** (1980). *Radar Range-Performance Analysis*. Artech House.
261285
- 4/3 Earth model origins
262286

287+
5. **Oh, Y., Sarabandi, K., & Ulaby, F.T.** (1992). "An Empirical Model and an
288+
Inversion Technique for Radar Scattering from Bare Soil Surfaces." IEEE TGRS.
289+
DOI: 10.1109/36.134086.
290+
291+
6. **Gregers-Hansen, V., & Mital, R.** (2012). "An Improved Empirical Model for Radar
292+
Sea Clutter Reflectivity." IEEE TAES. DOI: 10.1109/TAES.2012.6324732.
293+
263294
---
264295

265296
*Document generated for RadarSim v1.0.0*

src/io/exporter.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,17 @@ def _extract_environment(engine) -> Dict[str, Any]:
150150
"terrain_type": str(getattr(engine, "terrain_type", "rural")),
151151
"sea_state": int(getattr(engine, "sea_state", 0)),
152152
},
153+
"ground_surface": {
154+
"model": str(getattr(engine, "ground_model", "gamma")),
155+
"gamma_db": getattr(engine, "land_gamma_db", None),
156+
"relative_permittivity_real": float(
157+
getattr(engine, "ground_relative_permittivity", complex(8.0, -0.8)).real
158+
),
159+
"relative_permittivity_loss": float(
160+
-getattr(engine, "ground_relative_permittivity", complex(8.0, -0.8)).imag
161+
),
162+
"rms_height_m": float(getattr(engine, "ground_rms_height_m", 0.01)),
163+
},
153164
}
154165

155166
# ECM settings

src/io/scenario_loader.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,11 @@ class EnvironmentConfig:
9191
terrain_type: str = "rural"
9292
sea_state: int = 0
9393
rain_rate_mm_hr: float = 0.0
94+
ground_model: str = "gamma"
95+
land_gamma_db: Optional[float] = None
96+
ground_relative_permittivity_real: float = 8.0
97+
ground_relative_permittivity_loss: float = 0.8
98+
ground_rms_height_m: float = 0.01
9499

95100
def __post_init__(self) -> None:
96101
if self.pressure_hpa <= 0.0:
@@ -99,6 +104,14 @@ def __post_init__(self) -> None:
99104
raise ValueError("Water-vapor density and rain rate cannot be negative")
100105
if not 0 <= self.sea_state <= 6:
101106
raise ValueError("sea_state must be between 0 and 6")
107+
if self.ground_model not in {"gamma", "oh1992"}:
108+
raise ValueError("ground_model must be 'gamma' or 'oh1992'")
109+
if self.ground_relative_permittivity_real <= 1.0:
110+
raise ValueError("ground_relative_permittivity_real must be greater than one")
111+
if self.ground_relative_permittivity_loss < 0.0:
112+
raise ValueError("ground_relative_permittivity_loss cannot be negative")
113+
if self.ground_rms_height_m <= 0.0:
114+
raise ValueError("ground_rms_height_m must be greater than zero")
102115

103116

104117
@dataclass
@@ -287,14 +300,26 @@ def _parse_targets(self) -> List[TargetConfig]:
287300
def _parse_environment(self) -> EnvironmentConfig:
288301
"""Parse environment configuration."""
289302
env = self.data.get("environment", {})
303+
clutter = env.get("clutter", {})
304+
ground = env.get("ground_surface", {})
305+
gamma_value = ground.get("gamma_db")
290306

291307
return EnvironmentConfig(
292308
temperature_c=float(env.get("temperature_c", 15.0)),
293309
pressure_hpa=float(env.get("pressure_hpa", 1013.25)),
294310
water_vapor_gpm3=float(env.get("water_vapor_gpm3", 7.5)),
295-
terrain_type=str(env.get("terrain_type", "rural")),
296-
sea_state=int(env.get("sea_state", 0)),
311+
terrain_type=str(env.get("terrain_type", clutter.get("terrain_type", "rural"))),
312+
sea_state=int(env.get("sea_state", clutter.get("sea_state", 0))),
297313
rain_rate_mm_hr=float(env.get("rain_rate_mm_hr", 0.0)),
314+
ground_model=str(ground.get("model", "gamma")).lower(),
315+
land_gamma_db=float(gamma_value) if gamma_value is not None else None,
316+
ground_relative_permittivity_real=float(
317+
ground.get("relative_permittivity_real", 8.0)
318+
),
319+
ground_relative_permittivity_loss=float(
320+
ground.get("relative_permittivity_loss", 0.8)
321+
),
322+
ground_rms_height_m=float(ground.get("rms_height_m", 0.01)),
298323
)
299324

300325
def get_config(self) -> Optional[SimulationConfig]:
@@ -411,6 +436,13 @@ def create_simulation_engine(self):
411436
terrain_type=self._config.environment.terrain_type,
412437
sea_state=self._config.environment.sea_state,
413438
rain_rate_mm_hr=self._config.environment.rain_rate_mm_hr,
439+
ground_model=self._config.environment.ground_model,
440+
land_gamma_db=self._config.environment.land_gamma_db,
441+
ground_relative_permittivity=complex(
442+
self._config.environment.ground_relative_permittivity_real,
443+
-self._config.environment.ground_relative_permittivity_loss,
444+
),
445+
ground_rms_height_m=self._config.environment.ground_rms_height_m,
414446
)
415447

416448
return engine

src/physics/clutter.py

Lines changed: 74 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"""
1616

1717
from enum import Enum
18-
from typing import Dict, Tuple
18+
from typing import Dict, Optional
1919

2020
import numba
2121
import numpy as np
@@ -46,17 +46,13 @@ class SeaState(Enum):
4646
HIGH = 6 # Very large waves
4747

4848

49-
# Terrain parameters: (A, B) for σ0 = A + B*sin(ψ) [dB]
50-
# Reference: Nathanson, "Radar Design Principles", Table 7.1
51-
TERRAIN_PARAMETERS: Dict[str, Tuple[float, float]] = {
52-
"urban": (-15, 15),
53-
"suburban": (-20, 12),
54-
"rural": (-25, 10),
55-
"forest": (-20, 12),
56-
"desert": (-30, 8),
57-
"mountains": (-18, 14),
58-
"sea_calm": (-40, 5),
59-
"sea_rough": (-25, 12),
49+
LAND_GAMMA_PRIORS_DB: Dict[str, float] = {
50+
"urban": -5.0,
51+
"suburban": -12.0,
52+
"rural": -20.0,
53+
"forest": -15.0,
54+
"desert": -30.0,
55+
"mountains": -8.0,
6056
}
6157

6258

@@ -130,11 +126,12 @@ def ground_clutter_sigma0(
130126
terrain_type: str = "rural",
131127
frequency_ghz: float = 10.0,
132128
polarization: str = "HH",
129+
gamma_db: Optional[float] = None,
133130
) -> float:
134131
"""
135132
Ground clutter backscatter coefficient (σ0).
136133
137-
Uses empirical model: σ0 = A + B*sin(ψ) [dB]
134+
Uses the constant-gamma engineering model σ0 = γ sin(ψ).
138135
139136
Args:
140137
grazing_angle_rad: Grazing angle [rad]
@@ -145,24 +142,71 @@ def ground_clutter_sigma0(
145142
Returns:
146143
σ0 in dB (dB relative to 1 m²/m²)
147144
148-
Reference: Nathanson, "Radar Design Principles", Table 7.1
145+
Terrain-name values are nominal priors, not site calibration. Pass gamma_db from
146+
measured clutter whenever quantitative accuracy is required.
149147
"""
150-
sin_psi = np.sin(grazing_angle_rad)
151-
152-
# Get terrain parameters
153-
A, B = TERRAIN_PARAMETERS.get(terrain_type, (-25, 10))
154-
155-
sigma0_db = A + B * sin_psi
156-
157-
# Frequency adjustment (σ0 increases ~3 dB per octave above X-band)
158-
if frequency_ghz > 10:
159-
sigma0_db += 3 * np.log2(frequency_ghz / 10)
160-
161-
# VV polarization typically 2-4 dB higher than HH
162-
if polarization == "VV":
163-
sigma0_db += 2.5
148+
if not 0.0 < grazing_angle_rad <= np.pi / 2.0:
149+
raise ValueError("grazing_angle_rad must be between 0 and pi/2")
150+
if frequency_ghz <= 0.0:
151+
raise ValueError("frequency_ghz must be positive")
152+
if polarization.upper() not in {"HH", "VV"}:
153+
raise ValueError("polarization must be 'HH' or 'VV'")
154+
if gamma_db is None:
155+
try:
156+
gamma_db = LAND_GAMMA_PRIORS_DB[terrain_type.lower()]
157+
except KeyError as error:
158+
raise ValueError(f"unknown terrain type: {terrain_type}") from error
159+
return float(gamma_db + 10.0 * np.log10(np.sin(grazing_angle_rad)))
164160

165-
return sigma0_db
161+
@staticmethod
162+
def bare_soil_oh1992_sigma0(
163+
grazing_angle_rad: float,
164+
frequency_ghz: float,
165+
relative_permittivity: complex,
166+
rms_height_m: float,
167+
polarization: str = "HH",
168+
) -> float:
169+
"""Oh-Sarabandi-Ulaby (1992) bare-soil normalized backscatter."""
170+
incidence = np.pi / 2.0 - grazing_angle_rad
171+
incidence_deg = float(np.degrees(incidence))
172+
if not 10.0 <= incidence_deg <= 70.0:
173+
raise ValueError("Oh-1992 model requires 10-70 degree incidence")
174+
if not 1.0 <= frequency_ghz <= 10.0:
175+
raise ValueError("Oh-1992 measurement domain is L-, C-, and X-band")
176+
if relative_permittivity.real <= 1.0 or relative_permittivity.imag > 0.0:
177+
raise ValueError("permittivity must use the passive convention eps'-j eps''")
178+
if rms_height_m <= 0.0:
179+
raise ValueError("rms_height_m must be positive")
180+
181+
wavelength = SPEED_OF_LIGHT / (frequency_ghz * 1e9)
182+
ks = 2.0 * np.pi / wavelength * rms_height_m
183+
if not 0.1 <= ks <= 6.0:
184+
raise ValueError("Oh-1992 model requires 0.1 <= k*s <= 6")
185+
186+
root_eps = np.sqrt(relative_permittivity)
187+
gamma_0 = abs((1.0 - root_eps) / (1.0 + root_eps)) ** 2
188+
root_term = np.sqrt(relative_permittivity - np.sin(incidence) ** 2)
189+
r_h = (np.cos(incidence) - root_term) / (np.cos(incidence) + root_term)
190+
r_v = (
191+
relative_permittivity * np.cos(incidence) - root_term
192+
) / (relative_permittivity * np.cos(incidence) + root_term)
193+
gamma_h = abs(r_h) ** 2
194+
gamma_v = abs(r_v) ** 2
195+
196+
sqrt_p = 1.0 - (2.0 * incidence / np.pi) ** (1.0 / (3.0 * gamma_0)) * np.exp(
197+
-ks
198+
)
199+
if sqrt_p <= 0.0:
200+
raise ValueError("Oh-1992 co-polarization ratio is outside its physical domain")
201+
p = sqrt_p**2
202+
q = 0.23 * np.sqrt(gamma_0) * (1.0 - np.exp(-ks))
203+
g = 0.7 * (1.0 - np.exp(-0.65 * ks**1.8))
204+
sigma_vv = g * np.cos(incidence) ** 3 * (gamma_v + gamma_h) / sqrt_p
205+
sigma = {"VV": sigma_vv, "HH": p * sigma_vv, "HV": q * sigma_vv}
206+
try:
207+
return float(10.0 * np.log10(sigma[polarization.upper()]))
208+
except KeyError as error:
209+
raise ValueError("polarization must be HH, VV, or HV") from error
166210

167211
@staticmethod
168212
def sea_clutter_sigma0(
@@ -409,9 +453,8 @@ def generate_clutter_map(
409453
Returns:
410454
2D array of clutter power [linear]
411455
"""
412-
# Create range and azimuth arrays
456+
# Create range array
413457
ranges = np.linspace(100, max_range_m, range_bins)
414-
azimuths = np.linspace(0, 2 * np.pi, azimuth_bins)
415458

416459
# Calculate grazing angles
417460
clutter_map = np.zeros((range_bins, azimuth_bins))

0 commit comments

Comments
 (0)