1515"""
1616
1717from enum import Enum
18- from typing import Dict , Tuple
18+ from typing import Dict , Optional
1919
2020import numba
2121import 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