├── src └── ewgeo │ ├── noise │ ├── __init__.py │ └── model.py │ ├── prop │ ├── __init__.py │ └── model.py │ ├── detector │ ├── __init__.py │ ├── xcorr.py │ └── squareLaw.py │ ├── array_df │ ├── __init__.py │ ├── perf.py │ ├── model.py │ └── solvers.py │ ├── atm │ ├── __init__.py │ └── test.py │ ├── triang │ ├── __init__.py │ └── perf.py │ ├── fdoa │ ├── __init__.py │ ├── perf.py │ └── solvers.py │ ├── tdoa │ ├── __init__.py │ ├── perf.py │ └── system.py │ ├── hybrid │ ├── __init__.py │ └── perf.py │ ├── aoa │ ├── __init__.py │ ├── aoa.py │ ├── watson_watt.py │ └── interferometer.py │ └── utils │ ├── __init__.py │ ├── constants.py │ ├── perf.py │ ├── unit_conversions.py │ ├── tracker.py │ └── geo.py ├── .gitignore ├── graphics ├── cover_emitterDet.png └── cover_practicalGeo.png ├── examples ├── practical_geo │ └── __init__.py ├── __init__.py ├── chapter2.py ├── chapter9.py └── chapter5.py ├── make_figures ├── practical_geo │ ├── __init__.py │ ├── chapter1.py │ ├── chapter8.py │ ├── chapter4.py │ ├── chapter2.py │ └── chapter3.py ├── __init__.py ├── chapter5.py ├── appendixB.py ├── chapter9.py ├── appendixD.py ├── chapter6.py ├── chapter1.py └── appendixC.py ├── pyproject.toml ├── LICENSE ├── README.md └── make_figures.py /src/ewgeo/noise/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | -------------------------------------------------------------------------------- /src/ewgeo/prop/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *__pycache__* 2 | *.idea* 3 | figures 4 | *.mat 5 | *.egg-info -------------------------------------------------------------------------------- /src/ewgeo/detector/__init__.py: -------------------------------------------------------------------------------- 1 | from . import squareLaw 2 | from . import xcorr 3 | -------------------------------------------------------------------------------- /src/ewgeo/array_df/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | from . import perf 3 | from . import solvers 4 | -------------------------------------------------------------------------------- /src/ewgeo/atm/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | from . import reference 3 | from . import test 4 | -------------------------------------------------------------------------------- /graphics/cover_emitterDet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodonoughue/emitter-detection-python/HEAD/graphics/cover_emitterDet.png -------------------------------------------------------------------------------- /graphics/cover_practicalGeo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nodonoughue/emitter-detection-python/HEAD/graphics/cover_practicalGeo.png -------------------------------------------------------------------------------- /src/ewgeo/triang/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | from . import perf 3 | from . import solvers 4 | from .system import DirectionFinder -------------------------------------------------------------------------------- /src/ewgeo/fdoa/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | from . import perf 3 | from . import solvers 4 | from .system import FDOAPassiveSurveillanceSystem -------------------------------------------------------------------------------- /src/ewgeo/tdoa/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | from . import perf 3 | from . import solvers 4 | from .system import TDOAPassiveSurveillanceSystem -------------------------------------------------------------------------------- /src/ewgeo/hybrid/__init__.py: -------------------------------------------------------------------------------- 1 | from . import model 2 | from . import perf 3 | from . import solvers 4 | from .system import HybridPassiveSurveillanceSystem 5 | -------------------------------------------------------------------------------- /src/ewgeo/aoa/__init__.py: -------------------------------------------------------------------------------- 1 | from .aoa import * 2 | from . import directional 3 | from . import doppler 4 | from . import interferometer 5 | from . import watson_watt 6 | -------------------------------------------------------------------------------- /examples/practical_geo/__init__.py: -------------------------------------------------------------------------------- 1 | from . import chapter2 2 | from . import chapter3 3 | from . import chapter4 4 | from . import chapter5 5 | from . import chapter6 6 | from . import chapter7 7 | from . import chapter8 8 | -------------------------------------------------------------------------------- /make_figures/practical_geo/__init__.py: -------------------------------------------------------------------------------- 1 | from . import chapter1 2 | from . import chapter2 3 | from . import chapter3 4 | from . import chapter4 5 | from . import chapter5 6 | from . import chapter6 7 | from . import chapter7 8 | from . import chapter8 9 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | from . import chapter2 2 | from . import chapter3 3 | from . import chapter4 4 | from . import chapter5 5 | from . import chapter7 6 | from . import chapter8 7 | from . import chapter9 8 | from . import chapter10 9 | from . import chapter11 10 | from . import chapter12 11 | from . import chapter13 12 | -------------------------------------------------------------------------------- /src/ewgeo/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .utils import * 2 | from . import constants 3 | from . import errors 4 | from . import geo 5 | from . import solvers 6 | from . import perf 7 | from . import unit_conversions 8 | from . import coordinates 9 | from . import constraints 10 | from . import system 11 | from . import tracker 12 | -------------------------------------------------------------------------------- /make_figures/__init__.py: -------------------------------------------------------------------------------- 1 | from . import practical_geo 2 | from . import chapter1 3 | from . import chapter2 4 | from . import chapter3 5 | from . import chapter4 6 | from . import chapter5 7 | from . import chapter6 8 | from . import chapter7 9 | from . import chapter8 10 | from . import chapter9 11 | from . import chapter10 12 | from . import chapter11 13 | from . import chapter12 14 | from . import chapter13 15 | from . import appendixB 16 | from . import appendixC 17 | from . import appendixD 18 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 77.0.3"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ewgeo" 7 | version = "0.0.0" 8 | authors = [ 9 | { name="Nicholas O'Donoughue", email="nodonoug@rand.org" }, 10 | ] 11 | description = "Python code companion to Emitter Detection and Geolocation for Electronic Warfare (Artech House, 2019)" 12 | readme = "README.md" 13 | requires-python = ">= 3.12" 14 | dependencies = [ 15 | "matplotlib", 16 | "numpy", 17 | "scipy", 18 | "seaborn", 19 | ] 20 | license = "MIT" 21 | license-files = ["LICEN[CS]E*"] 22 | 23 | [tool.setuptools] 24 | package-dir = {"" = "src"} 25 | 26 | [tool.setuptools.packages.find] 27 | where = ["src"] -------------------------------------------------------------------------------- /src/ewgeo/utils/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Define a set of universal constants that may be useful 3 | """ 4 | 5 | # Boltzmann's Constant 6 | boltzmann = 1.3806e-23 7 | 8 | # Reference Temperature (290 K), used for background noise level 9 | ref_temp = 290. 10 | kT = 4.00374e-21 # boltzmann * T0 11 | 12 | # Speed of Light 13 | speed_of_light = 299792458. 14 | 15 | # Radius of the earth; with a (4/3) constant applied to account for 16 | # electromagnetic propagation around the Earth 17 | radius_earth_true = 6371000. 18 | radius_earth_eff = radius_earth_true*4./3. 19 | 20 | # Elliptical Earth parameters 21 | semimajor_axis_km = 6378.137 22 | semiminor_axis_km = 6356.752314245 23 | first_ecc_sq = 6.69438002290e-3 24 | second_ecc_sq = 6.73949677548e-3 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Nicholas A O'Donoughue 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /examples/chapter2.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | from scipy.special import erf, erfinv 4 | 5 | 6 | def run_all_examples(): 7 | """ 8 | Run all chapter 2 examples and return a list of figure handles 9 | 10 | :return figs: list of figure handles 11 | """ 12 | 13 | return [example2()] 14 | 15 | 16 | def example2(): 17 | """ 18 | Executes Example 2.2. 19 | 20 | Ported from MATLAB Code 21 | 22 | Nicholas O'Donoughue 23 | 23 March 2021 24 | 25 | :return: figure handle to generated graphic 26 | """ 27 | 28 | # Set up PFA and SNR vectors 29 | prob_fa = np.expand_dims(np.linspace(start=1.0e-9, stop=1, num=1001), axis=1) 30 | d2_vec = np.expand_dims(np.array([1e-6, 1, 5, 10]), axis=0) # Use 1e-6 instead of 0, to avoid NaN 31 | 32 | # Compute the threshold eta using MATLAB's built-in error function erf(x) 33 | # and inverse error function erfinv(x). 34 | eta = np.sqrt(2*d2_vec) * erfinv(1-2*prob_fa)-d2_vec/2 35 | 36 | # Compute the probability of detection 37 | prob_det = .5*(1-erf((eta-d2_vec/2)/np.sqrt(2*d2_vec))) 38 | 39 | # Plot the ROC curve 40 | fig = plt.figure() 41 | for idx, d2 in enumerate(d2_vec[0, :]): 42 | plt.plot(prob_fa, prob_det[:, idx], label='$d^2$ = {:.0f}'.format(d2)) 43 | 44 | # Axes Labels 45 | plt.ylabel('$P_D$') 46 | plt.xlabel('$P_{FA}$') 47 | 48 | # Legend 49 | plt.legend(loc='lower right') 50 | 51 | # Align the axes 52 | plt.xlim([0, 1]) 53 | plt.ylim([0, 1]) 54 | 55 | return fig 56 | 57 | 58 | if __name__ == '__main__': 59 | run_all_examples() 60 | plt.show() 61 | -------------------------------------------------------------------------------- /src/ewgeo/aoa/aoa.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ewgeo.utils import sinc_derivative 4 | 5 | 6 | def make_gain_functions(aperture_type, d_lam, psi_0): 7 | """ 8 | Generate function handles for the gain pattern (g) and gradient (g_dot), 9 | given the specified aperture type, aperture size, and mechanical steering 10 | angle. 11 | 12 | Ported from MATLAB Code 13 | 14 | Nicholas O'Donoughue 15 | 9 January 2021 16 | 17 | :param aperture_type: String indicating the type of aperture requested. Supports 'omni', 'adcock', 'rectangular' 18 | :param d_lam: Aperture length, in units of wavelength (d/lambda) 19 | :param psi_0: Mechanical steering angle (in radians) of the array [default=0] 20 | :return g: Function handle to the antenna pattern g(psi), for psi in radians 21 | :return g_dot: Function handle to the gradient of the antenna pattern, g_dot(psi), for psi in radians. 22 | """ 23 | 24 | # type = 'Adcock' or 'Rectangular' 25 | # params 26 | # d_lam = baseline (in wavelengths) 27 | # psi_0 = central angle 28 | 29 | # Define all the possible functions 30 | def g_omni(_): 31 | return 1. 32 | 33 | def g_dot_omni(_): 34 | return 0. 35 | 36 | def g_adcock(psi): 37 | return 2*np.sin(np.pi * d_lam * np.cos(psi-psi_0)) 38 | 39 | def g_dot_adcock(psi): 40 | return -2*np.pi*d_lam*np.sin(psi-psi_0)*np.cos(np.pi*d_lam*np.cos(psi-psi_0)) 41 | 42 | def g_rect(psi): 43 | return np.abs(np.sinc((psi-psi_0)*d_lam/np.pi)) # sinc includes implicit pi 44 | 45 | def g_dot_rect(psi): 46 | return sinc_derivative((psi - psi_0) * d_lam) * d_lam 47 | 48 | switcher = {'omni': (g_omni, g_dot_omni), 49 | 'adcock': (g_adcock, g_dot_adcock), 50 | 'rectangular': (g_rect, g_dot_rect)} 51 | 52 | result = switcher.get(aperture_type.lower()) 53 | 54 | return result[0], result[1] 55 | -------------------------------------------------------------------------------- /src/ewgeo/triang/perf.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import model 4 | from ewgeo.utils import safe_2d_shape 5 | from ewgeo.utils.covariance import CovarianceMatrix 6 | from ewgeo.utils.perf import compute_crlb_gaussian 7 | 8 | 9 | def compute_crlb(x_sensor, x_source, cov: CovarianceMatrix, do_2d_aoa=False, print_progress=False, **kwargs): 10 | """ 11 | Computes the CRLB on position accuracy for source at location xs and 12 | sensors at locations in x_aoa (Ndim x N). C is an NxN matrix of TOA 13 | covariances at each of the N sensors. 14 | 15 | Ported from MATLAB Code 16 | 17 | Nicholas O'Donoughue 18 | 22 February 2021 19 | 20 | :param x_sensor: (Ndim x N) array of AOA sensor positions 21 | :param x_source: (Ndim x M) array of source positions over which to calculate CRLB 22 | :param cov: AOA measurement error covariance matrix; object of the CovarianceMatrix class 23 | :param do_2d_aoa: Optional boolean parameter specifying whether 1D (az-only) or 2D (az/el) AOA is being performed 24 | :param print_progress: Boolean flag, if true then progress updates and elapsed/remaining time will be printed to 25 | the console. [default=False] 26 | :return crlb: Lower bound on the error covariance matrix for an unbiased FDOA estimator (Ndim x Ndim) 27 | """ 28 | 29 | # Parse inputs 30 | num_dimension, num_sensors = safe_2d_shape(x_sensor) 31 | num_dimension2, num_sources = safe_2d_shape(x_source) 32 | 33 | assert num_dimension == num_dimension2, "Sensor and Target positions must have the same number of dimensions" 34 | 35 | # Make sure that xs is a 2d array 36 | if len(np.shape(x_source)) == 1: 37 | x_source = np.expand_dims(x_source, axis=1) 38 | 39 | # Define a wrapper for the jacobian matrix that accepts only the position 'x' 40 | def jacobian(x): 41 | return model.jacobian(x_sensor=x_sensor, x_source=x, do_2d_aoa=do_2d_aoa) 42 | 43 | crlb = compute_crlb_gaussian(x_source=x_source, jacobian=jacobian, cov=cov, 44 | print_progress=print_progress, **kwargs) 45 | 46 | return crlb 47 | -------------------------------------------------------------------------------- /src/ewgeo/tdoa/perf.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import model 4 | from ewgeo.utils import safe_2d_shape 5 | from ewgeo.utils.constants import speed_of_light 6 | from ewgeo.utils.covariance import CovarianceMatrix 7 | from ewgeo.utils.perf import compute_crlb_gaussian 8 | 9 | 10 | def compute_crlb(x_sensor, x_source, cov: CovarianceMatrix, ref_idx=None, do_resample=True, variance_is_toa=True, 11 | print_progress=False, **kwargs): 12 | """ 13 | Computes the CRLB on position accuracy for source at location xs and 14 | sensors at locations in x_tdoa (Ndim x N). 15 | 16 | Ported from MATLAB Code 17 | 18 | Nicholas O'Donoughue 19 | 21 February 2021 20 | 21 | :param x_sensor: (Ndim x N) array of TDOA sensor positions 22 | :param x_source: (Ndim x M) array of source positions over which to calculate CRLB 23 | :param cov: TDOA measurement error covariance matrix; object of the CovarianceMatrix class 24 | :param ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings 25 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx 26 | :param variance_is_toa: Boolean flag; if true then the input covariance matrix is in units of s^2; if false, then 27 | it is in m^2 28 | :param print_progress: Boolean flag, if true then progress updates and elapsed/remaining time will be printed to 29 | the console. [default=False] 30 | :return crlb: Lower bound on the error covariance matrix for an unbiased FDOA estimator (Ndim x Ndim) 31 | """ 32 | 33 | # Parse inputs 34 | _, n_source = safe_2d_shape(x_source) 35 | 36 | # Make sure that xs is 2D 37 | if n_source == 1: 38 | x_source = x_source[:, np.newaxis] 39 | 40 | if variance_is_toa: 41 | # Multiply by the speed of light squared, unless it is inverted (then divide) 42 | cov = cov.multiply(speed_of_light ** 2, overwrite=False) 43 | 44 | if do_resample: 45 | cov = cov.resample(ref_idx=ref_idx) 46 | 47 | # Define a wrapper for the jacobian matrix that accepts only the position 'x' 48 | def jacobian(x): 49 | return model.jacobian(x_sensor=x_sensor, x_source=x, ref_idx=ref_idx) 50 | 51 | crlb = compute_crlb_gaussian(x_source=x_source, jacobian=jacobian, cov=cov, 52 | print_progress=print_progress, **kwargs) 53 | 54 | return crlb 55 | -------------------------------------------------------------------------------- /src/ewgeo/hybrid/perf.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import model 4 | from ewgeo.utils import safe_2d_shape 5 | from ewgeo.utils.covariance import CovarianceMatrix 6 | from ewgeo.utils.perf import compute_crlb_gaussian 7 | 8 | 9 | def compute_crlb(x_source, cov: CovarianceMatrix, x_aoa=None, x_tdoa=None, x_fdoa=None, v_fdoa=None, 10 | do_2d_aoa=False, tdoa_ref_idx=None, fdoa_ref_idx=None, do_resample=False, 11 | print_progress=False, **kwargs): 12 | """ 13 | Computes the CRLB on position accuracy for source at location xs and 14 | a combined set of AOA, TDOA, and FDOA measurements. The covariance 15 | matrix C dictates the combined variances across the three measurement 16 | types. 17 | 18 | Ported from MATLAB Code 19 | 20 | Nicholas O'Donoughue 21 | 10 March 2021 22 | 23 | :param x_source: Candidate source positions 24 | :param cov: Measurement error covariance matrix 25 | :param x_aoa: nDim x nAOA array of sensor positions 26 | :param x_tdoa: nDim x nTDOA array of TDOA sensor positions 27 | :param x_fdoa: nDim x nFDOA array of FDOA sensor positions 28 | :param v_fdoa: nDim x nFDOA array of FDOA sensor velocities 29 | :param do_2d_aoa: Optional boolean parameter specifying whether 1D (az-only) or 2D (az/el) AOA is being performed 30 | :param tdoa_ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings for TDOA 31 | :param fdoa_ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings for FDOA 32 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx [default=False] 33 | :param print_progress: Boolean flag, if true then progress updates and elapsed/remaining time will be printed to 34 | the console. [default=False] 35 | :return crlb: Lower bound on the error covariance matrix for an unbiased AOA/TDOA/FDOA estimator (Ndim x Ndim) 36 | """ 37 | 38 | n_dim, n_source = safe_2d_shape(x_source) 39 | 40 | if n_source == 1: 41 | # Make sure it's got a second dimension, so that it doesn't fail when we iterate over source positions 42 | x_source = x_source[:, np.newaxis] 43 | 44 | if do_resample: 45 | cov = cov.resample_hybrid(x_aoa=x_aoa, x_tdoa=x_tdoa, x_fdoa=x_fdoa, do_2d_aoa=do_2d_aoa, 46 | tdoa_ref_idx=tdoa_ref_idx, fdoa_ref_idx=fdoa_ref_idx) 47 | 48 | # Define a wrapper for the jacobian matrix that accepts only the position 'x' 49 | def jacobian(x): 50 | j = model.jacobian(x_aoa=x_aoa, x_tdoa=x_tdoa, x_fdoa=x_fdoa, v_fdoa=v_fdoa, 51 | x_source=x, do_2d_aoa=do_2d_aoa, 52 | tdoa_ref_idx=tdoa_ref_idx, fdoa_ref_idx=fdoa_ref_idx) 53 | return j[:n_dim] # just return the jacobian w.r.t. source position 54 | 55 | crlb = compute_crlb_gaussian(x_source=x_source, jacobian=jacobian, cov=cov, 56 | print_progress=print_progress, **kwargs) 57 | 58 | return crlb 59 | -------------------------------------------------------------------------------- /make_figures/chapter5.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 5 3 | 4 | This script generates all the figures that appear in Chapter 5 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 25 March 2021 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | 14 | from ewgeo.utils import init_output_dir, init_plot_style 15 | from examples import chapter5 16 | 17 | 18 | def make_all_figures(close_figs=False): 19 | """ 20 | Call all the figure generators for this chapter 21 | 22 | :close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 23 | Default=False 24 | :return: List of figure handles 25 | """ 26 | 27 | # Find the output directory 28 | prefix = init_output_dir('chapter5') 29 | init_plot_style() 30 | 31 | # Generate all figures 32 | fig4 = make_figure_4(prefix) 33 | fig6 = make_figure_6(prefix) 34 | fig7 = make_figure_7(prefix) 35 | 36 | figs = [fig4, fig6, fig7] 37 | 38 | if close_figs: 39 | for fig in figs: 40 | plt.close(fig) 41 | 42 | return None 43 | else: 44 | plt.show() 45 | 46 | return figs 47 | 48 | 49 | def make_figure_4(prefix=None): 50 | """ 51 | Figure 4 - Example 5.1 - Super-heterodyne Performance 52 | 53 | Ported from MATLAB Code 54 | 55 | Nicholas O'Donoughue 56 | 25 March 2021 57 | 58 | :param prefix: output directory to place generated figure 59 | :return: figure handle 60 | """ 61 | 62 | print('Generating Figure 5.4 (using Example 5.1)...') 63 | 64 | fig4 = chapter5.example1() 65 | 66 | # Save figure 67 | if prefix is not None: 68 | fig4.savefig(prefix + 'fig4.svg') 69 | fig4.savefig(prefix + 'fig4.png') 70 | 71 | return fig4 72 | 73 | 74 | def make_figure_6(prefix=None): 75 | """ 76 | Figure 6 - Example 5.2 - FMCW Radar 77 | 78 | Ported from MATLAB Code 79 | 80 | Nicholas O'Donoughue 81 | 25 March 2021 82 | 83 | :param prefix: output directory to place generated figure 84 | :return: figure handle 85 | """ 86 | 87 | print('Generating Figure 5.6 (using Example 5.2)...') 88 | 89 | fig6 = chapter5.example2() 90 | 91 | # Save figure 92 | if prefix is not None: 93 | fig6.savefig(prefix + 'fig6.svg') 94 | fig6.savefig(prefix + 'fig6.png') 95 | 96 | return fig6 97 | 98 | 99 | def make_figure_7(prefix=None): 100 | """ 101 | Figure 7 - Example 5.3 - Pulsed Radar 102 | 103 | Ported from MATLAB Code 104 | 105 | Nicholas O'Donoughue 106 | 25 March 2021 107 | 108 | :param prefix: output directory to place generated figure 109 | :return: figure handle 110 | """ 111 | 112 | print('Generating Figure 5.7 (using Example 5.3)...') 113 | 114 | fig7 = chapter5.example3() 115 | 116 | # Save figure 117 | if prefix is not None: 118 | fig7.savefig(prefix + 'fig7.svg') 119 | fig7.savefig(prefix + 'fig7.png') 120 | 121 | return fig7 122 | 123 | 124 | if __name__ == "__main__": 125 | make_all_figures(close_figs=False) 126 | -------------------------------------------------------------------------------- /make_figures/practical_geo/chapter1.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 1 3 | 4 | This script generates all the figures that appear in Chapter 1 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 21 March 2021 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | 14 | from ewgeo.utils import init_output_dir, init_plot_style 15 | 16 | from make_figures import chapter10 17 | from make_figures import chapter11 18 | from make_figures import chapter12 19 | 20 | 21 | def make_all_figures(close_figs=False): 22 | """ 23 | Call all the figure generators for this chapter 24 | 25 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 26 | Default=False 27 | :return: List of figure handles 28 | """ 29 | 30 | # Find the output directory 31 | prefix = init_output_dir('practical_geo/chapter1') 32 | init_plot_style() 33 | 34 | # Generate all figures 35 | fig2a = make_figure_2a(prefix) 36 | fig2b = make_figure_2b(prefix) 37 | fig2c = make_figure_2c(prefix) 38 | 39 | figs = [fig2a, fig2b, fig2c] 40 | if close_figs: 41 | for fig in figs: 42 | plt.close(fig) 43 | 44 | return None 45 | else: 46 | plt.show() 47 | 48 | return figs 49 | 50 | 51 | def make_figure_2a(prefix=None): 52 | """ 53 | Figure 2a, Triangulation Example. A reprint of Figure 10.1 from the 2019 text. 54 | 55 | :param prefix: output directory 56 | :return: figure handle 57 | """ 58 | 59 | print('Generating Figure 1.2a...') 60 | 61 | fig2a = chapter10.make_figure_1(prefix=None) # use prefix=None to suppress the figure export command 62 | 63 | # Display the plot 64 | plt.draw() 65 | 66 | # Output to file 67 | if prefix is not None: 68 | fig2a.savefig(prefix + 'fig2a.svg') 69 | fig2a.savefig(prefix + 'fig2a.png') 70 | 71 | return fig2a 72 | 73 | 74 | def make_figure_2b(prefix=None): 75 | """ 76 | Figure 2b, TDOA Example. A reprint of Figure 11.1b from the 2019 text. 77 | 78 | :param prefix: output directory 79 | :return: figure handle 80 | """ 81 | 82 | print('Generating Figure 1.2b...') 83 | 84 | _, fig2b = chapter11.make_figure_1(prefix=None, do_uncertainty=True) 85 | 86 | # Display the plot 87 | plt.draw() 88 | 89 | # Output to file 90 | if prefix is not None: 91 | fig2b.savefig(prefix + 'fig2b.svg') 92 | fig2b.savefig(prefix + 'fig2b.png') 93 | 94 | return fig2b 95 | 96 | 97 | def make_figure_2c(prefix=None): 98 | """ 99 | Figure 2c, FDOA Example 100 | 101 | :param prefix: output directory 102 | :return: figure handle 103 | """ 104 | 105 | print('Generating Figure 1.2c...') 106 | 107 | fig2c = chapter12.make_figure_1(prefix=None, do_uncertainty=True) 108 | 109 | # Display the plot 110 | plt.draw() 111 | 112 | # Output to file 113 | if prefix is not None: 114 | fig2c.savefig(prefix + 'fig2c.svg') 115 | fig2c.savefig(prefix + 'fig2c.png') 116 | 117 | return fig2c 118 | 119 | 120 | if __name__ == "__main__": 121 | make_all_figures(close_figs=False) 122 | -------------------------------------------------------------------------------- /examples/chapter9.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | 4 | from ewgeo.utils.errors import compute_cep50, draw_cep50, draw_error_ellipse 5 | 6 | 7 | def run_all_examples(): 8 | """ 9 | Run all chapter 9 examples and return a list of figure handles 10 | 11 | :return figs: list of figure handles 12 | """ 13 | 14 | fig1 = example1() 15 | fig2 = example2() 16 | 17 | return [fig1, fig2] 18 | 19 | 20 | def example1(): 21 | """ 22 | Executes Example 9.1 and generates one figure 23 | 24 | Ported from MATLAB Code 25 | 26 | Nicholas O'Donoughue 27 | 17 May 2021 28 | 29 | :return fig: figure handle to generated graphic 30 | """ 31 | 32 | # Set up error covariance matrix 33 | covariance = np.array([[10, -3], [-3, 5]]) 34 | eigenvalues, eigenvectors = np.linalg.eigh(covariance) 35 | sort_index = np.argsort(eigenvalues) 36 | 37 | v_max = eigenvectors[:, sort_index[-1]] 38 | # v_min = eigenvectors[:, sort_index[0]] -- not used 39 | lam_max = eigenvalues[sort_index[-1]] 40 | lam_min = eigenvalues[sort_index[0]] 41 | 42 | gamma = 4.601 # 90% confidence interval 43 | a = np.sqrt(gamma*lam_max) 44 | b = np.sqrt(gamma*lam_min) 45 | 46 | num_plot_points = 101 47 | th = np.linspace(0, 2*np.pi, num_plot_points) 48 | x1 = a * np.cos(th) 49 | x2 = b * np.sin(th) 50 | 51 | alpha = np.arctan2(v_max[1], v_max[0]) 52 | x = x1 * np.cos(alpha) - x2 * np.sin(alpha) 53 | y = x1 * np.sin(alpha) + x2 * np.cos(alpha) 54 | 55 | fig = plt.figure() 56 | plt.scatter(0, 0, marker='+', label='Bias Point') 57 | plt.text(-2.25, .25, 'Bias Point') 58 | plt.text(3.5, 3, '90% Error Ellipse') 59 | plt.plot(x, y, linestyle='-', label='Error Ellipse') 60 | 61 | # Draw the semi-minor and semi-major axes 62 | plt.plot([0, -a*np.cos(alpha)], [0, -a*np.sin(alpha)], color='k', linestyle='--') 63 | plt.plot([0, b*np.sin(alpha)], [0, -b*np.cos(alpha)], color='k', linestyle='--') 64 | plt.text(4.5, -2, '$r_1=7.24$', fontsize=12) 65 | plt.text(1.1, 2, '$r_2=4.07$', fontsize=12) 66 | 67 | plt.plot([0, 3], [0, 0], color='k', linestyle='--') 68 | th_vec = np.pi / 180.0 * np.arange(start=0, stop=-25, step=-0.1) 69 | plt.plot(2*np.cos(th_vec), 2*np.sin(th_vec), color='k', linestyle='-', linewidth=.5) 70 | plt.text(2.1, -.75, r'$\alpha = -25^\circ$', fontsize=12) 71 | 72 | return fig 73 | 74 | 75 | def example2(): 76 | """ 77 | Executes Example 9.2 and generates one figure 78 | 79 | Ported from MATLAB Code 80 | 81 | Nicholas O'Donoughue 82 | 17 May 2021 83 | 84 | :return: figure handle to generated graphic 85 | """ 86 | 87 | # Set up error covariance matrix 88 | covariance = np.array([[10.0, -3.0], [-3.0, 5.0]]) 89 | 90 | cep50 = compute_cep50(covariance) 91 | print('CEP50: {:0.2f}'.format(cep50)) 92 | 93 | x_ell, y_ell = draw_error_ellipse(np.array([0, 0]), covariance, num_pts=101, conf_interval=50) 94 | x_cep, y_cep = draw_cep50(np.array([0, 0]), covariance, num_pts=101) 95 | 96 | # Draw the Ellipse and CEP 97 | fig = plt.figure() 98 | plt.scatter(0, 0, marker='^', label='Bias Point') 99 | plt.plot(x_ell, y_ell, linestyle='--', label='Error Ellipse') 100 | plt.plot(x_cep, y_cep, linestyle='-', label='$CEP_{50}$') 101 | 102 | # Annotation 103 | plt.text(-1.3, .1, 'Bias Point', fontsize=12) 104 | plt.text(-.4, 1.4, '50% Error Ellipse', fontsize=12) 105 | plt.text(2.2, 2.3, r'$CEP_{50}$', fontsize=12) 106 | 107 | return fig 108 | 109 | 110 | if __name__ == '__main__': 111 | run_all_examples() 112 | plt.show() 113 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Companion to Emitter Detection and Geolocation for Electronic Warfare 2 | 3 | 4 | 5 | This repository is a port of the [MATLAB software companion](https://github.com/nodonoughue/emitter-detection-book/) to *Emitter Detection and Geolocation for Electronic Warfare,* by Nicholas A. O'Donoughue, Artech House, 2019. 6 | 7 | This repository contains the Python code, released under the MIT License, and when it is complete, it will generate all the figures and implements all the algorithms and many of the performance calculations within the texts *Emitter Detection and Geolocation for Electronic Warfare,* by Nicholas A. O'Donoughue, Artech House, 2019 and *Practical Geolocation for Electronic Warfare using MATLAB,* by Nicholas A. O'Donoughue, Artech House, 2022. 8 | 9 | The textbooks can be purchased from Artech House directly at the following links: **[Emitter Detection and Geolocation for Electronic Warfare](https://us.artechhouse.com/Emitter-Detection-and-Geolocation-for-Electronic-Warfare-P2291.aspx)**, and **[Practical Geolocation for Electronic Warfare using MATLAB](https://us.artechhouse.com/Practical-Geolocation-for-Electronic-Warfare-Using-MATLAB-P2292.aspx)** Both are also available from Amazon. 10 | 11 | ## Installation 12 | 13 | Clone the repository, then 14 | ``` 15 | cd passive-geolocation-python 16 | python3 -m venv .venv 17 | source .venv/bin/activate 18 | python3 -m pip install -e . 19 | ``` 20 | 21 | This repository has been tested with Python 3.12 and 3.13. We recommend using a 22 | virtual environment for package/dependency handling (the virtual environment 23 | does not need to be named `.venv`, however). 24 | 25 | ### Dependencies 26 | 27 | This repository is dependent on the following packages, and was written with Python 3.12. 28 | + matplotlib 29 | + numpy 30 | + scipy 31 | + seaborn 32 | 33 | ## Figures 34 | The **make_figures/** folder contains the code to generate all the figures in the textbook. The subfolder **make_figures/practical_geo** generates figures for the second textbook. 35 | 36 | To generate all figures, run the file **make_figures.py**. To run figures for an individual chapter, use a command such as the following: 37 | ```python 38 | import make_figures 39 | chap1_figs = make_figures.chapter1.make_all_figures() 40 | ``` 41 | 42 | ## Examples 43 | The **examples/** folder contains the code to execute each of the examples in the textbook. The subfolder **examples/practical_geo** has examples from the second textbook. 44 | 45 | ## Utilities 46 | A number of utilities are provided in this repository, under the following namespaces: 47 | 48 | + **aoa/** Code to execute angle-of-arrival estimation, as discussed in Chapter 7 49 | + **array/** Code to execute array-based angle-of-arrival estimation, as discussed in Chapter 8 50 | + **atm/** Code to model atmospheric loss, as discussed in Appendix Carlo 51 | + **detector/** Code to model detection performance, as discussed in Chapter 3-4 52 | + **fdoa/** Code to execute Frequency Difference of Arrival (FDOA) geolocation processing, as discussed in Chapter 12. 53 | + **hybrid/** Code to execute hybrid geolocation processing, as discussed in Chapter 13. 54 | + **noise/** Code to model noise power, as discussed in Appendix D. 55 | + **prop/** Code to model propagation losses, as discussed in Appendix B. 56 | + **tdoa/** Code to execute Time Difference of Arrival (TDOA) geolocation processing, as discussed in Chapter 11. 57 | + **triang/** Code to model triangulation from multiple AOA measurements, as discussed in Chapter 10. 58 | + **utils/** Generic utilities, including numerical solvers used in geolocation algorithms. 59 | 60 | ## Feedback 61 | Please submit any suggestions, bugs, or comments as issues in this git repository. 62 | -------------------------------------------------------------------------------- /src/ewgeo/array_df/perf.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def crlb_det(covariance, noise_power, psi_vec, num_snapshots, v, v_dot): 5 | """ 6 | Computes the deterministic CRLB for array-based DOA, according to section 8.4. 7 | 8 | Ported from MATLAB Code. 9 | 10 | Nicholas O'Donoughue 11 | 18 January 2021 12 | 13 | :param covariance: Source signal covariance matrix 14 | :param noise_power: Noise power 15 | :param psi_vec: Array of D steering angles (in radians) for each source 16 | :param num_snapshots: Number of temporal snapshots taken 17 | :param v: Function handle to steering vector v(psi) 18 | :param v_dot: Function handle to steering vector gradient dv(psi)/dpsi 19 | :return crlb: CRLB matrix (DxD) for angle of arrival estimation of each source, in radians^2 20 | Note: multiply by (180/pi)^2 to convert to degrees. 21 | """ 22 | 23 | # Apply source steering angles to steering vector and steering vector gradient function handles. 24 | steer = v(psi_vec) # N x D 25 | steer_gradient = v_dot(psi_vec) # N x D, equation 8.78 26 | 27 | # Construct the QR Decomposition of the vector subspace V, and use it to form the projection matrix orthogonal 28 | # to the subspace spanned by V 29 | q, r = np.linalg.qr(steer) 30 | proj = q.dot(np.conjugate(q).T) # N x N 31 | proj_ortho = np.eye(N=np.shape(proj)[0]) - proj # N x N 32 | h = np.conjugate(steer_gradient).T.dot(proj_ortho).dot(steer_gradient) # D x D, equation 8.77 33 | 34 | # Build spectral matrix from linear SNR 35 | xi = covariance / noise_power # D x D 36 | 37 | # Scaled CRLB, use an if/else to handle scalar cases separately 38 | if np.size(covariance) > 1: 39 | c = np.linalg.pinv(np.real(xi*h.T)) 40 | else: 41 | c = 1/np.real(xi*h) 42 | 43 | return np.squeeze(c) / (2 * num_snapshots) 44 | 45 | 46 | def crlb_stochastic(covariance, noise_power, psi_vec, num_snapshots, v, v_dot): 47 | """ 48 | Computes the stochastic CRLB for array-based DOA, according to section 8.4. 49 | 50 | Ported from MATLAB Code. 51 | 52 | Nicholas O'Donoughue 53 | 18 January 2021 54 | 55 | :param covariance: Source signal covariance matrix 56 | :param noise_power: Noise power 57 | :param psi_vec: Array of D steering angles (in radians) for each source 58 | :param num_snapshots: Number of temporal snapshots taken 59 | :param v: Function handle to steering vector v(psi) 60 | :param v_dot: Function handle to steering vector gradient dv(psi)/dpsi 61 | :return crlb: CRLB matrix (DxD) for angle of arrival estimation of each source, in radians^2 62 | Note: multiply by (180/pi)^2 to convert to degrees. 63 | """ 64 | 65 | # Apply source steering angles to steering vector and steering vector gradient function handles. 66 | steer = v(psi_vec) # N x D 67 | steer_gradient = v_dot(psi_vec) # N x D, equation 8.78 68 | num_elements, num_sources = np.shape(steer) 69 | 70 | # Construct the QR Decomposition of the vector subspace V, and use it to form the projection matrix orthogonal 71 | # to the subspace spanned by V 72 | q, r = np.linalg.qr(steer) 73 | proj = q.dot(np.conjugate(q).T) # N x N 74 | proj_ortho = np.eye(N=num_elements) - proj # N x N 75 | h = np.conjugate(steer_gradient).T.dot(proj_ortho).dot(steer_gradient) # D x D, equation 8.77 76 | 77 | # Build the spectral matrix from linear SNR 78 | a = np.conjugate(r).T.dot(r).dot(covariance) / noise_power 79 | if num_sources > 1: 80 | b = np.dot(np.linalg.lstsq(np.eye(N=num_sources) + a, covariance), a) 81 | else: 82 | b = covariance * a / (1+a) 83 | 84 | # CRLB, ex 8.75 85 | return np.squeeze((noise_power / (2 * num_snapshots)) * np.linalg.pinv(np.real(b*h.T))) # D x D 86 | -------------------------------------------------------------------------------- /src/ewgeo/array_df/model.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import warnings 3 | 4 | 5 | def compute_array_factor(v_fun, h, psi): 6 | """ 7 | Computes the array factor given a beamformer h, and array 8 | steering vector v (function handle) evaluated at a set of 9 | angles psi (in radians). 10 | 11 | The return is the response of the specified beamformer (h) to 12 | a plane wave (defined by v_fun) at various possible source angles. 13 | The outputs is in linear units of amplitude. 14 | 15 | Ported from MATLAB code 16 | 17 | Nicholas O'Donoughue 18 | 18 January 2021 19 | 20 | :param v_fun: Function handle that returns N-element vector of complex values 21 | :param h: Beamformer factor (length N); will be normalized to peak amplitude of 1 22 | :param psi: Angles (in radians) over which to evaluate v_fun 23 | :return af: Array output at each steering angle 24 | """ 25 | 26 | # Normalize the beamformer and make it a column vector 27 | h = np.reshape(h, shape=(np.size(h), 1))/np.max(np.abs(h)) 28 | 29 | # Generate the steering vectors 30 | vv = v_fun(psi) # should be N x numel(psi) 31 | 32 | # Compute the inner product 33 | return np.conjugate(vv.T).dot(h) 34 | 35 | 36 | def compute_array_factor_ula(d_lam, num_elements, psi, psi_0=np.pi / 2, el_pattern=lambda x: 1): 37 | """ 38 | Computes the array factor for a uniform linear array with specified 39 | parameters. 40 | 41 | Ported from MATLAB code. 42 | 43 | Nicholas O'Donoughue 44 | 18 January 2021 45 | 46 | :param d_lam: Inter-element spacing (in wavelengths) 47 | :param num_elements: Number of array elements 48 | :param psi: Incoming signal angle [radians] 49 | :param psi_0: Steering angle [radians] 50 | :param el_pattern: Optional element pattern (function handle that accepts psi and returns the individual element 51 | amplitude). 52 | :return af: Array output at each steering angle 53 | """ 54 | 55 | # Build the Array Pattern -- ignore runtime warnings, we're going to handle divide by zero cases in a few lines 56 | with warnings.catch_warnings(): 57 | warnings.simplefilter("ignore") 58 | af = np.fabs(np.sin(num_elements * np.pi * d_lam * (np.sin(psi) - np.sin(psi_0))) / 59 | (num_elements * (np.sin(np.pi * d_lam * (np.sin(psi) - np.sin(psi_0)))))) 60 | 61 | # Look for grating lobes 62 | epsilon = 1e-6 63 | mask = np.less(np.fabs(np.mod(d_lam*(np.sin(psi)-np.sin(psi_0)) + .5, 1) - .5), epsilon) 64 | np.putmask(af, mask=mask, values=1) 65 | 66 | # Apply the element pattern 67 | el = el_pattern(psi) 68 | return af * el 69 | 70 | 71 | def make_steering_vector(d_lam, num_elements): 72 | """ 73 | Returns an array manifold for a uniform linear array with N elements 74 | and inter-element spacing d_lam. 75 | 76 | Ported from MATLAB code. 77 | 78 | Nicholas O'Donoughue 79 | 18 January 2021 80 | 81 | :param d_lam: Inter-element spacing, in units of wavelengths 82 | :param num_elements: Number of elements in array 83 | :return v: Function handle that accepts an angle psi (in radians) and returns an N-element vector of complex phase 84 | shifts for each element. If multiple angles are supplied, the output is a matrix of size N x numel(psi). 85 | :return v_dot: Function handle that computes the gradient of v(psi) with respect to psi. Returns a matrix of 86 | size N x numel(psi) 87 | """ 88 | 89 | element_idx_vec = np.expand_dims(np.arange(num_elements), axis=1) # Make it 2D, elements along first dim 90 | 91 | def steer(psi): 92 | return np.exp(1j * 2 * np.pi * d_lam * element_idx_vec * np.sin(np.atleast_1d(psi)[np.newaxis, :])) 93 | 94 | def steer_grad(psi): 95 | return (-1j * 2 * np.pi * d_lam * element_idx_vec * np.cos(np.atleast_1d(psi)[np.newaxis, :])) * steer(psi) 96 | 97 | return steer, steer_grad 98 | -------------------------------------------------------------------------------- /make_figures/practical_geo/chapter8.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 7 3 | 4 | This script generates all the figures that appear in Chapter 7 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 28 June 2025 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | 14 | from ewgeo.utils import init_output_dir, init_plot_style 15 | 16 | from examples.practical_geo import chapter8 17 | 18 | 19 | def make_all_figures(close_figs=False, mc_params=None): 20 | """ 21 | Call all the figure generators for this chapter 22 | 23 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 24 | Default=False 25 | :param mc_params: Optional struct to control Monte Carlo trial size 26 | :return: List of figure handles 27 | """ 28 | 29 | # Reset the random number generator, to ensure reproducibility 30 | # rng = np.random.default_rng() 31 | 32 | # Find the output directory 33 | prefix = init_output_dir('practical_geo/chapter8') 34 | init_plot_style() 35 | 36 | # Generate all figures 37 | figs3_4_5 = make_figures_3_4_5(prefix, mc_params) 38 | figs7_8 = make_figures_7_8(prefix, mc_params) 39 | 40 | figs = list(figs3_4_5) + list(figs7_8) 41 | if close_figs: 42 | [plt.close(fig) for fig in figs] 43 | return None 44 | else: 45 | # Display the plots 46 | plt.show() 47 | 48 | return figs 49 | 50 | 51 | def make_figures_3_4_5(prefix=None, mc_params=None): 52 | """ 53 | Figures 8.3, 8.4, and 8.5 from Example 8.1 54 | 55 | :param prefix: output directory to place generated figure 56 | :param mc_params: Optional struct to control Monte Carlo trial size 57 | :return: handle 58 | """ 59 | 60 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 61 | print('Skipping Figures 8.3, 8.4, and 8.5 (re-run with mc_params[\'force_recalc\']=True to generate)...') 62 | return None, 63 | 64 | print('Generating Figures 8.3, 8.4, and 8.5 (Example 8.1)...') 65 | 66 | figs = chapter8.example1(mc_params=mc_params) 67 | 68 | # Output to file 69 | if prefix is not None: 70 | labels = ['fig3', 'fig4', 'fig5'] 71 | if len(labels) != len(figs): 72 | print('**Error saving figure 8.3, 8.4, and 8.5; unexpected number of figures returned from Example 8.1.') 73 | else: 74 | for fig, label in zip(figs, labels): 75 | fig.savefig(prefix + label + '.svg') 76 | fig.savefig(prefix + label + '.png') 77 | 78 | return figs 79 | 80 | 81 | def make_figures_7_8(prefix=None, mc_params=None): 82 | """ 83 | Figures 8.7 and 8.8 from Example 7.2 84 | 85 | :param prefix: output directory to place generated figure 86 | :param mc_params: Optional struct to control Monte Carlo trial size 87 | :return: handle 88 | """ 89 | 90 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 91 | print('Skipping Figures 8.7 and 8.8 (re-run with mc_params[\'force_recalc\']=True to generate)...') 92 | return None, 93 | 94 | print('Generating Figures 8.7 and 8.8 (Example 8.2)...') 95 | 96 | figs = chapter8.example2() 97 | 98 | # Output to file 99 | if prefix is not None: 100 | labels = ['fig7a', 'fig7b', 'fig8'] 101 | if len(labels) != len(figs): 102 | print('**Error saving figure 8.7 and 8.8; unexpected number of figures returned from Example 8.2.') 103 | else: 104 | for fig, label in zip(figs, labels): 105 | fig.savefig(prefix + label + '.svg') 106 | fig.savefig(prefix + label + '.png') 107 | 108 | return figs 109 | 110 | 111 | if __name__ == "__main__": 112 | make_all_figures(close_figs=False, mc_params={'force_recalc': True, 'monte_carlo_decimation': 1, 'min_num_monte_carlo': 1}) 113 | -------------------------------------------------------------------------------- /make_figures/practical_geo/chapter4.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 4 3 | 4 | This script generates all the figures that appear in Chapter 4 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 7 February 2025 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | 14 | from ewgeo.utils import init_output_dir, init_plot_style 15 | 16 | from examples.practical_geo import chapter4 17 | 18 | 19 | def make_all_figures(close_figs=False, mc_params=None): 20 | """ 21 | Call all the figure generators for this chapter 22 | 23 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 24 | Default=False 25 | :param mc_params: Optional struct to control Monte Carlo trial size 26 | :return: List of figure handles 27 | """ 28 | 29 | # Reset the random number generator, to ensure reproducibility 30 | # rng = np.random.default_rng() 31 | 32 | # Find the output directory 33 | prefix = init_output_dir('practical_geo/chapter4') 34 | init_plot_style() 35 | 36 | # Generate all figures 37 | figs_10_11 = make_figures_10_11(prefix, mc_params) 38 | fig12 = make_figure_12(prefix, mc_params) 39 | 40 | figs = list(figs_10_11) + list(fig12) 41 | if close_figs: 42 | for fig in figs: 43 | plt.close(fig) 44 | 45 | return None 46 | else: 47 | plt.show() 48 | 49 | return figs 50 | 51 | 52 | def make_figures_10_11(prefix=None, mc_params=None): 53 | """ 54 | Figure 4.10 and 4.11 from Example 4.1 55 | 56 | :param prefix: output directory to place generated figure 57 | :param mc_params: Optional struct to control Monte Carlo trial size 58 | :return: handle 59 | """ 60 | 61 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 62 | print('Skipping Figures 4.10, and 4.11 (re-run with mc_params[\'force_recalc\']=True to generate)...') 63 | return None, None 64 | 65 | print('Generating Figures 4.10, 4.11 (from Example 4.1)...') 66 | 67 | figs = chapter4.example1(mc_params=mc_params) 68 | 69 | # Display the plot 70 | plt.draw() 71 | 72 | # Output to file 73 | if prefix is not None: 74 | labels = ['fig10', 'fig11'] 75 | if len(labels) != len(figs): 76 | print('**Error saving figures 4.10 and 4.11; unexpected number of figures returned from Example 4.1.') 77 | else: 78 | for fig, label in zip(figs, labels): 79 | fig.savefig(prefix + label + '.svg') 80 | fig.savefig(prefix + label + '.png') 81 | 82 | return figs 83 | 84 | 85 | def make_figure_12(prefix=None, mc_params=None): 86 | """ 87 | Figure 4.12 from Example 4.2 88 | 89 | :param prefix: output directory to place generated figure 90 | :param mc_params: Optional struct to control Monte Carlo trial size 91 | :return: handle 92 | """ 93 | 94 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 95 | print('Skipping Figure 4.12 (re-run with mc_params[\'force_recalc\']=True to generate)...') 96 | return None, None 97 | 98 | print('Generating Figure 4.12 (from Example 4.2)...') 99 | 100 | figs = chapter4.example2() 101 | 102 | # Display the plot 103 | plt.draw() 104 | 105 | # Output to file 106 | if prefix is not None: 107 | labels = ['fig12'] 108 | if len(labels) != len(figs): 109 | print('**Error saving figure 4.12; unexpected number of figures returned from Example 4.2.') 110 | else: 111 | for fig, label in zip(figs, labels): 112 | fig.savefig(prefix + label + '.svg') 113 | fig.savefig(prefix + label + '.png') 114 | 115 | return figs 116 | 117 | 118 | if __name__ == "__main__": 119 | make_all_figures(close_figs=False, mc_params={'force_recalc': True, 'monte_carlo_decimation': 1, 'min_num_monte_carlo': 1}) 120 | -------------------------------------------------------------------------------- /examples/chapter5.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | from scipy import stats 4 | 5 | from ewgeo.utils.unit_conversions import db_to_lin 6 | 7 | 8 | def run_all_examples(): 9 | """ 10 | Run all chapter 4 examples and return a list of figure handles 11 | 12 | :return figs: list of figure handles 13 | """ 14 | 15 | return [example1(), example2(), example3()] 16 | 17 | 18 | def example1(): 19 | """ 20 | Executes Example 5.1. 21 | 22 | Ported from MATLAB Code 23 | 24 | Nicholas O'Donoughue 25 | 25 March 2021 26 | 27 | :return: figure handle to generated graphic 28 | """ 29 | 30 | # Scan Variables 31 | t_hop = np.array([10e-3, 1e-3, 1e-4]) # Target signal hopping period 32 | bw_hop = 200e6 # Target signal hopping bandwidth 33 | # bw_signal = 5e6 # Target signal transmit bandwidth 34 | bw_receiver = 5e6 # Target signal receive bandwidth 35 | 36 | det_time = t_hop*bw_receiver/bw_hop 37 | 38 | sample_freq = bw_receiver 39 | num_samples = np.expand_dims(np.floor(det_time*sample_freq), axis=0) 40 | 41 | # Detection Curve 42 | snr_db_vec = np.arange(start=-15.0, step=.1, stop=10.0) 43 | snr_lin_vec = np.expand_dims(db_to_lin(snr_db_vec), axis=1) 44 | prob_fa = 1e-6 45 | 46 | threshold = stats.chi2.ppf(q=1-prob_fa, df=2*num_samples) 47 | prob_det = stats.ncx2.sf(x=threshold, df=2*num_samples, nc=2*num_samples*snr_lin_vec) 48 | 49 | fig = plt.figure() 50 | for idx, this_thop in enumerate(t_hop.tolist()): 51 | plt.plot(snr_db_vec, prob_det[:, idx], label=r'$T_{{hop}}$ = ' + '{:.1f} ms'.format(this_thop*1e3)) 52 | 53 | plt.xlabel('SNR [dB]') 54 | plt.ylabel('$P_D$') 55 | plt.legend(loc='lower right') 56 | 57 | return fig 58 | 59 | 60 | def example2(): 61 | """ 62 | Executes Example 5.2. 63 | 64 | Ported from MATLAB Code 65 | 66 | Nicholas O'Donoughue 67 | 25 March 2021 68 | 69 | :return: figure handle to generated graphic 70 | """ 71 | 72 | t_hop = np.array([1e-3, 1e-4, 1e-5]) 73 | bw_signal = np.arange(start=1e5, step=1e5, stop=1e7) 74 | bw_hop = 4e9 75 | 76 | t_dwell = 1/bw_signal 77 | 78 | num_scans = np.expand_dims(t_hop, axis=0)/np.expand_dims(t_dwell, axis=1) 79 | bw_receiver = np.maximum(np.expand_dims(bw_signal, axis=1), bw_hop/num_scans) 80 | 81 | fig = plt.figure() 82 | for idx, this_thop in enumerate(t_hop.tolist()): 83 | plt.loglog(bw_signal/1e6, bw_receiver[:, idx]/1e6, label=r'$T_{hop}$' + '={:.2f} ms'.format(this_thop*1e3)) 84 | 85 | plt.xlabel(r'Frequency Resolution ($\delta_f$) [MHz]') 86 | plt.ylabel(r'Receiver Bandwidth ($B_r$) [MHz]') 87 | plt.legend(loc='upper right') 88 | 89 | return fig 90 | 91 | 92 | def example3(): 93 | """ 94 | Executes Example 5.3. 95 | 96 | Ported from MATLAB Code 97 | 98 | Nicholas O'Donoughue 99 | 25 March 2021 100 | 101 | :return: figure handle to generated graphic 102 | """ 103 | 104 | # Define input parameters 105 | bw_signal = np.arange(start=1e5, step=1e5, stop=1e7) 106 | bw_hop = 4e9 107 | t_hop = np.array([1e-3, 1e-4, 1e-5]) 108 | duty = .2 109 | pulse_duration = duty*t_hop 110 | 111 | t_dwell = 1/bw_signal 112 | num_scans = np.expand_dims(pulse_duration, axis=0)/np.expand_dims(t_dwell, axis=1) 113 | bw_receive = np.maximum(np.expand_dims(bw_signal, axis=1), bw_hop/num_scans) 114 | 115 | fig = plt.figure() 116 | for idx, this_tp in enumerate(pulse_duration.tolist()): 117 | plt.loglog(bw_signal/1e6, bw_receive[:, idx]/1e6, label='$t_p$={:.0f}'.format(this_tp*1e6) + r'$\mu$s') 118 | 119 | plt.xlabel(r'Frequency Resolution ($\delta_f$) [MHz]') 120 | plt.ylabel(r'Receiver Bandwidth ($B_r$) [MHz]') 121 | plt.legend(loc='upper right') 122 | 123 | return fig 124 | 125 | 126 | if __name__ == '__main__': 127 | run_all_examples() 128 | plt.show() 129 | -------------------------------------------------------------------------------- /make_figures/appendixB.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Appendix B 3 | 4 | This script generates all the figures that appear in Appendix B of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 8 December 2022 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | import ewgeo.prop as prop 16 | from ewgeo.utils import init_output_dir, init_plot_style 17 | 18 | 19 | def make_all_figures(close_figs=False): 20 | """ 21 | Call all the figure generators for this chapter 22 | 23 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 24 | Default=False 25 | :return: List of figure handles 26 | """ 27 | 28 | # Find the output directory 29 | prefix = init_output_dir('appendixB') 30 | init_plot_style() 31 | 32 | # Random Number Generator 33 | # rng = np.random.default_rng(0) 34 | 35 | # Colormap 36 | # colors = plt.get_cmap("tab10") 37 | 38 | # Generate all figures 39 | fig4 = make_figure_4(prefix) 40 | 41 | figs = [fig4] 42 | if close_figs: 43 | for fig in figs: 44 | plt.close(fig) 45 | 46 | return None 47 | else: 48 | plt.show() 49 | 50 | return figs 51 | 52 | 53 | def make_figure_4(prefix=None): 54 | """ 55 | Figure 4 - Fresnel Zone Illustration 56 | 57 | Ported from MATLAB Code 58 | 59 | Nicholas O'Donoughue 60 | 8 December 2022 61 | 62 | :param prefix: output directory to place generated figure 63 | :return: figure handle 64 | """ 65 | 66 | print('Generating Figure B.4...') 67 | 68 | # Define range axis 69 | range_vec = np.concatenate((np.arange(start=1e3, stop=100e3, step=1e3), 70 | np.arange(start=200e3, stop=1000e3, step=100e3), 71 | np.arange(start=2000e3, stop=10000e3, step=1000e3)), axis=0) 72 | 73 | # Open the Plot 74 | fig4 = plt.figure() 75 | 76 | # Three Situations 77 | # 1 - L Band, ht=hr=10 m 78 | # 2 - L Band, ht=hr=100 m 79 | # 3 - X Band, ht=hr=100 m 80 | freq_vec = np.array([1e9, 1e10]) 81 | ht_vec = np.array([10., 100.]) 82 | 83 | fresnel_zone_range_vec = [] 84 | fresnel_zone_loss_vec = [] 85 | 86 | for freq_hz in freq_vec: 87 | for ht_m in ht_vec: 88 | # Compute Path Loss two ways 89 | fspl = prop.model.get_free_space_path_loss(range_m=range_vec, freq_hz=freq_hz, height_tx_m=ht_m, 90 | include_atm_loss=False) 91 | two_ray = prop.model.get_two_ray_path_loss(range_m=range_vec, freq_hz=freq_hz, height_tx_m=ht_m, 92 | include_atm_loss=False) 93 | 94 | handle = plt.plot(range_vec/1e3, fspl, label='Free-Space Path Loss, f={:.1f} GHz'.format(freq_hz/1e9)) 95 | plt.plot(range_vec/1e3, two_ray, linestyle='-.', color=handle[0].get_color(), 96 | label='Two-Ray Path Loss, f={} GHz, h={} m'.format(freq_hz/1e9, ht_m)) 97 | 98 | # Overlay the Fresnel Zone Range 99 | r_fz = prop.model.get_fresnel_zone(f0=freq_hz, ht=ht_m, hr=ht_m) 100 | 101 | y_fz = prop.model.get_free_space_path_loss(range_m=r_fz, freq_hz=freq_hz, height_tx_m=ht_m, 102 | include_atm_loss=False) 103 | 104 | fresnel_zone_range_vec.append(r_fz/1e3) 105 | fresnel_zone_loss_vec.append(y_fz) 106 | 107 | plt.scatter(fresnel_zone_range_vec, fresnel_zone_loss_vec, 108 | marker='^', color='k', zorder=3, label='Fresnel Zone') 109 | 110 | plt.xscale('log') 111 | plt.legend(loc='upper left') 112 | 113 | plt.xlabel('Path Length [km]') 114 | plt.ylabel('Loss') 115 | 116 | if prefix is not None: 117 | fig4.savefig(prefix + 'fig4.png') 118 | fig4.savefig(prefix + 'fig4.svg') 119 | 120 | return fig4 121 | 122 | 123 | if __name__ == "__main__": 124 | make_all_figures(close_figs=False) 125 | -------------------------------------------------------------------------------- /src/ewgeo/fdoa/perf.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import model 4 | from ewgeo.utils import safe_2d_shape 5 | from ewgeo.utils.covariance import CovarianceMatrix 6 | from ewgeo.utils.perf import compute_crlb_gaussian 7 | from ewgeo.utils.unit_conversions import db_to_lin 8 | 9 | 10 | def compute_crlb(x_sensor, v_sensor, x_source, cov: CovarianceMatrix, v_source=None, ref_idx=None, do_resample=True, 11 | print_progress=False, **kwargs): 12 | """ 13 | Computes the CRLB on position accuracy for source at location xs and 14 | sensors at locations in x_fdoa (Ndim x N) with velocity v_fdoa. 15 | C is a Nx1 vector of FOA variances at each of the N sensors, and ref_idx 16 | defines the reference sensor(s) used for FDOA. 17 | 18 | Ported from MATLAB Code 19 | 20 | Nicholas O'Donoughue 21 | 21 February 2021 22 | 23 | :param x_sensor: (Ndim x N) array of FDOA sensor positions 24 | :param v_sensor: (Ndim x N) array of FDOA sensor velocities 25 | :param x_source: (Ndim x M) array of source positions over which to calculate CRLB 26 | :param v_source: n_dim x n_source vector of source velocities 27 | :param cov: CovarianceMatrix object for range rate estimates 28 | :param ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings 29 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx 30 | :param print_progress: Boolean flag, if true then progress updates and elapsed/remaining time will be printed to 31 | the console. [default=False] 32 | :return crlb: Lower bound on the error covariance matrix for an unbiased FDOA estimator (Ndim x Ndim) 33 | """ 34 | 35 | # Parse inputs 36 | n_dim, n_source = safe_2d_shape(x_source) 37 | 38 | # Make sure that xs is 2D 39 | if n_source == 1: 40 | x_source = x_source[:, np.newaxis] 41 | 42 | if do_resample: 43 | cov = cov.resample(ref_idx=ref_idx) 44 | 45 | # Define a wrapper for the jacobian matrix that accepts only the position 'x' 46 | def jacobian(x): 47 | j= model.jacobian(x_sensor=x_sensor, v_sensor=v_sensor, 48 | x_source=x, v_source=v_source, ref_idx=ref_idx) 49 | # the jacobian will return the gradient with respect to both position and velocity, if asked for 50 | # just grab the first num_dim rows 51 | return j[:n_dim] 52 | 53 | crlb = compute_crlb_gaussian(x_source=x_source, jacobian=jacobian, cov=cov, 54 | print_progress=print_progress, **kwargs) 55 | 56 | return crlb 57 | 58 | 59 | def freq_crlb(sample_time, num_samples, snr_db): 60 | """ 61 | Compute the CRLB for the frequency difference estimate from a pair of 62 | sensors, given the time duration of the sampled signals, receiver 63 | bandwidth, and average SNR. 64 | 65 | Ported from MATLAB code. 66 | 67 | Nicholas O'Donoughue 68 | 21 February 2021 69 | 70 | :param sample_time: Received signal duration [s] 71 | :param num_samples: Number of receiver samples [Hz] 72 | :param snr_db: SNR [dB] 73 | :return: Frequency difference estimate error standard deviation [Hz] 74 | """ 75 | 76 | # Convert SNR to linear units 77 | snr_lin = db_to_lin(snr_db) 78 | 79 | # Compute the CRLB of the center frequency variance 80 | sigma = np.sqrt(3 / (np.pi**2 * sample_time**2 * num_samples * (num_samples**2 - 1) * snr_lin)) 81 | 82 | return sigma 83 | 84 | 85 | def freq_diff_crlb(time_s, bw_hz, snr_db): 86 | """ 87 | Compute the CRLB for the frequency difference estimate from a pair of 88 | sensors, given the time duration of the sampled signals, receiver 89 | bandwidth, and average SNR. 90 | 91 | Ported from MATLAB code 92 | 93 | Nicholas O'Donoughue 94 | 21 February 2021 95 | 96 | :param time_s: Received signal duration [s] 97 | :param bw_hz: Received signal bandwidth [Hz] 98 | :param snr_db: Average SNR [dB] 99 | :return sigma: Frequency difference estimate error standard deviation [Hz] 100 | """ 101 | 102 | # Convert SNR to linear units 103 | snr_lin = db_to_lin(snr_db) 104 | 105 | # Apply the CRLB equations 106 | sigma = np.sqrt(3 / (4 * np.pi**2 * time_s**3 * bw_hz * snr_lin)) 107 | 108 | return sigma 109 | -------------------------------------------------------------------------------- /src/ewgeo/utils/perf.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from scipy.linalg import pinvh, cholesky, solve_triangular 3 | import time 4 | 5 | from . import utils 6 | from .covariance import CovarianceMatrix 7 | 8 | 9 | def compute_crlb_gaussian(x_source, jacobian, cov: CovarianceMatrix, print_progress=False, 10 | eq_constraints_grad:list = None): 11 | """ 12 | Computes the CRLB for a Gaussian problem at one or more source positions. The CRLB for Gaussian problems takes the 13 | general form: 14 | 15 | C >= F^{-1} = [J C_z^{-1} J^T]^{-1} 16 | 17 | where C is the covariance matrix for the estimate of x, J is the Jacobian evaluated at x, and C_z is the 18 | measurement (z) covariance matrix. 19 | 20 | Nicholas O'Donoughue 21 | 25 February 2025 22 | 23 | :param x_source: n_dim x n_source ndarray of source positions 24 | :param jacobian: function that accepts a single source position and returns the n_dim x n_measurement Jacobian 25 | :param cov: Covariance Matrix object 26 | :param print_progress: Boolean flag; if true then elapsed/remaining time estimates will be printed to the console 27 | :param eq_constraints_grad: list of constraint gradients for equality constraints to be applied 28 | :return crlb: n_dim x n_dim x n_source lower bound on the estimate covariance matrix 29 | """ 30 | 31 | # Parse inputs 32 | n_dim, n_source = utils.safe_2d_shape(x_source) 33 | if n_source==1 and len(x_source.shape) == 1: 34 | x_source = x_source[:, np.newaxis] 35 | 36 | do_eq_constraints = eq_constraints_grad is not None 37 | if do_eq_constraints: 38 | # Compute the gradient for all positions and store the result in an array of dimension 39 | # num_constraints x n_dim x n_source 40 | constraint_grad = np.asarray([eq(x_source) for eq in eq_constraints_grad]) 41 | 42 | # Initialize output variable 43 | crlb = np.zeros((n_dim, n_dim, n_source)) 44 | 45 | # Print CRLB calculation progress, if desired 46 | markers_per_row = 40 47 | desired_num_rows = 10 48 | min_iter_per_marker = 10 49 | max_iter_per_marker = 10000 50 | iter_per_marker = int(np.floor(n_source / markers_per_row / desired_num_rows)) 51 | iter_per_marker = np.fmin(max_iter_per_marker, np.fmax(min_iter_per_marker, iter_per_marker)) 52 | iter_per_row = markers_per_row * iter_per_marker 53 | 54 | # at least 1 iteration per marker, no more than 100 iterations per marker 55 | t_start = time.perf_counter() 56 | 57 | if print_progress: 58 | print('Computing CRLB solution for {} source positions...'.format(n_source)) 59 | 60 | # Repeat CRLB for each of the n_source test positions 61 | for idx in np.arange(n_source): 62 | if print_progress: 63 | utils.print_progress(num_total=n_source, curr_idx=idx, iterations_per_marker=iter_per_marker, 64 | iterations_per_row=iter_per_row, t_start=t_start) 65 | 66 | this_x = x_source[:, idx] 67 | 68 | # Evaluate the Jacobian 69 | this_jacobian = jacobian(this_x) 70 | 71 | # Compute the Fisher Information Matrix 72 | fisher_matrix = cov.solve_aca(this_jacobian) 73 | 74 | # Compute Constraint Gradients, if any 75 | if do_eq_constraints: 76 | # Grab the constraint gradient for the current source position 77 | # Transpose it so the dimensions are n_dim x num_constraints 78 | # noinspection PyUnboundLocalVariable 79 | this_gradient = constraint_grad[:, :, idx].T 80 | 81 | if np.any(np.isnan(fisher_matrix)) or np.any(np.isinf(fisher_matrix)): 82 | # Problem is ill-defined, Fisher Information Matrix cannot be 83 | # inverted 84 | crlb[:, :, idx] = np.nan 85 | else: 86 | fisher_inv = np.real(pinvh(fisher_matrix)) 87 | if do_eq_constraints: 88 | # Apply the impact of equality constraints 89 | # noinspection PyUnboundLocalVariable 90 | fg = fisher_inv @ this_gradient 91 | gfg = this_gradient.T @ fg 92 | lower = cholesky(gfg, lower=True) 93 | 94 | res = solve_triangular(lower, fg.T, lower=True) 95 | 96 | fisher_const_inv = res.T @ res 97 | 98 | # Subtract the Fisher inverse for the constraint from the unconstrained Fisher inverse to yield the 99 | # constrained Fisher inverse 100 | fisher_inv = fisher_inv - fisher_const_inv 101 | 102 | crlb[:, :, idx] = fisher_inv 103 | 104 | if print_progress: 105 | print('done') 106 | t_elapsed = time.perf_counter() - t_start 107 | utils.print_elapsed(t_elapsed) 108 | 109 | if n_source == 1: 110 | # There's only one source, trim the third dimension 111 | crlb = crlb[:, :, 0] 112 | 113 | return crlb 114 | -------------------------------------------------------------------------------- /src/ewgeo/utils/unit_conversions.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | _length_factors = { 4 | "m": 1, 5 | "km": 1000, 6 | "ft": 0.3048, 7 | "kft": 304.8, 8 | "yd": 0.9144, 9 | "mi": 1609.34, 10 | "nmi": 1852 11 | } 12 | 13 | _angle_factors = { 14 | "deg": 1, 15 | "rad": 180/np.pi 16 | } 17 | 18 | _speed_factors = { 19 | "m/s": 1, 20 | "kph": 1./3.6, 21 | "knot": 0.5144, 22 | } 23 | 24 | 25 | def lin_to_db(lin_value, eps=1e-99): 26 | """ 27 | Convert inputs from linear units to dB, via the simple equation 28 | db = 10 * log10 (lin) 29 | 30 | Nicholas O'Donoughue 31 | 7 May 2021 32 | 33 | :param lin_value: scalar or numpy array of linear values to convert 34 | :param eps: minimum precision, any inputs < eps will be capped, to prevent a divide by zero runtime error 35 | :return: scalar or numpy array of db values 36 | """ 37 | 38 | # Use the optional eps argument as a minimum allowable precision, to prevent divide by zero errors if we take the 39 | # logarithm of 0. 40 | # ToDo: suppress runtime warning (divide by zero) while generating Fig 8.7b, 12.3a 41 | return np.where(lin_value>=eps, 10 * np.log10(lin_value), -np.inf) 42 | 43 | 44 | def db_to_lin(db_value, inf_val=3000): 45 | """ 46 | Convert input from db units to linear, via the simple equation 47 | lin = 10^(db/10) 48 | 49 | Nicholas O'Donoughue 50 | 7 May 2021 51 | 52 | :param db_value: scalar or numpy array of db values to convert 53 | :param inf_val: any dB values above this point will be converted to np.inf to avoid overflow 54 | :return: scalar or numpy array of linear values 55 | """ 56 | 57 | # Use the optional inf_val argument as a maximum dB value, above which we convert the output to np.inf to prevent 58 | # overflow errors 59 | return np.where(db_value>inf_val, np.inf, np.power(10, db_value/10)) 60 | 61 | 62 | def convert(value, from_unit, to_unit): 63 | """ 64 | Convert a speed, length, or angle from one unit to another. 65 | 66 | :param value: float or numpy array 67 | :param from_unit: str denoting the units of value 68 | :param to_unit: str denoting the desired units of the return value 69 | :return: output after conversion to desired units 70 | """ 71 | factor = parse_units(from_unit, to_unit) 72 | 73 | return value * factor 74 | 75 | 76 | def parse_units(from_unit, to_unit): 77 | """ 78 | Attempt to determine what type of conversion this is, and use the appropriate factor 79 | dictionary. 80 | """ 81 | 82 | # Make sure the units are lower-case, our dictionaries are case-insensitive 83 | from_unit = from_unit.lower() 84 | to_unit = to_unit.lower() 85 | 86 | # Determine which type of unit conversion is being asked for, and lookup the factor 87 | if from_unit in _length_factors: 88 | # Length factor 89 | if to_unit not in _length_factors: 90 | # Not a valid length conversion 91 | raise ValueError("Invalid unit; length conversion detected. Please use on of: " 92 | + ", ".join(_length_factors.keys())) 93 | 94 | factor = _length_factors[from_unit] / _length_factors[to_unit] 95 | 96 | elif from_unit in _angle_factors: 97 | if to_unit not in _angle_factors: 98 | # Not a valid angle conversion 99 | raise ValueError("Invalid unit; angle conversion detected. Please use on of: " 100 | + ", ".join(_angle_factors.keys())) 101 | 102 | factor = _angle_factors[from_unit] / _angle_factors[to_unit] 103 | 104 | elif from_unit in _speed_factors: 105 | if to_unit not in _speed_factors: 106 | # Not a valid speed conversion 107 | raise ValueError("Invalid unit; speed conversion detected. Please use on of: " 108 | + ", ".join(_speed_factors.keys())) 109 | 110 | factor = _speed_factors[from_unit] / _speed_factors[to_unit] 111 | 112 | else: 113 | raise ValueError("Invalid unit; unable to determine desired conversion type.") 114 | 115 | return factor 116 | 117 | 118 | def kft_to_km(kft_value): 119 | """ 120 | Convert altitude from kft (thousands of feet) to km. 121 | 122 | :param kft_value: 123 | :return: 124 | """ 125 | # return kft_value * _ft2m 126 | return convert(kft_value, "kft", "km") 127 | 128 | 129 | def km_to_kft(km_value): 130 | """ 131 | Convert altitude from km to kft (thousands of feet) 132 | 133 | :param km_value: 134 | :return: 135 | """ 136 | # return km_value * _m2ft 137 | return convert(km_value, "km", "kft") 138 | 139 | 140 | def kph_to_mps(kph_value): 141 | """ 142 | Convert speed from kph to m/s 143 | 144 | :param kph_value: 145 | :return: 146 | """ 147 | # return kph_value * 1e3 / 3600 148 | return convert(kph_value, "kph", "m/s") 149 | 150 | 151 | def mps_to_kph(mps_value): 152 | """ 153 | Convert speed from m/s to kph 154 | 155 | :param mps_value: 156 | :return: 157 | """ 158 | # return mps_value * 3.6 159 | return convert(mps_value, "m/s", "kph") 160 | -------------------------------------------------------------------------------- /make_figures.py: -------------------------------------------------------------------------------- 1 | import make_figures 2 | 3 | close_figs = True 4 | 5 | do_book_1 = True 6 | do_book_2 = True 7 | 8 | # Parameters used to control execution 9 | mc_params = {'force_recalc': True, 10 | 'monte_carlo_decimation': 1, 11 | 'min_num_monte_carlo': int(10)} 12 | 13 | if do_book_1: 14 | print('************************************************************************************************') 15 | print('Generating all figures from ''Emitter Detection and Geolocation for Electronic Warfare'', 2019.') 16 | print('************************************************************************************************') 17 | print('close_figs = {}'.format(close_figs)) 18 | print('force_recalc = {}'.format(mc_params['force_recalc'])) 19 | print('min_num_monte_carlo = {}'.format(mc_params['min_num_monte_carlo'])) 20 | print('monte_carlo_decimation = {}'.format(mc_params['monte_carlo_decimation'])) 21 | 22 | print('*** Chapter 1 ***') 23 | make_figures.chapter1.make_all_figures(close_figs=close_figs) 24 | print('*** Chapter 2 ***') 25 | make_figures.chapter2.make_all_figures(close_figs=close_figs) 26 | print('*** Chapter 3 ***') 27 | make_figures.chapter3.make_all_figures(close_figs=close_figs, mc_params=mc_params) 28 | print('*** Chapter 4 ***') 29 | make_figures.chapter4.make_all_figures(close_figs=close_figs, mc_params=mc_params) 30 | print('*** Chapter 5 ***') 31 | make_figures.chapter5.make_all_figures(close_figs=close_figs) 32 | print('*** Chapter 6 ***') 33 | make_figures.chapter6.make_all_figures(close_figs=close_figs) 34 | print('*** Chapter 7 ***') 35 | make_figures.chapter7.make_all_figures(close_figs=close_figs, mc_params=mc_params) 36 | print('*** Chapter 8 ***') 37 | make_figures.chapter8.make_all_figures(close_figs=close_figs, mc_params=mc_params) 38 | print('*** Chapter 9 ***') 39 | make_figures.chapter9.make_all_figures(close_figs=close_figs) 40 | print('*** Chapter 10 ***') 41 | make_figures.chapter10.make_all_figures(close_figs=close_figs, mc_params=mc_params) 42 | print('*** Chapter 11 ***') 43 | make_figures.chapter11.make_all_figures(close_figs=close_figs, mc_params=mc_params) 44 | print('*** Chapter 12 ***') 45 | make_figures.chapter12.make_all_figures(close_figs=close_figs, mc_params=mc_params) 46 | print('*** Chapter 13 ***') 47 | make_figures.chapter13.make_all_figures(close_figs=close_figs, mc_params=mc_params) 48 | print('*** Appendix B ***') 49 | make_figures.appendixB.make_all_figures(close_figs=close_figs) 50 | print('*** Appendix C ***') 51 | make_figures.appendixC.make_all_figures(close_figs=close_figs) 52 | print('*** Appendix D ***') 53 | make_figures.appendixD.make_all_figures(close_figs=close_figs) 54 | print('Figure generation complete.') 55 | else: 56 | print('************************************************************************************************') 57 | print('Skipping ''Emitter Detection and Geolocation for Electronic Warfare'', 2019.') 58 | print('Re-run with ''do_book_1 = True'' to generate all figures.') 59 | print('************************************************************************************************') 60 | 61 | if do_book_2: 62 | print('************************************************************************************************') 63 | print('Generating all figures from ''Practical Geolocation for Electronic Warfare using MATLAB'', 2022.') 64 | print('************************************************************************************************') 65 | print('close_figs = {}'.format(close_figs)) 66 | print('force_recalc = {}'.format(mc_params['force_recalc'])) 67 | print('min_num_monte_carlo = {}'.format(mc_params['min_num_monte_carlo'])) 68 | print('monte_carlo_decimation = {}'.format(mc_params['monte_carlo_decimation'])) 69 | 70 | print('*** Chapter 1 ***') 71 | make_figures.practical_geo.chapter1.make_all_figures(close_figs=close_figs) 72 | print('*** Chapter 2 ***') 73 | make_figures.practical_geo.chapter2.make_all_figures(close_figs=close_figs, mc_params=mc_params) 74 | print('*** Chapter 3 ***') 75 | make_figures.practical_geo.chapter3.make_all_figures(close_figs=close_figs, mc_params=mc_params) 76 | print('*** Chapter 4 ***') 77 | make_figures.practical_geo.chapter4.make_all_figures(close_figs=close_figs, mc_params=mc_params) 78 | print('*** Chapter 5 ***') 79 | make_figures.practical_geo.chapter5.make_all_figures(close_figs=close_figs, mc_params=mc_params) 80 | print('*** Chapter 6 ***') 81 | make_figures.practical_geo.chapter6.make_all_figures(close_figs=close_figs, mc_params=mc_params) 82 | print('*** Chapter 7 ***') 83 | make_figures.practical_geo.chapter7.make_all_figures(close_figs=close_figs, mc_params=mc_params) 84 | print('*** Chapter 8 ***') 85 | make_figures.practical_geo.chapter8.make_all_figures(close_figs=close_figs, mc_params=mc_params) 86 | print('Figure generation complete.') 87 | else: 88 | print('************************************************************************************************') 89 | print('Skipping ''Practical Geolocation for Electronic Warfare using MATLAB'', 2022.') 90 | print('************************************************************************************************') 91 | print('Re-run with ''do_book_2 = True'' to generate all figures.') -------------------------------------------------------------------------------- /make_figures/chapter9.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 9 3 | 4 | This script generates all the figures that appear in Chapter 9 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 17 May 2021 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | from ewgeo.utils import init_output_dir, init_plot_style 16 | from ewgeo.utils.errors import draw_cep50, draw_error_ellipse 17 | 18 | from examples import chapter9 19 | 20 | 21 | def make_all_figures(close_figs=False): 22 | """ 23 | Call all the figure generators for this chapter 24 | 25 | :close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 26 | Default=False 27 | :return: List of figure handles 28 | """ 29 | 30 | # Find the output directory 31 | prefix = init_output_dir('chapter9') 32 | init_plot_style() 33 | 34 | # Generate all figures 35 | fig1 = make_figure_1(prefix) 36 | fig2 = make_figure_2(prefix) 37 | fig3 = make_figure_3(prefix) 38 | fig4 = make_figure_4(prefix) 39 | 40 | figs = [fig1, fig2, fig3, fig4] 41 | 42 | if close_figs: 43 | for fig in figs: 44 | plt.close(fig) 45 | 46 | return None 47 | else: 48 | plt.show() 49 | 50 | return figs 51 | 52 | 53 | def make_figure_1(prefix=None): 54 | """ 55 | Figure 1, Plot of Error Ellipse 56 | 57 | Ported from MATLAB Code 58 | 59 | Nicholas O'Donoughue 60 | 17 May 2021 61 | 62 | :param prefix: output directory to place generated figure 63 | :return: figure handle 64 | """ 65 | 66 | print('Generating Figure 9.1...') 67 | 68 | # Define Positions 69 | x0 = np.array([0, 1]) 70 | x2 = np.array([.2, .2]) 71 | 72 | # Define Covariance Matrix 73 | sx2 = 5 74 | sy2 = 3 75 | rho = .8 76 | sxy = rho*np.sqrt(sx2*sy2) # cross-covariance 77 | cov_mtx = np.array([[sx2, sxy], [sxy, sy2]]) 78 | 79 | # Compute Ellipses 80 | x_ell1, y_ell1 = draw_error_ellipse(x2, cov_mtx, num_pts=361, conf_interval=1) # 1 sigma 81 | x_ell2, y_ell2 = draw_error_ellipse(x2, cov_mtx, num_pts=361, conf_interval=95) # 95% conf interval 82 | 83 | # Draw figure 84 | fig1 = plt.figure() 85 | 86 | # Draw True/Estimate Positions 87 | plt.scatter(x0[0], x0[1], marker='^', label='True') 88 | plt.scatter(x2[0], x2[1], marker='+', label='Estimated') 89 | 90 | # Draw error ellipses 91 | plt.plot(x_ell1, y_ell1, linestyle='-', label=r'1$\sigma$ Ellipse') 92 | plt.plot(x_ell2, y_ell2, linestyle='--', label='95% Ellipse') 93 | 94 | # Adjust Figure Display 95 | plt.legend(loc='upper left') 96 | 97 | if prefix is not None: 98 | fig1.savefig(prefix + 'fig1.png') 99 | fig1.savefig(prefix + 'fig1.svg') 100 | 101 | return fig1 102 | 103 | 104 | def make_figure_2(prefix=None): 105 | """ 106 | Figure 2 Plot of Error Ellipse Example 107 | 108 | Ported from MATLAB Code 109 | 110 | Nicholas O'Donoughue 111 | 17 May 2021 112 | 113 | :param prefix: output directory to place generated figure 114 | :return: figure handle 115 | """ 116 | 117 | print('Generating Figure 9.2 (using Example 9.1)...') 118 | 119 | fig2 = chapter9.example1() 120 | 121 | if prefix is not None: 122 | fig2.savefig(prefix + 'fig2.png') 123 | fig2.savefig(prefix + 'fig2.svg') 124 | 125 | return fig2 126 | 127 | 128 | def make_figure_3(prefix=None): 129 | """ 130 | Figure 3, Plot of CEP50 131 | 132 | Ported from MATLAB Code 133 | 134 | Nicholas O'Donoughue 135 | 17 May 2021 136 | 137 | :param prefix: output directory to place generated figure 138 | :return: figure handle 139 | """ 140 | 141 | print('Generating Figure 9.3...') 142 | 143 | # Initialize Emitter Location and Estimate 144 | x0 = np.array([0, 0]) 145 | x2 = np.array([.5, -.2]) 146 | 147 | # Initialize Covariance Matrix 148 | sx2 = 5 149 | sy2 = 3 150 | rho = .8 151 | sxy = rho*np.sqrt(sx2*sy2) 152 | cov_mtx = np.array([[sx2, sxy], [sxy, sy2]]) 153 | 154 | # Compute Error Ellipses 155 | x_ellipse, y_ellipse = draw_error_ellipse(x2, cov_mtx, num_pts=361, conf_interval=50) 156 | x_cep, y_cep = draw_cep50(x2, cov_mtx, num_pts=361) 157 | 158 | # Draw Figure 159 | fig3 = plt.figure() 160 | 161 | # Draw Ellipses 162 | plt.plot(x_cep, y_cep, linestyle='-', label=r'$CEP_{50}$') 163 | plt.plot(x_ellipse, y_ellipse, linestyle='--', label='50% Error Ellipse') 164 | plt.scatter(x0[0], x0[1], marker='^', label='True') 165 | plt.scatter(x2[0], x2[1], marker='+', label='Estimated') 166 | plt.xlim(1.1*np.amax(x_ellipse)*np.array([-1, 1])) 167 | 168 | # Adjust Display 169 | plt.legend(loc='upper left') 170 | 171 | if prefix is not None: 172 | fig3.savefig(prefix + 'fig3.png') 173 | fig3.savefig(prefix + 'fig3.svg') 174 | 175 | return fig3 176 | 177 | 178 | def make_figure_4(prefix=None): 179 | """ 180 | Figure 4 Plot of CEP50 and Error Ellipse Example 181 | 182 | Ported from MATLAB Code 183 | 184 | Nicholas O'Donoughue 185 | 17 May 2021 186 | 187 | :param prefix: output directory to place generated figure 188 | :return: figure handle 189 | """ 190 | 191 | print('Generating Figure 9.4 (using Example 9.2)...') 192 | 193 | fig4 = chapter9.example2() 194 | 195 | if prefix is not None: 196 | fig4.savefig(prefix + 'fig4.png') 197 | fig4.savefig(prefix + 'fig4.svg') 198 | 199 | return fig4 200 | 201 | 202 | if __name__ == "__main__": 203 | make_all_figures(close_figs=False) 204 | -------------------------------------------------------------------------------- /src/ewgeo/atm/test.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | 4 | from . import reference, model 5 | 6 | 7 | def plot_itu_ref_figs(): 8 | """ 9 | Generates figures to compare with ITU-R P.676-12 10 | 11 | Can be used to ensure atmospheric loss tables and calculations are reasonably accurate. 12 | 13 | Ported from MATLAB Code 14 | 15 | Nicholas O'Donoughue 16 | 21 March 2021 17 | 18 | :return figs: array of figure handle objects 19 | """ 20 | 21 | # Figure 1 - Specific attenuation 22 | f_ghz = np.arange(1001) 23 | f_ctr_o, f_ctr_w = reference.get_spectral_lines(f_ghz[0]*1e9, f_ghz[-1]*1e9) 24 | f = np.sort(np.concatenate((f_ghz * 1e9, f_ctr_o, f_ctr_w))) 25 | f_ghz = f/1e9 # recompute f_ghz to include spectral lines 26 | atmosphere = reference.get_standard_atmosphere(0) 27 | 28 | gamma_ox, gamma_h2o = model.get_gas_loss_coeff(f, atmosphere.press, atmosphere.water_vapor_press, atmosphere.temp) 29 | 30 | fig1 = plt.figure() 31 | plt.semilogy(f_ghz, gamma_ox, linestyle='b-', label='Dry') 32 | plt.semilogy(f_ghz, gamma_ox+gamma_h2o, linestyle='r-', label='Standard') 33 | plt.xlabel('Frequency (GHz)') 34 | plt.ylabel('Specific Attenuation (dB/km)') 35 | plt.grid() 36 | plt.legend(loc='NorthWest') 37 | plt.title('Replication of ITU-R P.676-12, Figure 1') 38 | plt.xlim((0, 1e3)) 39 | 40 | # Figure 2 41 | f_ghz = np.arange(start=50, stop=70.1, step=0.1) 42 | f_ctr_o, f_ctr_w = reference.get_spectral_lines(f_ghz[0] * 1e9, f_ghz[-1] * 1e9) 43 | f = np.sort(np.concatenate((f_ghz * 1e9, f_ctr_o, f_ctr_w))) 44 | f_ghz = f / 1e9 # recompute f_ghz to include spectral lines 45 | 46 | alts = np.arange(start=0, stop=25, step=5)*1e3 47 | atmosphere = reference.get_standard_atmosphere(alts) 48 | 49 | gamma_ox, gamma_h2o = model.get_gas_loss_coeff(f, atmosphere.press, atmosphere.water_vapor_press, atmosphere.temp) 50 | gamma = gamma_ox + gamma_h2o 51 | 52 | fig2 = plt.figure() 53 | for idx, alt in enumerate(alts.tolist()): 54 | plt.semilogy(f_ghz, gamma[:, idx], linestyle='-', label='{:.0f} km'.format(alt/1e3)) 55 | 56 | plt.legend(loc='NorthWest') 57 | plt.xlabel('Frequency (GHz)') 58 | plt.ylabel('Specific Attenuation (dB/km)') 59 | plt.grid() 60 | plt.title('Replication of ITU-R P.676-12, Figure 2') 61 | 62 | # Figure 4 63 | f_ghz = np.arange(start=1, stop=1001, step=1) 64 | f_ctr_o, f_ctr_w = reference.get_spectral_lines(f_ghz[0] * 1e9, f_ghz[-1] * 1e9) 65 | f = np.sort(np.concatenate((f_ghz * 1e9, f_ctr_o, f_ctr_w))) 66 | f_ghz = f / 1e9 # recompute f_ghz to include spectral lines 67 | 68 | l, lo, _ = model.calc_zenith_loss(f, alt_start_m=0.0, zenith_angle_deg=0.0) 69 | 70 | fig4 = plt.figure() 71 | plt.semilogy(f_ghz, lo, linestyle='b-', label='Dry') 72 | plt.semilogy(f_ghz, l, linestyle='r-', label='Standard') 73 | plt.xlim((0, 1e3)) 74 | plt.xlabel('Frequency (Ghz)') 75 | plt.ylabel('Zenith Attenuation (dB)') 76 | plt.grid() 77 | plt.legend(loc='NorthWest') 78 | plt.title('Replication of ITU-R P.676-12, Figure 4') 79 | 80 | # Figure 10 81 | f_ghz = np.arange(start=1, stop=351, step=1) 82 | f_ctr_o, f_ctr_w = reference.get_spectral_lines(f_ghz[0] * 1e9, f_ghz[-1] * 1e9) 83 | f = np.sort(np.concatenate((f_ghz * 1e9, f_ctr_o, f_ctr_w))) 84 | f_ghz = f / 1e9 # recompute f_ghz to include spectral lines 85 | 86 | atmosphere = reference.get_standard_atmosphere(0) 87 | 88 | gamma_ox, gamma_h2o = model.get_gas_loss_coeff(f, atmosphere.press, atmosphere.water_vapor_press, atmosphere.temp) 89 | 90 | fig10 = plt.figure() 91 | plt.loglog(f_ghz, gamma_ox, linestyle='b-', label='Dry') 92 | plt.loglog(f_ghz, gamma_h2o, linestyle='r-', label='Water Vapour') 93 | plt.loglog(f_ghz, gamma_ox+gamma_h2o, linestyle='k-', label='Total') 94 | plt.xlabel('Frequency (GHz)') 95 | plt.ylabel('Specific Attenuation (dB/km)') 96 | plt.grid() 97 | plt.legend(loc='NorthWest') 98 | plt.title('Replication of ITU-R P.676-12, Figure 10') 99 | plt.xlim((0, 350)) 100 | 101 | # Figure 11 102 | f_ghz = np.arange(start=1, stop=351, step=1) 103 | f_ctr_o, f_ctr_w = reference.get_spectral_lines(f_ghz[0] * 1e9, f_ghz[-1] * 1e9) 104 | f = np.sort(np.concatenate((f_ghz * 1e9, f_ctr_o, f_ctr_w))) 105 | f_ghz = f / 1e9 # recompute f_ghz to include spectral lines 106 | 107 | l, lo, lw = model.calc_zenith_loss(f, alt_start_m=0.0, zenith_angle_deg=0.0) 108 | 109 | fig11 = plt.figure() 110 | plt.loglog(f_ghz, lo, linestyle='b-', label='Dry') 111 | plt.loglog(f_ghz, lw, linestyle='r-', label='Water vapour') 112 | plt.semilogy(f_ghz, l, linestyle='k-', label='Total') 113 | plt.xlim((0, 350)) 114 | plt.xlabel('Frequency (Ghz)') 115 | plt.ylabel('Zenith Attenuation (dB)') 116 | plt.grid() 117 | plt.legend(loc='NorthWest') 118 | plt.title('Replication of ITU-R P.676-12, Figure 11') 119 | 120 | # Figure 12 121 | f_ghz = np.arange(start=50, stop=70.01, step=.01) 122 | f_ctr_o, f_ctr_w = reference.get_spectral_lines(f_ghz[0] * 1e9, f_ghz[-1] * 1e9) 123 | f = np.sort(np.concatenate((f_ghz * 1e9, f_ctr_o, f_ctr_w))) 124 | f_ghz = f / 1e9 # recompute f_ghz to include spectral lines 125 | 126 | alts = np.arange(start=0, stop=25, step=5)*1e3 127 | 128 | l, _, _ = model.calc_zenith_loss(f, alts, zenith_angle_deg=0.0) 129 | 130 | fig12 = plt.figure() 131 | for idx, alt in enumerate(alts.tolist()): 132 | plt.loglog(f_ghz, l[:, idx], linestyle='-', label='{:.0f} km'.format(alt/1e3)) 133 | 134 | plt.xlabel('Frequency (GHz)') 135 | plt.ylabel('Zenith Attenuation (dB)') 136 | plt.grid() 137 | plt.legend(loc='NorthWest') 138 | plt.title('Replication of ITU-R P.676-12, Figure 12') 139 | 140 | return fig1, fig2, fig4, fig10, fig11, fig12 141 | -------------------------------------------------------------------------------- /src/ewgeo/aoa/watson_watt.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | import seaborn as sns 4 | 5 | from ewgeo.utils.unit_conversions import db_to_lin 6 | 7 | 8 | def crlb(snr, num_samples): 9 | """ 10 | Compute the lower bound on unbiased estimation error for a Watson - Watt based angle of arrival receiver. 11 | 12 | Ported from MATLAB code. 13 | 14 | Nicholas O'Donoughue 15 | 9 January 2021 16 | 17 | :param snr: signal-to-noise ratio [dB] 18 | :param num_samples: number of samples taken 19 | :return crlb: Cramer-Rao Lower Bound on error variance [psi] 20 | """ 21 | 22 | snr_lin = db_to_lin(snr) 23 | return 1. / (num_samples * snr_lin) 24 | 25 | 26 | def compute_df(r, x, y): 27 | """ 28 | Compute the angle of arrival given a centrally located reference signal, and a pair of Adcock antennas oriented 29 | orthogonally. 30 | 31 | Ported from MATLAB code. 32 | 33 | Nicholas O'Donoughue 34 | 9 January 2021 35 | 36 | :param r: reference signal from centrally located antenna 37 | :param x: test signal from the primary Adcock receiver, oriented in the +x direction (0 degrees) 38 | :param y: test signal from the secondary Adcock receiver, oriented in the +y direction (90 degrees CCW) 39 | :return psi: estimated angle of arrival (radians) 40 | """ 41 | 42 | # Remove reference signal from test data, achieved via a conjugate inner transpose 43 | xx = np.vdot(r, x) 44 | yy = np.vdot(r, y) 45 | 46 | # Results should be V * cos(th) and V * sin(th), use atan2 to solve for th 47 | return np.arctan2(yy.real, xx.real) # output in radians 48 | 49 | 50 | def run_example(mc_params=None): 51 | """ 52 | Test script that demonstrates how to analyze a Watson-Watt DF receiver. 53 | 54 | Ported from MATLAB code. 55 | 56 | Nicholas O'Donoughue 57 | 9 January 2021 58 | 59 | :param mc_params: Optional struct to control Monte Carlo trial size 60 | :return: None 61 | """ 62 | 63 | # Generate the Signals 64 | th_true = 45. 65 | psi_true = np.deg2rad(th_true) 66 | f = 1.0e9 67 | t_samp = 1 / (3 * f) # ensure the Nyquist criteria is satisfied 68 | 69 | # Set up the parameter sweep 70 | num_samples_vec = np.asarray([1., 10., 100.]) # Number of temporal samples at each antenna test point 71 | snr_db_vec = np.arange(start=-10., step=0.2, stop=20.2) # signal-to-noise ratio 72 | num_monte_carlo = 10000 # number of monte carlo trials at each parameter setting 73 | if mc_params is not None: 74 | num_monte_carlo = max(int(num_monte_carlo/mc_params['monte_carlo_decimation']),mc_params['min_num_monte_carlo']) 75 | 76 | # Set up output variables 77 | out_shp = (np.size(num_samples_vec), np.size(snr_db_vec)) 78 | rmse_psi = np.zeros(shape=out_shp) 79 | crlb_psi = np.zeros(shape=out_shp) 80 | 81 | # Loop over parameters 82 | print('Executing Watson Watt Monte Carlo sweep...') 83 | for idx_num_samples, this_num_samples in enumerate(num_samples_vec.tolist()): 84 | this_num_monte_carlo = num_monte_carlo / this_num_samples 85 | print('\t {} samples per estimate...'.format(this_num_samples)) 86 | 87 | # Generate signal vectors 88 | t_vec = np.arange(this_num_samples) * t_samp # Time vector 89 | r0 = np.cos(2 * np.pi * f * t_vec) # Reference signal 90 | y0 = np.sin(psi_true) * r0 91 | x0 = np.cos(psi_true) * r0 92 | 93 | # Generate Monte Carlo Noise with unit power -- generate one for each MC trial 94 | ref_pwr = np.sqrt(np.mean(r0**2)) # root-mean-square of reference signal 95 | noise_base_r = [np.random.normal(loc=0., scale=ref_pwr, size=(this_num_samples, 1)) 96 | for _ in np.arange(this_num_monte_carlo)] 97 | noise_base_x = [np.random.normal(loc=0., scale=ref_pwr, size=(this_num_samples, 1)) 98 | for _ in np.arange(this_num_monte_carlo)] 99 | noise_base_y = [np.random.normal(loc=0., scale=ref_pwr, size=(this_num_samples, 1)) 100 | for _ in np.arange(this_num_monte_carlo)] 101 | 102 | # Loop over SNR levels 103 | for idx_snr, this_snr_db in enumerate(snr_db_vec.tolist()): 104 | if np.mod(idx_snr/10.) == 0: 105 | print('.', end='', flush=True) 106 | 107 | # Compute noise power, scale base noise 108 | noise_amp = np.sqrt(db_to_lin(-this_snr_db)) 109 | 110 | # Generate noisy measurements 111 | r = [r0 + r * noise_amp for r in noise_base_r] 112 | y = [y0 + y * noise_amp for y in noise_base_y] 113 | x = [x0 + x * noise_amp for x in noise_base_x] 114 | 115 | # Compute the estimate for each Monte Carlo trial 116 | psi_est = np.asarray([compute_df(this_r, this_x, this_y) for this_r, this_x, this_y in zip(r, x, y)]) 117 | 118 | # Compute RMS Error 119 | rmse_psi[idx_num_samples, idx_snr] = np.sqrt(np.mean((psi_est - psi_true)**2)) 120 | 121 | # Compute CRLB for RMS Error 122 | crlb_psi[idx_num_samples, idx_snr] = np.abs(crlb(this_snr_db, this_num_samples)) 123 | 124 | print('done.') 125 | 126 | # Generate the plot 127 | sns.set_theme() 128 | 129 | _, _ = plt.subplots() 130 | 131 | crlb_label = 'CRLB' 132 | mc_label = 'Simulation Result' 133 | 134 | for idx_num_samples, this_num_samples in enumerate(num_samples_vec): 135 | if idx_num_samples == 0: 136 | crlb_label = 'CRLB, M={}'.format(this_num_samples) 137 | mc_label = 'Simulation Result, M={}'.format(this_num_samples) 138 | 139 | handle1 = plt.semilogy(snr_db_vec, np.rad2deg(np.sqrt(crlb_psi[idx_num_samples, :])), label=crlb_label) 140 | plt.semilogy(snr_db_vec, np.rad2deg(rmse_psi[idx_num_samples, :]), color=handle1[0].get_color(), 141 | style='--', label=mc_label) 142 | 143 | plt.xlabel(r'$\xi$ [dB]') 144 | plt.ylabel('RMSE [deg]') 145 | plt.title('Watson Watt DF Performance') 146 | plt.legend(loc='lower left') 147 | -------------------------------------------------------------------------------- /src/ewgeo/array_df/solvers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from ewgeo.utils.covariance import CovarianceMatrix 4 | 5 | 6 | def beamscan(x, v, psi_max=np.pi/2, num_points=101): 7 | """ 8 | # Generates a beamscan image for N_pts equally spaced angular coordinates 9 | # from -psi_max to psi_max (in radians), given the input data x, and array 10 | # steering vector v 11 | 12 | Ported from MATLAB Code 13 | 14 | Nicholas O'Donoughue 15 | 18 January 2021 16 | 17 | :param x: N x M data vector 18 | :param v: Steering vector function that returns N point steering vector for each input (in radians). 19 | :param psi_max: Maximum steering angle (radians) 20 | :param num_points: Number of steering angles to compute 21 | :return p: Power image (1 x N_pimts) in linear units 22 | :return psi_vec: Vector of scan angles computed (in radians) 23 | """ 24 | 25 | # Generate scan vector 26 | psi_vec = np.linspace(start=-1, stop=1, num=num_points) * psi_max 27 | 28 | # Parse inputs 29 | num_array_elements, num_samples = np.shape(x) 30 | 31 | # Generate steering vectors 32 | steering_vectors = v(psi_vec)/np.sqrt(num_samples) # num_array_elements x num_points 33 | 34 | # Steer each of the num_samples data samples 35 | # - take the magnitude squared 36 | # - compute the mean across M snapshots 37 | p = np.ravel(np.sum(np.abs(np.conjugate(x).T.dot(steering_vectors))**2, axis=0)/num_samples) 38 | 39 | return p, psi_vec 40 | 41 | 42 | def beamscan_mvdr(x, v, psi_max=np.pi/2, num_points=101): 43 | """ 44 | Generates a beamscan image for N_pts equally spaced angular coordinates 45 | from -psi_max to psi_max (in radians), given the input data x, and array 46 | steering vector v 47 | 48 | Ported from MATLAB Code 49 | 50 | Nicholas O'Donoughue 51 | 18 January 2021 52 | 53 | :param x: N x M data vector 54 | :param v: Steering vector function that returns N point steering vector for each input (in radians). 55 | :param psi_max: Maximum steering angle (radians) 56 | :param num_points: Number of steering angles to compute 57 | :return p: Power image (1 x N_pts) in linear units 58 | :return psi_vec: Vector of scan angles computed (in radians) 59 | """ 60 | 61 | # Generate scan vector 62 | psi_vec = np.linspace(start=-1, stop=1, num=num_points) * psi_max 63 | 64 | # Compute the sample covariance matrix 65 | num_array_elements, num_samples = np.shape(x) 66 | covariance = CovarianceMatrix(np.cov(x), do_inverse=True) 67 | 68 | # Steer each of the M data samples 69 | p = np.zeros(shape=(num_points, )) 70 | for idx_psi in np.arange(num_points): 71 | this_v = v(psi_vec[idx_psi])/np.sqrt(num_array_elements) # N x 1 72 | 73 | # p[idx_psi] = 1/np.abs(np.conj(this_v).T @ covariance.inv @ this_v) 74 | p[idx_psi] = 1/np.abs(covariance.solve_aca(np.conj(this_v).T)[0]) 75 | 76 | return p, psi_vec 77 | 78 | 79 | def music(x, steer, num_sig_dims=0, max_psi=np.pi / 2, num_points=101): 80 | """ 81 | Generates a MUSIC-based image for N_pts equally spaced angular 82 | coordinates from -psi_max to psi_max (in radians), given the input 83 | data x, array steering vector v, and optional number of signals D. 84 | 85 | If left blank, or set to zero, the value D will be estimated using a 86 | simple algorithm that counts the number of eigenvalues greater than twice 87 | the minimum eigenvalue. This will break down in low SNR scenarios. 88 | 89 | Ported from MATLAB Code. 90 | 91 | Nicholas O'Donoughue 92 | 18 January 2021 93 | 94 | :param x: N x M data vector 95 | :param steer: Steering vector function that returns N point steering vector for each input (in radians). 96 | :param num_sig_dims: Number of signals [optional, set to zero to automatically estimate D based on a threshold 97 | eigenvalue twice the minimum eigenvalue] 98 | :param max_psi: Maximum steering angle (radians) 99 | :param num_points: Number of steering angles to compute 100 | :return p: Power image (1 x N_pts) in linear units 101 | :return psi_vec: Vector of scan angles computed (in radians) 102 | """ 103 | 104 | # Compute the sample covariance matrix 105 | n, m = np.shape(x) 106 | # covariance = np.cov(x, bias=True) 107 | covariance = np.zeros((n, n), dtype=complex) 108 | for idx in np.arange(m): 109 | this_x = np.expand_dims(x[:, idx], axis=1) 110 | tmp = this_x @ np.conjugate(this_x.T) 111 | covariance += tmp/m 112 | 113 | # Perform Eigendecomposition of the covariance matrix 114 | lam, eig_vec = np.linalg.eigh(covariance) 115 | 116 | # Sort the eigenvectors and eigenvectors 117 | idx_sort = np.flip(np.argsort(np.abs(lam))) # np.argsort operates in ascending order, reverse it 118 | # lam_sort = np.take_along_axis(lam, idx_sort, axis=0) 119 | eig_vec_sort = np.take_along_axis(eig_vec, np.expand_dims(idx_sort, axis=0), axis=1) 120 | 121 | # Isolate Noise Subspace 122 | if num_sig_dims != 0: 123 | eig_vec_noise = eig_vec_sort[:, num_sig_dims:] 124 | else: 125 | # We need to estimate D first 126 | 127 | # Assume that the noise power is given by the smallest eigenvalue 128 | noise = lam[idx_sort[-1]] 129 | 130 | # Set a threshold of 2x the noise level; and find the eigenvalue that 131 | # first cuts above it 132 | num_sig_dims = np.argwhere(lam >= 2 * noise)[-1] 133 | 134 | eig_vec_noise = eig_vec_sort[:, num_sig_dims:] 135 | 136 | # Noise Subspace Projection 137 | proj = eig_vec_noise.dot(np.conjugate(eig_vec_noise).T) 138 | 139 | # Generate steering vectors 140 | psi_vec = np.linspace(start=-1, stop=1, num=num_points) * max_psi 141 | 142 | p = np.zeros(shape=(num_points, )) 143 | for idx_pt in np.arange(num_points): 144 | # Project the steering vector onto the noise subspace 145 | vv = steer(psi_vec[idx_pt]) / np.sqrt(n) 146 | q = np.conjugate(vv).T.dot(proj).dot(vv) 147 | 148 | # Invert the power 149 | p[idx_pt] = 1/np.abs(q) 150 | 151 | return p, psi_vec 152 | -------------------------------------------------------------------------------- /src/ewgeo/aoa/interferometer.py: -------------------------------------------------------------------------------- 1 | import matplotlib.pyplot as plt 2 | import numpy as np 3 | 4 | from ewgeo.utils.unit_conversions import lin_to_db, db_to_lin 5 | 6 | 7 | def crlb(snr1, snr2, num_samples, d_lam, psi_true): 8 | """ 9 | Computes the lower bound on unbiased estimator error for an 10 | interferometer based direction of arrival receiver with multiple 11 | amplitude samples taken (M samples) 12 | 13 | Ported from MATLAB code. 14 | 15 | Nicholas O'Donoughue 16 | 10 January 2021 17 | 18 | :param snr1: Signal-to-Noise ratio [dB] at receiver 1 19 | :param snr2: Signal-to-Noise ratio [dB] at receiver 2 20 | :param num_samples: Number of samples 21 | :param d_lam: Distance between receivers, divided by the signal wavelength 22 | :param psi_true: Angle of arrival [radians] 23 | :return crlb: Lower bound on the Mean Squared Error of an unbiased estimation of psi (radians) 24 | """ 25 | 26 | # Compute the effective SNR 27 | snr_lin1 = db_to_lin(snr1) 28 | snr_lin2 = db_to_lin(snr2) 29 | snr_eff = 1./(1./snr_lin1 + 1./snr_lin2) 30 | 31 | return (1. / (2. * num_samples * snr_eff)) * (1. / (2. * np.pi * d_lam * np.cos(psi_true)))**2 # output in radians 32 | 33 | 34 | def compute_df(x1, x2, d_lam): 35 | """ 36 | Compute the estimated angle of arrival for an interferometer, given the 37 | complex signal at each of two receivers, and the distance between them. 38 | 39 | Ported from MATLAB code. 40 | 41 | Nicholas O'Donoughue 42 | 10 January 2021 43 | 44 | :param x1: Signal vector from antenna 1 45 | :param x2: Signal vector from antenna 2 46 | :param d_lam: Antenna spacing, divided by the signal wavelength 47 | :return: Estimated angle of arrival [radians] 48 | """ 49 | 50 | # The inner product of the two signals is a sufficient statistic for the 51 | # phase between them, in the presence of a single signal and Gaussian noise 52 | y = np.vdot(x1, x2) 53 | 54 | # Use atan2 to solve for the complex phase angle 55 | phi_est = np.angle(y) 56 | 57 | # Convert from phase angle to angle of arrival 58 | return np.arcsin(phi_est/(2.*np.pi*d_lam)) 59 | 60 | 61 | def run_example(mc_params=None): 62 | """ 63 | Example approach to analyze an interferometer 64 | 65 | Ported from MATLAB code. 66 | 67 | Nicholas O'Donoughue 68 | 10 January 2021 69 | 70 | :param mc_params: Optional struct to control Monte Carlo trial size 71 | :return: None 72 | """ 73 | 74 | # Generate the Signals 75 | th_true = 45 # angle (degrees) 76 | d_lam = .5 77 | psi_true = np.deg2rad(th_true) # angle (radians) 78 | phi = 2*np.pi*d_lam*np.sin(psi_true) # interferometer phase 79 | alpha = 1 # power scale 80 | 81 | # Set up the parameter sweep 82 | num_samples_vec = np.asarray([1., 10., 100.]) # Number of temporal samples at each antenna test point 83 | snr_db_vec = np.arange(start=-10., step=0.2, stop=20.2) # signal-to-noise ratio 84 | num_monte_carlo = 10000 # number of monte carlo trials at each parameter setting 85 | if mc_params is not None: 86 | num_monte_carlo = max(int(num_monte_carlo/mc_params['monte_carlo_decimation']),mc_params['min_num_monte_carlo']) 87 | 88 | # Set up output variables 89 | out_shp = (np.size(num_samples_vec), np.size(snr_db_vec)) 90 | rmse_psi = np.zeros(shape=out_shp) 91 | crlb_psi = np.zeros(shape=out_shp) 92 | 93 | # Loop over parameters 94 | print('Executing Interferometer Monte Carlo sweep...') 95 | for idx_num_samples, this_num_samples in enumerate(num_samples_vec.tolist()): 96 | this_num_monte_carlo = num_monte_carlo / this_num_samples 97 | print('\t {} samples per estimate...'.format(this_num_samples)) 98 | 99 | # Generate Signals 100 | iq_amp = np.sqrt(2)/2 101 | s1 = [np.random.normal(loc=0.0, scale=iq_amp, size=(this_num_samples, 2)).view(np.complex128) 102 | for _ in np.arange(this_num_monte_carlo)] 103 | s2 = [alpha*this_s1*np.exp(1j*phi) for this_s1 in s1] 104 | 105 | # Generate Noise 106 | noise_base1 = [np.random.normal(loc=0.0, scale=iq_amp, size=(this_num_samples, 2)).view(np.complex128) 107 | for _ in np.arange(this_num_monte_carlo)] 108 | noise_base2 = [np.random.normal(loc=0.0, scale=iq_amp, size=(this_num_samples, 2)).view(np.complex128) 109 | for _ in np.arange(this_num_monte_carlo)] 110 | 111 | # Loop over SNR levels 112 | for idx_snr, this_snr_db in enumerate(snr_db_vec.tolist()): 113 | if np.mod(idx_snr, 10) == 0: 114 | print('.', end='', flush=True) 115 | 116 | # Compute noise power, scale base noise 117 | noise_pwr = db_to_lin(-this_snr_db) 118 | 119 | # Generate noisy signals 120 | x1 = [this_s1 + np.sqrt(noise_pwr)*this_noise for (this_s1, this_noise) in zip(s1, noise_base1)] 121 | x2 = [this_s2 + np.sqrt(noise_pwr)*this_noise for (this_s2, this_noise) in zip(s2, noise_base2)] 122 | 123 | # Compute the estimate for each Monte Carlo trial 124 | psi_est = np.asarray([compute_df(this_x1, this_x2, d_lam) for (this_x1, this_x2) in zip(x1, x2)]) 125 | 126 | # Compute RMS Error 127 | rmse_psi[idx_num_samples, idx_snr] = np.sqrt(np.mean((psi_est-psi_true)**2)) 128 | 129 | # Compute CRLB for RMS Error 130 | crlb_psi[idx_num_samples, idx_snr] = crlb(this_snr_db, this_snr_db + lin_to_db(alpha ** 2), 131 | this_num_samples, d_lam, psi_true) 132 | 133 | print('done.') 134 | 135 | _, _ = plt.subplots() 136 | 137 | for idx_num_samples, this_num_samples in enumerate(num_samples_vec): 138 | crlb_label = 'CRLB, M={}'.format(this_num_samples) 139 | mc_label = 'Simulation Result, M={}'.format(this_num_samples) 140 | 141 | # Plot the MC and CRLB results for this number of samples 142 | handle1 = plt.semilogy(snr_db_vec, np.rad2deg(np.sqrt(crlb_psi[idx_num_samples, :])), label=crlb_label) 143 | plt.semilogy(snr_db_vec, np.rad2deg(rmse_psi[idx_num_samples, :]), color=handle1[0].get_color(), 144 | style='--', label=mc_label) 145 | 146 | plt.xlabel(r'$\xi$ [dB]') 147 | plt.ylabel('RMSE [deg]') 148 | plt.title('Interferometer DF Performance') 149 | plt.legend(loc='lower left') 150 | -------------------------------------------------------------------------------- /src/ewgeo/detector/xcorr.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import scipy.stats as stats 3 | 4 | from . import squareLaw 5 | import ewgeo.prop as prop 6 | from ewgeo.utils.unit_conversions import lin_to_db, db_to_lin 7 | 8 | 9 | 10 | def det_test(y1, y2, noise_var, num_samples, prob_fa): 11 | """ 12 | Apply cross-correlation to determine whether a signal (y2) is present or 13 | absent in the provided received data vector (y1). 14 | 15 | Ported from MATLAB Code 16 | 17 | Nicholas O'Donoughue 18 | 18 January 2021 19 | 20 | :param y1: Data vector (MxN) 21 | :param y2: Desired signal (Mx1) 22 | :param noise_var: Variance of the noise in y1 23 | :param num_samples: Number of samples in y1 24 | :param prob_fa: Acceptable probability of false alarm 25 | :return: Array of N binary detection results 26 | """ 27 | 28 | # Compute the sufficient statistic 29 | sigma_0_sq = num_samples * noise_var ** 2 / 2 30 | suff_stat = np.absolute(np.sum(np.conjugate(y1)*y2, axis=0))**2 / sigma_0_sq 31 | 32 | # Compute the threshold 33 | eta = stats.chi2.ppf(q=1-prob_fa, df=2) 34 | 35 | # Compare T to eta 36 | det_result = np.array(suff_stat > eta) 37 | 38 | # In the rare event that T==eta, flip a weighted coin 39 | coin_flip_mask = suff_stat == eta 40 | num_flips = np.sum(coin_flip_mask, axis=None) 41 | 42 | if num_flips > 0: 43 | coin_flip_result = np.random.uniform(low=0., high=1., size=(num_flips,)) > (1 - prob_fa) 44 | 45 | if np.isscalar(det_result): 46 | det_result = coin_flip_result[0] 47 | else: 48 | det_result[coin_flip_mask] = coin_flip_result 49 | 50 | return det_result 51 | 52 | 53 | def min_sinr(prob_fa, prob_d, corr_time, pulse_duration, bw_noise, bw_signal): 54 | """ 55 | Compute the required SNR to achieve the desired probability of detection, 56 | given the maximum acceptable probability of false alarm, and the number 57 | of complex samples M. 58 | 59 | The returned SNR is the ratio of signal power to complex noise power. 60 | 61 | Ported from MATLAB Code. 62 | 63 | Nicholas O'Donoughue 64 | 18 January 2021 65 | 66 | :param prob_fa: Probability of False Alarm [0-1] 67 | :param prob_d: Probability of Detection [0-1] 68 | :param corr_time: Correlation time [sec] 69 | :param pulse_duration: Pulse Duration [sec] 70 | :param bw_noise: Noise bandwidth [Hz] 71 | :param bw_signal: Signal Bandwidth [Hz] 72 | :return: Signal-to-Noise ratio [dB] 73 | """ 74 | 75 | # Make sure the signal bandwidth and time are observable 76 | bw_signal = np.minimum(bw_signal, bw_noise) 77 | pulse_duration = np.minimum(pulse_duration, corr_time) 78 | num_samples = np.fix(corr_time * bw_noise) 79 | 80 | # Find the min SNR after cross-correlation processing 81 | xi_min_out = squareLaw.min_sinr(prob_fa, prob_d, num_samples) 82 | 83 | # Invert the SNR Gain equation 84 | xi_out_lin = db_to_lin(xi_min_out) 85 | xi_in_lin = (xi_out_lin + np.sqrt(xi_out_lin * (xi_out_lin + bw_signal * corr_time))) / (pulse_duration * bw_signal) 86 | xi = lin_to_db(xi_in_lin) 87 | 88 | return xi 89 | 90 | 91 | def max_range(prob_fa, prob_d, corr_time, pulse_duration, bw_noise, bw_signal, f0, ht, hr, snr0, include_atm_loss=False, 92 | atmosphere=None): 93 | """ 94 | Compute the maximum range for a square law detector, as specified by the 95 | PD, PFA, and number of samples (M). The link is described by the carrier 96 | frequency (f0), and transmit/receive antenna heights (ht and hr), and the 97 | transmitter and receiver are specified by the SNR in the absence of path 98 | loss (SNR0). If specified, the atmospheric struct is passed onto the 99 | path loss model. 100 | 101 | Ported from MATLAB Code 102 | 103 | Nicholas O'Donoughue 104 | 18 January 2021 105 | 106 | :param prob_fa: Probability of False Alarm [0-1] 107 | :param prob_d: Probability of Detection [0-1] 108 | :param corr_time: Correlation time [sec] 109 | :param pulse_duration: Pulse Duration [sec] 110 | :param bw_noise: Noise bandwidth [Hz] 111 | :param bw_signal: Signal Bandwidth [Hz] 112 | :param f0: Carrier Frequency [Hz] 113 | :param ht: Transmitter height [m] 114 | :param hr: Receiver height [m] 115 | :param snr0: SNR in the absence of path loss [dB] 116 | :param include_atm_loss: Boolean flag indicating whether atmospheric loss should be considered in path loss 117 | [Default = false] 118 | :param atmosphere: (Optional) struct containing atmospheric parameters; can be called from atm.standardAtmosphere(). 119 | :return: Maximum range at which the specified PD/PFA condition can be met [m] 120 | """ 121 | 122 | # Find the required SNR Threshold 123 | snr_min = min_sinr(prob_fa, prob_d, corr_time, pulse_duration, bw_noise, bw_signal) 124 | 125 | max_range_val = np.zeros(np.shape(snr_min)) 126 | 127 | for idx_snr_min, this_snr_min in enumerate(snr_min): 128 | if np.size(snr0) > 1: 129 | this_snr0 = snr0[idx_snr_min] 130 | else: 131 | this_snr0 = snr0 132 | 133 | # Find the acceptable propagation loss 134 | prop_loss_max = this_snr0 - this_snr_min 135 | 136 | # Set up error function 137 | def prop_loss(r): 138 | return prop.model.get_path_loss(range_m=r, freq_hz=f0, tx_ht_m=ht, rx_ht_m=hr, 139 | include_atm_loss=include_atm_loss, atmosphere=atmosphere) 140 | 141 | def err_fun(r): 142 | return prop_loss(r) - prop_loss_max 143 | 144 | # Set up initial search point 145 | this_r = 1e3 146 | err = err_fun(this_r) 147 | 148 | # Optimization Parameters 149 | err_tol = .01 # SNR error tolerance [dB] 150 | max_iter = 1000 # Maximum number of iterations 151 | iter_num = 0 # Iteration counter 152 | 153 | # Perform the optimization 154 | while iter_num < max_iter and abs(err) > err_tol: 155 | # Compute derivative 156 | d_r = 1 # 1 meter 157 | y1 = err_fun(this_r+d_r) 158 | y0 = err_fun(this_r-d_r) 159 | df = (y1-y0)/(2*d_r) 160 | 161 | # Error Checking for Flat Derivative to avoid divide by zero 162 | # when calculating step size 163 | if df == 0: 164 | df = 1 165 | 166 | # Newton Method step 167 | this_r = this_r - err/df 168 | err = err_fun(this_r) 169 | 170 | # Iteration count 171 | iter_num += 1 172 | 173 | max_range_val[idx_snr_min] = this_r 174 | 175 | return max_range_val 176 | 177 | -------------------------------------------------------------------------------- /src/ewgeo/noise/model.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | import ewgeo.atm as atm 4 | from ewgeo.utils.unit_conversions import db_to_lin, lin_to_db 5 | from ewgeo.utils.constants import boltzmann, ref_temp 6 | 7 | 8 | def get_thermal_noise(bandwidth_hz, noise_figure_db=0, temp_ext_k=0): 9 | """ 10 | N = thermal_noise(bw,nf,t_ext) 11 | 12 | Compute the total noise power, given the receiver's noise bandwidth, noise figure, and external noise temperature. 13 | 14 | Ported from MATLAB Code 15 | 16 | Nicholas O'Donoughue 17 | 15 March 2021 18 | 19 | :param bandwidth_hz: Receiver noise bandwidth [Hz] 20 | :param noise_figure_db: Receiver noise figure [dB] (DEFAULT = 0 dB) 21 | :param temp_ext_k: External noise temp [K] (DEFAULT = 0 K) 22 | :return: Thermal noise power [dBW] 23 | """ 24 | 25 | # Add the external noise temp to the reference temp (270 K) 26 | temp = ref_temp + temp_ext_k 27 | 28 | # Boltzmann's Constant 29 | k = boltzmann 30 | 31 | # Equation (D.6) 32 | return lin_to_db(k * temp * bandwidth_hz) + noise_figure_db 33 | 34 | 35 | def get_atmospheric_noise_temp(freq_hz, alt_start_m=0, el_angle_deg=90): 36 | """ 37 | Computes the noise temperature contribution from the reradaition of 38 | energy absorbed by the atmosphere in the direction of the antenna's 39 | mainlobe. 40 | 41 | Ported from MATLAB code. 42 | 43 | Nicholas O'Donoughue 44 | 15 March 2021 45 | 46 | :param freq_hz: Frequency [Hz] 47 | :param alt_start_m: Altitude of receiver [m] 48 | :param el_angle_deg: Elevation angle of receive mainbeam [degrees above local ground plane] 49 | :return: Atmospheric noise temperature [K] 50 | """ 51 | 52 | # Assume integrated antenna gain is unity 53 | alpha_a = 1 54 | 55 | # Compute zenith loss along main propagation path 56 | zenith_angle_deg = (90-el_angle_deg) 57 | loss_db, _, _ = atm.model.calc_zenith_loss(freq_hz=freq_hz, alt_start_m=alt_start_m, 58 | zenith_angle_deg=zenith_angle_deg) 59 | loss_lin = db_to_lin(loss_db) 60 | 61 | # Compute average atmospheric temp 62 | alt_bands = np.arange(start=alt_start_m, stop=100.0e3+100, step=100) 63 | atmosphere = atm.reference.get_standard_atmosphere(alt_bands) 64 | t_atmos = np.mean(atmosphere.temp) 65 | # t_atmos = T0; 66 | 67 | # Equation D.12 68 | return alpha_a * t_atmos * (1-1/loss_lin) 69 | 70 | 71 | def get_sun_noise_temp(freq_hz): 72 | """ 73 | Returns the noise temp (in Kelvin) for the sun at the specified 74 | frequency f (in Hertz). f can be a scalar, or N-dimensional matrix. 75 | 76 | Assumes a quiet sun, and represents a rough approximation from ITU 77 | documentation on radio noise. Sun noise can be several orders of 78 | magnitude larger during solar disturbances. 79 | 80 | Ref: Rec. ITU-R P.372-14 81 | 82 | Ported from MATLAB Code 83 | 84 | Nicholas O'Donoughue 85 | 15 March 2021 86 | 87 | :param freq_hz: Carrier frequency [Hz] 88 | :return: Sun noise temp [K] 89 | """ 90 | 91 | # Based on a visual reading on Figure 12 and the corresponding text 92 | f_ghz = np.hstack((np.array([.05, .2]), np.arange(start=1, stop=10, step=1), 93 | np.arange(start=10, step=10, stop=110))) 94 | t_ref = np.asarray([1e6, 1e6, 2e5, 9e4, 4.5e4, 2.9e4, 2e4, 1.6e4, 1.4e4, 1.3e4, 1.2e4, 1e4, 7e3, 6.3e3, 95 | 6.2e3, 6e3, 6e3, 6e3, 6e3, 6e3, 6e3]) 96 | 97 | # Perform linear interpolation 98 | return np.interp(xp=f_ghz, fp=t_ref, x=freq_hz/1e9, left=0, right=0) 99 | 100 | 101 | def get_moon_noise_temp(): 102 | """ 103 | Returns the noise temp (in Kelvin) for the moon. 104 | 105 | The moon noise temp is fairly constant across spectrum, with ~140 K during new moon phase and ~280 K during at full 106 | moon. Using the arithmatic mean here as an approximate value. 107 | 108 | Ported from MATLAB Code 109 | 110 | Ref: Rec. ITU-R P.372-8 111 | 112 | Nicholas O'Donoughue 113 | 15 March 2021 114 | 115 | :return: Moon noise temp [K] 116 | """ 117 | 118 | return (140 + 280)/2 119 | 120 | 121 | def get_cosmic_noise_temp(freq_hz, rx_alt_m=0, alpha_c=0.95, gain_sun_dbi=-np.inf, gain_moon_dbi=-np.inf): 122 | """ 123 | Computes the combined cosmic noise temperature, including contributions from the sun, the moon, and the galactic 124 | background. Includes approximate effect of atmospheric loss (sun and moon are treated as coming from zenith), 125 | rather than their true angles. 126 | 127 | Ported from MATLAB Code 128 | 129 | Nicholas O'Donoughue 130 | 15 March 2021 131 | 132 | :param freq_hz: Carrier frequency [Hz] 133 | :param rx_alt_m: Receiver altitude [m] 134 | :param alpha_c: Fraction of the antenna's receive pattern that is above the horizon [0-1] 135 | :param gain_sun_dbi: Antenna gain directed at the sun [dBi] 136 | :param gain_moon_dbi: Antenna gain directed at the moon [dBi] 137 | :return: Combined cosmic noise temperature [K] 138 | """ 139 | 140 | # Compute Raw Noise Temp 141 | temp_100_mhz = 3050 # Geometric mean of 100 MHz noise spectrum samples 142 | 143 | temp_cosmic = temp_100_mhz * (100e6 / freq_hz) ** 2.5 + 2.7 144 | temp_sun = get_sun_noise_temp(freq_hz) 145 | temp_moon = get_moon_noise_temp() 146 | 147 | # Above 2 GHz, the only contribution is from cosmic background radiation 148 | # (2.7 K), which is essentially negligible. 149 | high_freq_mask = freq_hz >= 2e9 150 | np.place(temp_cosmic, high_freq_mask, 2.7) 151 | 152 | # Apply Antenna Patterns 153 | gain_sun_lin = db_to_lin(gain_sun_dbi) 154 | gain_moon_lin = db_to_lin(gain_moon_dbi) 155 | 156 | init_temp = (temp_cosmic * alpha_c) + (temp_sun * 4.75e-6 * gain_sun_lin) + (temp_moon * 4.75e-6 * gain_moon_lin) 157 | 158 | # Compute Atmospheric Losses for Zenith Path at pi/4 (45 deg from zenith) 159 | zenith_loss_db, _, _ = atm.model.calc_zenith_loss(freq_hz, rx_alt_m, np.pi / 4) 160 | zenith_loss_lin = db_to_lin(np.reshape(zenith_loss_db, np.shape(freq_hz))) 161 | 162 | # Apply Atmospheric Loss to combined galactic noise temp 163 | return init_temp / zenith_loss_lin 164 | 165 | 166 | def get_ground_noise_temp(ant_gain_ground_dbi=-5, ground_emissivity=1, angular_area=np.pi): 167 | """ 168 | Compute the combined noise temperature from ground effects; predominantly caused by reradiation of thermal energy 169 | from the sun. 170 | 171 | Ported from MATLAB Code 172 | 173 | Nicholas O'Donoughue 174 | 15 March 2021 175 | 176 | :param ant_gain_ground_dbi: Average antenna gain in direction of the ground [dBi] (DEFAULT = -5 dBi) 177 | :param ground_emissivity: Emissivity of ground (Default = 1) 178 | :param angular_area: Area (in steradians) of ground as visible from antenna (DEFAULT = pi) 179 | :return: Ground noise temperature [K] 180 | """ 181 | 182 | # Convert average ground antenna gain to linear units 183 | gain_lin = db_to_lin(ant_gain_ground_dbi) 184 | 185 | # Assume ground temp is 290 K (ref temp) 186 | thermal_temp_ground = ref_temp 187 | 188 | # Compute ground noise temp according to (D.13) 189 | return angular_area * gain_lin * ground_emissivity * thermal_temp_ground / (4*np.pi) 190 | -------------------------------------------------------------------------------- /make_figures/practical_geo/chapter2.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 2 3 | 4 | This script generates all the figures that appear in Chapter 2 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 9 January 2025 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | 14 | from ewgeo.utils import init_output_dir, init_plot_style 15 | 16 | from examples.practical_geo import chapter2 17 | from make_figures import chapter10 18 | from make_figures import chapter11 19 | from make_figures import chapter12 20 | from make_figures import chapter13 21 | 22 | 23 | def make_all_figures(close_figs=False, mc_params=None): 24 | """ 25 | Call all the figure generators for this chapter 26 | 27 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 28 | Default=False 29 | :param mc_params: Optional struct to control Monte Carlo trial size 30 | :return: List of figure handles 31 | """ 32 | 33 | # Reset the random number generator, to ensure reproducibility 34 | # rng = np.random.default_rng() 35 | 36 | # Find the output directory 37 | prefix = init_output_dir('practical_geo/chapter2') 38 | init_plot_style() 39 | 40 | # Generate all figures 41 | fig1 = make_figure_1(prefix) 42 | fig2 = make_figure_2(prefix) 43 | fig3 = make_figure_3(prefix) 44 | fig4 = make_figure_4(prefix) 45 | fig5, fig6a, fig6b, fig6c, fig6d = make_figures_5_6(prefix, mc_params) 46 | fig8a, fig8b = make_figure_8(prefix, mc_params) 47 | fig10a, fig10b = make_figure_10(prefix, mc_params) 48 | 49 | figs = [fig1, fig2, fig3, fig4, fig5, fig6a, fig6b, fig6c, fig6d, fig8a, fig8b, fig10a, fig10b] 50 | if close_figs: 51 | for fig in figs: 52 | plt.close(fig) 53 | 54 | return None 55 | else: 56 | plt.show() 57 | 58 | return figs 59 | 60 | 61 | def make_figure_1(prefix=None): 62 | """ 63 | Figure 1 64 | 65 | :param prefix: output directory 66 | :return: figure handle 67 | """ 68 | 69 | print('Generating Figure 2.1...') 70 | fig1 = chapter10.make_figure_2(prefix=None) 71 | 72 | # Display the plot 73 | plt.draw() 74 | 75 | # Output to file 76 | if prefix is not None: 77 | fig1.savefig(prefix + 'fig1.svg') 78 | fig1.savefig(prefix + 'fig1.png') 79 | 80 | return fig1 81 | 82 | 83 | def make_figure_2(prefix=None): 84 | """ 85 | Figure 2, TDOA Example. A reprint of Figure 11.1b from the 2019 text. 86 | 87 | :param prefix: output directory 88 | :return: figure handle 89 | """ 90 | 91 | print('Generating Figure 2.2...') 92 | 93 | _, fig2 = chapter11.make_figure_1(prefix=None) 94 | 95 | # Display the plot 96 | plt.draw() 97 | 98 | # Output to file 99 | if prefix is not None: 100 | fig2.savefig(prefix + 'fig2.svg') 101 | fig2.savefig(prefix + 'fig2.png') 102 | 103 | return fig2 104 | 105 | 106 | def make_figure_3(prefix=None): 107 | """ 108 | Figure 3, FDOA Example. Recreation of Figure 12.1 from 2019 text. 109 | 110 | :param prefix: output directory 111 | :return: figure handle 112 | """ 113 | 114 | print('Generating Figure 2.3...') 115 | fig3 = chapter12.make_figure_1(prefix=None) 116 | 117 | # Display the plot 118 | plt.draw() 119 | 120 | # Output to file 121 | if prefix is not None: 122 | fig3.savefig(prefix + 'fig3.svg') 123 | fig3.savefig(prefix + 'fig3.png') 124 | 125 | return fig3 126 | 127 | 128 | def make_figure_4(prefix=None): 129 | """ 130 | Figure 4 131 | 132 | :param prefix: output directory 133 | :return: figure handle 134 | """ 135 | 136 | print('Generating Figure 2.4...') 137 | fig4 = chapter13.make_figure_1(prefix=None) 138 | 139 | # Display the plot 140 | plt.draw() 141 | 142 | # Output to file 143 | if prefix is not None: 144 | fig4.savefig(prefix + 'fig4.svg') 145 | fig4.savefig(prefix + 'fig4.png') 146 | 147 | return fig4 148 | 149 | 150 | def make_figures_5_6(prefix=None, mc_params=None): 151 | """ 152 | Figures 5 and 6(a, b, c, d) 153 | 154 | :param prefix: output directory to place generated figure 155 | :param mc_params: Optional struct to control Monte Carlo trial size 156 | :return: figure handle 157 | """ 158 | 159 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 160 | print('Skipping Figures 2.5, 2.6a, 2.6b, 2.6c, and 2.6d (re-run with mc_params[\'force_recalc\']=True to generate)...') 161 | return None, None, None, None, None 162 | 163 | print('Generating Figures 2.5, 2.6a, 2.6b, 2.6c, and 2.6d (using Example 2.1)...') 164 | 165 | figs = chapter2.example1() 166 | 167 | # Display the plot 168 | plt.draw() 169 | 170 | # Output to file 171 | if prefix is not None: 172 | labels = ['fig5', 'fig6a', 'fig6b', 'fig6c', 'fig6d'] 173 | if len(labels) != len(figs): 174 | print('**Error saving figures 2.5 and 2.6; unexpected number of figures returned from Example 2.1.') 175 | else: 176 | for fig, label in zip(figs, labels): 177 | fig.savefig(prefix + label + '.svg') 178 | fig.savefig(prefix + label + '.png') 179 | 180 | return figs 181 | 182 | 183 | def make_figure_8(prefix=None, mc_params=None): 184 | """ 185 | Figure 8 186 | 187 | :param prefix: output directory to place generated figure 188 | :param mc_params: Optional struct to control Monte Carlo trial size 189 | :return: figure handle 190 | """ 191 | 192 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 193 | print('Skipping Figures 2.8a and 2.8b (re-run with mc_params[\'force_recalc\']=True to generate)...') 194 | return None, None 195 | 196 | print('Generating Figure 2.8a and 2.8b...') 197 | 198 | fig8a, fig8b = chapter2.example2() 199 | 200 | # Display the plot 201 | plt.draw() 202 | 203 | # Output to file 204 | if prefix is not None: 205 | fig8a.savefig(prefix + 'fig8a.svg') 206 | fig8a.savefig(prefix + 'fig8a.png') 207 | 208 | fig8b.savefig(prefix + 'fig8b.svg') 209 | fig8b.savefig(prefix + 'fig8b.png') 210 | 211 | return fig8a, fig8b 212 | 213 | 214 | def make_figure_10(prefix=None, mc_params=None): 215 | """ 216 | Figure 10 217 | 218 | :param prefix: output directory to place generated figure 219 | :param mc_params: Optional struct to control Monte Carlo trial size 220 | :return: figure handle 221 | """ 222 | 223 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 224 | print('Skipping Figures 2.10a and 2.10b (re-run with mc_params[\'force_recalc\']=True to generate)...') 225 | return None, None 226 | 227 | print('Generating Figures 2.10a and 2.10b (using Example 2.3)...') 228 | 229 | fig10a, fig10b = chapter2.example3() 230 | 231 | # Display the plot 232 | plt.draw() 233 | 234 | # Output to file 235 | if prefix is not None: 236 | fig10a.savefig(prefix + 'fig10a.svg') 237 | fig10a.savefig(prefix + 'fig10a.png') 238 | 239 | fig10b.savefig(prefix + 'fig10b.svg') 240 | fig10b.savefig(prefix + 'fig10b.png') 241 | 242 | return fig10a, fig10b 243 | 244 | 245 | if __name__ == "__main__": 246 | make_all_figures(close_figs=False, mc_params={'force_recalc': True, 'monte_carlo_decimation': 1, 'min_num_monte_carlo': 1}) 247 | -------------------------------------------------------------------------------- /src/ewgeo/detector/squareLaw.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import scipy.stats as stats 3 | import warnings 4 | 5 | import ewgeo.prop as prop 6 | from ewgeo.utils.unit_conversions import db_to_lin 7 | 8 | 9 | def det_test(z, noise_var, prob_fa): 10 | """ 11 | Compute detection via the square law and return binary detection events 12 | 13 | Ported from MATLAB Code 14 | 15 | Nicholas O'Donoughue 16 | 18 January 2021 17 | 18 | :param z: Input signal, (MxN) for M samples per detection event, and N separate test 19 | :param noise_var: Noise variance on input signal 20 | :param prob_fa: Acceptable probability of false alarm 21 | :return detResult: Array of N binary detection results 22 | """ 23 | 24 | # Compute the sufficient statistic 25 | suff_stat = np.sum(np.absolute(z)**2, axis=0)/noise_var 26 | 27 | # Compute the threshold 28 | eta = stats.chi2.ppf(q=1-prob_fa, df=2*np.shape(z)[0]) 29 | 30 | # Compare T to eta 31 | det_result = np.greater(suff_stat, eta) 32 | 33 | # In the rare event that T==eta, flip a weighted coin 34 | coin_flip_mask = suff_stat == eta 35 | coin_flip_result = np.random.uniform(low=0., high=1.) > (1-prob_fa) 36 | 37 | np.putmask(det_result, mask=coin_flip_mask, values=coin_flip_result) 38 | 39 | return det_result 40 | 41 | 42 | def min_sinr(prob_fa, prob_d, num_samples): 43 | """ 44 | Compute the required SNR to achieve the desired probability of detection, 45 | given the maximum acceptable probability of false alarm, and the number 46 | of complex samples M. 47 | 48 | The returned SNR is the ratio of signal power to complex noise power. 49 | 50 | Ported from MATLAB Code. 51 | 52 | Nicholas O'Donoughue 53 | 18 January 2021 54 | 55 | :param prob_fa: Probability of False Alarm 56 | :param prob_d: Probability of Detection 57 | :param num_samples: Number of samples collected 58 | :return xi: Required input signal-to-noise ratio [dB] 59 | """ 60 | 61 | eta = stats.chi2.ppf(q=1 - prob_fa, df=2 * num_samples) 62 | 63 | if np.isscalar(eta): 64 | eta = np.array([eta]) 65 | 66 | xi = np.zeros(np.shape(eta)) 67 | for ii, this_eta in enumerate(eta): 68 | if np.size(num_samples) > 1: 69 | this_m = num_samples[ii] 70 | else: 71 | this_m = num_samples 72 | 73 | if np.size(prob_d) > 1: 74 | this_pd = prob_d[ii] 75 | else: 76 | this_pd = prob_d 77 | 78 | # Set up function for probability of detection and error calculation 79 | def pd_fun(x): 80 | return stats.ncx2.sf(x=this_eta, df=2*this_m, nc=2*this_m*db_to_lin(x)) # Xi is in dB 81 | 82 | def err_fun(x): 83 | return pd_fun(x) - this_pd 84 | 85 | # Initial Search Value 86 | this_xi = 0.0 # Start at 0 dB 87 | err = err_fun(this_xi) # Compute the difference between PD and desired PD 88 | 89 | # Initialize Search Parameters 90 | err_tol = .0001 # Desired PD error tolerance 91 | max_iter = 1000 # Maximum number of iterations 92 | idx = 0 # Current iteration number 93 | max_step = .5 # Maximum Step Size [dB] - to prevent badly scaled results when PD is near 0 or 1 94 | 95 | # Perform optimization 96 | while abs(err) > err_tol and idx < max_iter: 97 | # Compute derivative at the current test point 98 | dxi = .01 # dB 99 | y0 = err_fun(this_xi-dxi) 100 | y1 = err_fun(this_xi+dxi) 101 | df = (y1-y0)/(2*dxi) 102 | 103 | # Error Checking for Flat Derivative to avoid divide by zero 104 | # when calculating step size 105 | if df == 0: 106 | df = 1 107 | 108 | # Newton-Rhapson Step Size 109 | step = -err/df 110 | 111 | # Ensure that the step size is not overly large 112 | if abs(step) > max_step: 113 | step = np.sign(step)*max_step 114 | 115 | # Iterate the Newton Approximation 116 | this_xi = this_xi + step 117 | err = err_fun(this_xi) 118 | 119 | # Increment the iteration counter 120 | idx += 1 121 | 122 | if idx >= max_iter: 123 | warnings.warn('Computation finished before suitable tolerance achieved.' 124 | ' Error = {:.6f}'.format(np.fabs(err))) 125 | 126 | xi[ii] = this_xi 127 | 128 | return xi 129 | 130 | 131 | def max_range(prob_fa, prob_d, num_samples, f0, ht, hr, snr0, include_atm_loss=False, atm_struct=None): 132 | """ 133 | Compute the maximum range for a square law detector, as specified by the 134 | PD, PFA, and number of samples (M). The link is described by the carrier 135 | frequency (f0), and transmit/receive antenna heights (ht and hr), and the 136 | transmitter and receiver are specified by the SNR in the absence of path 137 | loss (SNR0). If specified, the atmospheric struct is passed onto the 138 | path loss model. 139 | 140 | :param prob_fa: Probability of False Alarm 141 | :param prob_d: Probability of Detection 142 | :param num_samples: Number of samples collected 143 | :param f0: Carrier frequency [Hz] 144 | :param ht: Transmitter height [m] 145 | :param hr: Receiver height [m] 146 | :param snr0: Signal-to-Noise ratio [dB] without path loss 147 | :param include_atm_loss: Binary flag determining whether atmospheric loss is to be included. [Default=False] 148 | :param atm_struct: (Optional) struct containing fields that specify atmospherics parameters. See 149 | atm.standardAtmosphere(). 150 | :return range: Maximum range at which square law detector can achieve the PD/PFA test point. 151 | """ 152 | 153 | # Find the required SNR Threshold 154 | snr_min = min_sinr(prob_fa, prob_d, num_samples) 155 | 156 | max_range_vec = np.zeros_like(snr_min) 157 | 158 | for idx_snr, this_snr_min in enumerate(snr_min): 159 | if np.size(snr0) > 1: 160 | this_snr0 = snr0[idx_snr] 161 | else: 162 | this_snr0 = snr0 163 | 164 | # Find the acceptable propagation loss 165 | prop_loss_max = this_snr0 - this_snr_min 166 | 167 | # Set up error function 168 | def prop_loss_fun(r): 169 | return prop.model.get_path_loss(r, f0, ht, hr, include_atm_loss, atm_struct) 170 | 171 | def err_fun(r): 172 | return prop_loss_fun(r) - prop_loss_max 173 | 174 | # Set up initial search point 175 | this_range = 1e3 176 | err = err_fun(this_range) 177 | 178 | # Optimization Parameters 179 | err_tol = .01 # SNR error tolerance [dB] 180 | max_iter = 1000 # Maximum number of iterations 181 | iter_num = 0 # Iteration counter 182 | 183 | # Perform the optimization 184 | while iter_num < max_iter and np.fabs(err) > err_tol: 185 | # Compute derivative 186 | range_deriv = 1 # 1 meter 187 | y1 = err_fun(this_range+range_deriv) 188 | y0 = err_fun(this_range-range_deriv) 189 | df = (y1-y0)/(2*range_deriv) 190 | 191 | # Newton Method step 192 | this_range = this_range - err/df 193 | err = err_fun(this_range) 194 | 195 | # Iteration count 196 | iter_num += 1 197 | 198 | max_range_vec[idx_snr] = this_range 199 | 200 | return max_range_vec 201 | -------------------------------------------------------------------------------- /src/ewgeo/fdoa/solvers.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import model 4 | from ewgeo.utils import make_pdfs, SearchSpace 5 | from ewgeo.utils.covariance import CovarianceMatrix 6 | from ewgeo.utils.solvers import bestfix_solver, gd_solver, ls_solver, ml_solver 7 | 8 | 9 | def max_likelihood(x_sensor, v_sensor, zeta, cov: CovarianceMatrix, search_space: SearchSpace, ref_idx=None, 10 | do_resample=False, bias=None, **kwargs): 11 | """ 12 | Construct the ML Estimate by systematically evaluating the log 13 | likelihood function at a series of coordinates, and returning the index 14 | of the maximum. Optionally returns the full set of evaluated 15 | coordinates, as well. 16 | 17 | :param x_sensor: Sensor positions [m] 18 | :param v_sensor: Sensor velocities [m/s] 19 | :param zeta: Measurement vector [Hz] 20 | :param cov: Measurement error covariance matrix 21 | :param ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings 22 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx 23 | :param bias: measurement bias (optional) 24 | :return x_est: Estimated source position [m] 25 | :return likelihood: Likelihood computed across the entire set of candidate source positions 26 | :return x_grid: Candidate source positions 27 | """ 28 | 29 | # Resample the covariance matrix 30 | if do_resample: 31 | cov = cov.resample(ref_idx=ref_idx) 32 | 33 | # Set up function handle 34 | def ell(x, **ell_kwargs): 35 | return model.log_likelihood(x_sensor=x_sensor, v_sensor=v_sensor, rho_dot=zeta, cov=cov, 36 | x_source=x, v_source=None, ref_idx=ref_idx, do_resample=False, bias=bias, 37 | **ell_kwargs) 38 | 39 | # Call the util function 40 | x_est, likelihood, x_grid = ml_solver(ell=ell, search_space=search_space, **kwargs) 41 | 42 | return x_est, likelihood, x_grid 43 | 44 | 45 | def gradient_descent(x_sensor, v_sensor, zeta, cov: CovarianceMatrix, x_init, v_source=None, ref_idx=None, 46 | do_resample=False, bias=None, **kwargs): 47 | """ 48 | Computes the gradient descent solution for FDOA processing. 49 | 50 | Ported from MATLAB code. 51 | 52 | Nicholas O'Donoughue 53 | 21 February 2021 54 | 55 | :param x_sensor: FDOA sensor positions [m] 56 | :param v_sensor: FDOA sensor velocities [m/s] 57 | :param zeta: Measurement vector 58 | :param cov: FDOA error covariance matrix 59 | :param x_init: Initial estimate of source position [m] 60 | :param v_source: Source velocity (assumed to be true) [m/s] 61 | :param ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings 62 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx 63 | :return x: Estimated source position 64 | :return x_full: Iteration-by-iteration estimated source positions 65 | """ 66 | 67 | # Initialize measurement error and jacobian functions 68 | def y(this_x): 69 | return zeta - model.measurement(x_sensor=x_sensor, v_sensor=v_sensor, 70 | x_source=this_x, v_source=v_source, ref_idx=ref_idx, bias=bias) 71 | 72 | def jacobian(this_x): 73 | return model.jacobian(x_sensor=x_sensor, v_sensor=v_sensor, 74 | x_source=this_x, v_source=v_source, 75 | ref_idx=ref_idx) 76 | 77 | # Resample the covariance matrix 78 | if do_resample: 79 | cov = cov.resample(ref_idx=ref_idx) 80 | 81 | # Call generic Gradient Descent solver 82 | x, x_full = gd_solver(y, jacobian, cov, x_init, **kwargs) 83 | 84 | return x, x_full 85 | 86 | 87 | def least_square(x_sensor, v_sensor, zeta, cov: CovarianceMatrix, x_init, ref_idx=None, do_resample=False, 88 | bias=None, **kwargs): 89 | """ 90 | Computes the least square solution for FDOA processing. 91 | 92 | Ported from MATLAB Code 93 | 94 | Nicholas O'Donoughue 95 | 21 February 2021 96 | 97 | :param x_sensor: Sensor positions [m] 98 | :param v_sensor: Sensor velocities [m/s] 99 | :param zeta: Range Rate-Difference Measurements [m/s] 100 | :param cov: Measurement Error Covariance Matrix [(m/s)^2] 101 | :param x_init: Initial estimate of source position [m] 102 | :param ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings 103 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx 104 | :return x: Estimated source position 105 | :return x_full: Iteration-by-iteration estimated source positions 106 | """ 107 | 108 | # Initialize measurement error and Jacobian function handles 109 | def y(this_x): 110 | return zeta - model.measurement(x_sensor=x_sensor, v_sensor=v_sensor, 111 | x_source=this_x, v_source=None, 112 | ref_idx=ref_idx, bias=bias) 113 | 114 | def jacobian(this_x): 115 | return model.jacobian(x_sensor=x_sensor, v_sensor=v_sensor, 116 | x_source=this_x, v_source=None, 117 | ref_idx=ref_idx) 118 | 119 | # Resample the covariance matrix 120 | if do_resample: 121 | cov = cov.resample(ref_idx=ref_idx) 122 | 123 | # Call the generic Least Square solver 124 | x, x_full = ls_solver(y, jacobian, cov, x_init, **kwargs) 125 | 126 | return x, x_full 127 | 128 | 129 | def bestfix(x_sensor, v_sensor, zeta, cov: CovarianceMatrix, search_space: SearchSpace, ref_idx=None, pdf_type=None, 130 | do_resample=False): 131 | """ 132 | Construct the BestFix estimate by systematically evaluating the PDF at 133 | a series of coordinates, and returning the index of the maximum. 134 | Optionally returns the full set of evaluated coordinates, as well. 135 | 136 | Assumes a multi-variate Gaussian distribution with covariance matrix C, 137 | and unbiased estimates at each sensor. Note that the BestFix algorithm 138 | implicitly assumes each measurement is independent, so any cross-terms in 139 | the covariance matrix C are ignored. 140 | 141 | Ref: 142 | Eric Hodson, "Method and arrangement for probabilistic determination of 143 | a target location," U.S. Patent US5045860A, 1990, https://patents.google.com/patent/US5045860A 144 | 145 | Ported from MATLAB Code 146 | 147 | Nicholas O'Donoughue 148 | 21 February 2021 149 | 150 | :param x_sensor: Sensor positions [m] 151 | :param v_sensor: Sensor velocities [m/s] 152 | :param zeta: Measurement vector [Hz] 153 | :param cov: Measurement error covariance matrix 154 | :param ref_idx: Scalar index of reference sensor, or nDim x nPair matrix of sensor pairings 155 | :param pdf_type: String indicating the type of distribution to use. See +utils/makePDFs.m for options. 156 | :param do_resample: Boolean flag; if true the covariance matrix will be resampled, using ref_idx 157 | :return x_est: Estimated source position [m] 158 | :return likelihood: Likelihood computed across the entire set of candidate source positions 159 | :return x_grid: Candidate source positions 160 | """ 161 | 162 | # Resample the covariance matrix 163 | if do_resample: 164 | cov = cov.resample(ref_idx=ref_idx) 165 | 166 | # Make sure that rho is a vector -- the pdf functions choke if the mean value 167 | # is a Nx1 matrix 168 | zeta = np.squeeze(zeta) 169 | 170 | # Generate the PDF 171 | def msmt(x): 172 | # We have to squeeze rho, so let's also squeeze msmt 173 | return np.squeeze(model.measurement(x_sensor=x_sensor, v_sensor=v_sensor, 174 | x_source=x, v_source=None, ref_idx=ref_idx)) 175 | 176 | pdfs = make_pdfs(msmt, zeta, pdf_type, cov.cov) 177 | 178 | # Call the util function 179 | x_est, likelihood, x_grid = bestfix_solver(pdfs, search_space) 180 | 181 | return x_est, likelihood, x_grid 182 | 183 | -------------------------------------------------------------------------------- /make_figures/appendixD.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Appendix D 3 | 4 | This script generates all the figures that appear in Appendix D of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 8 December 2022 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | import ewgeo.atm as atm 16 | import ewgeo.noise as noise 17 | from ewgeo.utils import init_output_dir, init_plot_style 18 | from ewgeo.utils.constants import ref_temp 19 | 20 | 21 | def make_all_figures(close_figs=False): 22 | """ 23 | Call all the figure generators for this chapter 24 | 25 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 26 | Default=False 27 | :return: List of figure handles 28 | """ 29 | 30 | # Find the output directory 31 | prefix = init_output_dir('appendixD') 32 | init_plot_style() 33 | 34 | # Random Number Generator 35 | # rng = np.random.default_rng(0) 36 | 37 | # Colormap 38 | # colors = plt.get_cmap("tab10") 39 | 40 | # Generate all figures 41 | fig1 = make_figure_1(prefix) 42 | fig2 = make_figure_2(prefix) 43 | fig3 = make_figure_3(prefix) 44 | fig4 = make_figure_4(prefix) 45 | 46 | figs = [fig1, fig2, fig3, fig4] 47 | if close_figs: 48 | for fig in figs: 49 | plt.close(fig) 50 | 51 | return None 52 | else: 53 | plt.show() 54 | 55 | return figs 56 | 57 | 58 | def make_figure_1(prefix=None): 59 | """ 60 | Figure 1 - Noise vs. Noise Temp 61 | 62 | Ported from MATLAB Code 63 | 64 | Nicholas O'Donoughue 65 | 8 December 2022 66 | 67 | :param prefix: output directory to place generated figure 68 | :return: figure handle 69 | """ 70 | print('Generating Figure D.1...') 71 | 72 | t_ext = np.arange(300.) 73 | # t_total = ref_temp + t_ext 74 | 75 | bw = 1e3 76 | noise_total = noise.model.get_thermal_noise(bandwidth_hz=bw, temp_ext_k=t_ext) 77 | noise_ref = noise.model.get_thermal_noise(bandwidth_hz=bw) 78 | 79 | # Open figure 80 | fig1 = plt.figure() 81 | plt.semilogx(t_ext, noise_total-noise_ref) 82 | plt.xlabel('Combined Sky Noise Temperature [K]') 83 | plt.ylabel('Increase in Noise Level [dB]') 84 | plt.grid(True) 85 | 86 | if prefix is not None: 87 | plt.savefig(prefix + 'fig1.png') 88 | plt.savefig(prefix + 'fig1.svg') 89 | 90 | return fig1 91 | 92 | 93 | def make_figure_2(prefix=None): 94 | """ 95 | Figure 2 - Cosmic Noise 96 | 97 | Ported from MATLAB Code 98 | 99 | Nicholas O'Donoughue 100 | 8 December 2022 101 | 102 | :param prefix: output directory to place generated figure 103 | :return: figure handle 104 | """ 105 | 106 | print('Generating Figure D.2...') 107 | # Plot cosmic noise [dB] as a function of frequency for a fixed bandwidth 108 | # (a) without solar/lunar gain 109 | # (c) with solar gain = 30 dBi 110 | # (e) with lunar gain = 30 dBi 111 | 112 | freq = np.arange(start=100e6, step=100e6, stop=1e10) 113 | freq_ghz = freq/1e9 114 | noise_temp = noise.model.get_cosmic_noise_temp(freq_hz=freq, rx_alt_m=0, alpha_c=.95) 115 | noise_temp_sun = noise.model.get_cosmic_noise_temp(freq_hz=freq, rx_alt_m=0, alpha_c=.95, gain_sun_dbi=30) 116 | noise_temp_moon = noise.model.get_cosmic_noise_temp(freq_hz=freq, rx_alt_m=0, alpha_c=.95, gain_moon_dbi=30) 117 | 118 | fig2, ax = plt.subplots() 119 | plt.loglog(freq_ghz, noise_temp, linestyle='-', linewidth=1, label='Cosmic Noise') 120 | plt.loglog(freq_ghz, noise_temp_sun, linewidth=1, label='Sun Noise') 121 | plt.loglog(freq_ghz, noise_temp_moon, linewidth=1, label='Moon Noise') 122 | plt.loglog(freq_ghz, ref_temp*np.ones_like(freq), linestyle=':', linewidth=1, label='Thermal Noise') 123 | plt.loglog(freq_ghz, ref_temp + noise_temp, linestyle='-.', linewidth=1, label='Thermal + Cosmic Noise') 124 | plt.loglog(freq_ghz, ref_temp + noise_temp_sun, linestyle='-.', linewidth=1, label='Thermal + Sun Noise') 125 | plt.loglog(freq_ghz, ref_temp + noise_temp_moon, linestyle='-.', linewidth=1, label='Thermal + Moon Noise') 126 | 127 | plt.text(.7, 200, 'Impact of cosmic noise', fontsize=9) 128 | ax.annotate("", xy=(1, 1e3), xytext=(1, ref_temp), arrowprops=dict(arrowstyle="->", color="k")) 129 | plt.text(.45, 3, 'Sidelobe Cosmic Noise', fontsize=9) 130 | plt.text(1.5, 9, 'Mainbeam pointed at Moon', fontsize=9) 131 | plt.text(1, 75, 'Mainbeam pointed at Sun', fontsize=9) 132 | plt.legend(loc='upper left') 133 | plt.xlabel('Frequency [GHz]') 134 | plt.ylabel('Noise Temperature [K]') 135 | 136 | if prefix is not None: 137 | fig2.savefig(prefix + 'fig2.png') 138 | fig2.savefig(prefix + 'fig2.svg') 139 | 140 | return fig2 141 | 142 | 143 | def make_figure_3(prefix=None): 144 | """ 145 | Figure 3 - Atmospheric Noise 146 | 147 | Ported from MATLAB Code 148 | 149 | Nicholas O'Donoughue 150 | 8 December 2022 151 | 152 | :param prefix: output directory to place generated figure 153 | :return: figure handle 154 | """ 155 | import warnings 156 | 157 | print('Generating Figure D.3...') 158 | 159 | zenith_angle_deg = np.array([0, 10, 30, 60]) 160 | 161 | # Set up frequencies 162 | fo, fw = atm.reference.get_spectral_lines() 163 | freq_vec = np.sort(np.concatenate((fo, fw, fo + 50e6, fw + 50e6, fo - 100e6, fw - 100e6, 164 | np.arange(start=1e9, step=1e9, stop=350e9)), 165 | axis=0)) 166 | freq_ghz = freq_vec/1e9 167 | 168 | # Open the figure 169 | fig3, ax = plt.subplots() 170 | plt.semilogx(freq_ghz, ref_temp*np.ones_like(freq_vec), linestyle=':', label='Thermal Noise') 171 | degree_sign = u'\N{DEGREE SIGN}' 172 | thermal_plus_noise_label = 'Thermal + Atmospheric Noise' 173 | 174 | # Iterate over zenith angles 175 | for this_zenith in zenith_angle_deg: 176 | with warnings.catch_warnings(): 177 | warnings.simplefilter("ignore") # This sets an overflow warning in db_to_lin; ignore it 178 | ta = noise.model.get_atmospheric_noise_temp(freq_hz=freq_vec, alt_start_m=0, el_angle_deg=90-this_zenith) 179 | 180 | handle = plt.semilogx(freq_ghz, ta, label='{}{} from Zenith'.format(this_zenith, degree_sign)) 181 | plt.semilogx(freq_ghz, ref_temp + ta, linestyle='-.', color=handle[0].get_color(), 182 | label=thermal_plus_noise_label) 183 | thermal_plus_noise_label = None # clear the label, so we only get one entry in the legend 184 | 185 | plt.xlabel('Freq [GHz]') 186 | plt.ylabel('Noise Temperature[K]') 187 | plt.xlim([1, 350]) 188 | plt.legend(loc='upper left') 189 | 190 | plt.text(60, ref_temp+10, 'Impact of Atmospheric Noise', fontsize=9) 191 | ax.annotate("", xy=(60, 525), xytext=(60, ref_temp), arrowprops=dict(arrowstyle="->", color="k")) 192 | 193 | if prefix is not None: 194 | fig3.savefig(prefix + 'fig3.png') 195 | fig3.savefig(prefix + 'fig3.svg') 196 | 197 | return fig3 198 | 199 | 200 | def make_figure_4(prefix=None): 201 | """ 202 | Figure 4 - Ground Noise 203 | 204 | Ported from MATLAB Code 205 | 206 | Nicholas O'Donoughue 207 | 8 December 2022 208 | 209 | :param prefix: output directory to place generated figure 210 | :return: figure handle 211 | """ 212 | 213 | print('Generating Figure D.4...') 214 | 215 | ground_ant_gain_dbi = np.arange(start=-30, stop=0) 216 | 217 | ground_noise_temp = noise.model.get_ground_noise_temp(ant_gain_ground_dbi=ground_ant_gain_dbi) 218 | 219 | fig4, ax = plt.subplots() 220 | plt.plot(ground_ant_gain_dbi, ground_noise_temp, label='Ground Noise') 221 | plt.plot(ground_ant_gain_dbi, ref_temp * np.ones_like(ground_ant_gain_dbi), linestyle=':', label='Thermal Noise') 222 | plt.plot(ground_ant_gain_dbi, ground_noise_temp+ref_temp, linestyle='-.', label='Thermal + Ground Noise') 223 | plt.xlabel('Average Ground Antenna Gain [dBi]') 224 | plt.ylabel('Noise Temperature [K]') 225 | plt.legend(loc='upper left') 226 | 227 | # Annotation 228 | plt.text(-8, 270, 'Impact of Ground Noise', fontsize=9) 229 | ax.annotate("", xy=(-3, 330), xytext=(-3, ref_temp), arrowprops=dict(arrowstyle="->", color="k")) 230 | 231 | if prefix is not None: 232 | fig4.savefig(prefix + 'fig4.png') 233 | fig4.savefig(prefix + 'fig4.svg') 234 | 235 | return fig4 236 | 237 | 238 | if __name__ == "__main__": 239 | make_all_figures(close_figs=False) 240 | -------------------------------------------------------------------------------- /make_figures/practical_geo/chapter3.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 3 3 | 4 | This script generates all the figures that appear in Chapter 3 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 17 January 2025 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | import ewgeo.tdoa as tdoa 16 | from ewgeo.utils import init_output_dir, init_plot_style 17 | from ewgeo.utils.geo import calc_range_diff 18 | 19 | from examples.practical_geo import chapter3 20 | 21 | 22 | def make_all_figures(close_figs=False, mc_params=None): 23 | """ 24 | Call all the figure generators for this chapter 25 | 26 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 27 | Default=False 28 | :param mc_params: Optional struct to control Monte Carlo trial size 29 | :return: List of figure handles 30 | """ 31 | 32 | # Reset the random number generator, to ensure reproducibility 33 | # rng = np.random.default_rng() 34 | 35 | # Find the output directory 36 | prefix = init_output_dir('practical_geo/chapter3') 37 | init_plot_style() 38 | 39 | # Generate all figures 40 | fig1 = make_figure_1(prefix) 41 | fig2 = make_figure_2(prefix) 42 | figs_4_5 = make_figures_4_5(prefix) 43 | figs_6_7 = make_figures_6_7(prefix) 44 | figs_9_10 = make_figures_9_10(prefix) 45 | figs_11_12 = make_figures_11_12(prefix, mc_params) 46 | 47 | figs = [fig1, fig2] + list(figs_4_5) + list(figs_6_7) + list(figs_9_10) + list(figs_11_12) 48 | if close_figs: 49 | for fig in figs: 50 | plt.close(fig) 51 | 52 | return None 53 | else: 54 | plt.show() 55 | 56 | return figs 57 | 58 | 59 | def make_figure_1(prefix=None): 60 | """ 61 | Figure 1 62 | 63 | :param prefix: output directory to place generated figure 64 | :return: handle 65 | """ 66 | 67 | print('Generating Figure 3.1...') 68 | 69 | # Define source and sensor positions 70 | x_source = np.array([2, 3]) 71 | x_sensor = np.array([[0, -1, 0, 1], [0, -.5, 1, -.5]]) 72 | 73 | # Plot sensor / source positions 74 | fig = plt.figure() 75 | plt.scatter(x_source[0], x_source[1], marker='^', label='Source', clip_on=False, zorder=3) 76 | plt.scatter(x_sensor[0, :], x_sensor[1, :], marker='o', label='Sensor', clip_on=False, zorder=3) 77 | 78 | # Generate Isochrones 79 | isochrone_label = 'Isochrones' 80 | ref_idx = 0 81 | x_ref = x_sensor[:, ref_idx] 82 | 83 | for test_idx in np.arange(start=ref_idx+1, stop=4): 84 | x_test = x_sensor[:, test_idx] 85 | 86 | # TODO: Make sure test/ref indices are used consistently. Should be test-ref for TDOA and FDOA 87 | 88 | rdiff = calc_range_diff(x_source, x_ref, x_test) 89 | xy_iso = tdoa.model.draw_isochrone(x_test, x_ref, rdiff, 10000, 5) 90 | 91 | plt.plot(xy_iso[0], xy_iso[1], '--', label=isochrone_label) 92 | isochrone_label = None # set label to none after first use, so only one shows up in the plot legend 93 | 94 | plt.xlim([-1, 3]) 95 | plt.ylim([-1, 3]) 96 | plt.legend(loc='upper left') 97 | 98 | if prefix is not None: 99 | fig.savefig(prefix + 'fig1.svg') 100 | fig.savefig(prefix + 'fig1.png') 101 | 102 | return fig 103 | 104 | 105 | def make_figure_2(prefix=None): 106 | """ 107 | Figure 2 108 | 109 | :param prefix: output directory to place generated figure 110 | :return: handle 111 | """ 112 | 113 | print('Generating Figure 3.2...') 114 | 115 | # Define source and sensor positions 116 | x_source = np.array([2, 3]) 117 | x_sensor = np.array([[0, -1, 0, 1], [0, -.5, 1, -.5]]) 118 | 119 | # Plot sensor / source positions 120 | fig = plt.figure() 121 | plt.scatter(x_source[0], x_source[1], marker='^', label='Source', clip_on=False, zorder=3) 122 | plt.scatter(x_sensor[0, :], x_sensor[1, :], marker='o', label='Sensor', clip_on=False, zorder=3) 123 | 124 | # Generate Isochrones 125 | isochrone_label = 'Isochrones' 126 | for ref_idx in np.arange(3): 127 | x_ref = x_sensor[:, ref_idx] 128 | 129 | for test_idx in np.arange(start=ref_idx+1, stop=4): 130 | x_test = x_sensor[:, test_idx] 131 | 132 | rdiff = calc_range_diff(x_source, x_ref, x_test) 133 | xy_iso = tdoa.model.draw_isochrone(x_test, x_ref, rdiff, 10000, 5) 134 | 135 | plt.plot(xy_iso[0], xy_iso[1], '--', label=isochrone_label) 136 | isochrone_label = None # set label to none after first use, so only one shows up in the plot legend 137 | 138 | plt.xlim([-1, 3]) 139 | plt.ylim([-1, 3]) 140 | plt.legend(loc='upper left') 141 | 142 | if prefix is not None: 143 | fig.savefig(prefix + 'fig2.svg') 144 | fig.savefig(prefix + 'fig2.png') 145 | 146 | return fig 147 | 148 | 149 | def make_figures_4_5(prefix=None): 150 | """ 151 | Figure 3.4 and 3.5 152 | 153 | :param prefix: output directory to place generated figure 154 | :return: handle 155 | """ 156 | 157 | print('Generating Figures 3.4a, 3.4b, 3.5...') 158 | 159 | figs = chapter3.example1() 160 | 161 | # Display the plot 162 | plt.draw() 163 | 164 | # Output to file 165 | if prefix is not None: 166 | labels = ['fig4a', 'fig4b', 'fig5'] 167 | if len(labels) != len(figs): 168 | print('**Error saving figures 3.4 and 3.5; unexpected number of figures returned from Example 3.1.') 169 | else: 170 | for fig, label in zip(figs, labels): 171 | fig.savefig(prefix + label + '.svg') 172 | fig.savefig(prefix + label + '.png') 173 | 174 | return figs 175 | 176 | 177 | def make_figures_6_7(prefix=None): 178 | """ 179 | Figure 3.6 & 3.7 180 | 181 | :param prefix: output directory to place generated figure 182 | :return: handle 183 | """ 184 | 185 | print('Generating Figures 3.6, 3.7a, 3.7b, 3.7c, 3.7d, 3.7e (Example 3.2)...') 186 | 187 | figs = chapter3.example2() 188 | 189 | # Display the plot 190 | plt.draw() 191 | 192 | # Output to file 193 | if prefix is not None: 194 | labels = ['fig6', 'fig7a', 'fig7b', 'fig7c', 'fig7d', 'fig7e'] 195 | if len(labels) != len(figs): 196 | print('**Error saving figures 3.6 and 3.7; unexpected number of figures returned from Example 3.2.') 197 | else: 198 | for fig, label in zip(figs, labels): 199 | fig.savefig(prefix + label + '.svg') 200 | fig.savefig(prefix + label + '.png') 201 | 202 | return figs 203 | 204 | 205 | def make_figures_9_10(prefix=None): 206 | """ 207 | Figures 3.9 & 3.10 208 | 209 | :param prefix: output directory to place generated figure 210 | :return: handle 211 | """ 212 | 213 | print('Generating Figures 3.9, 3.10a, 3.10b, 3.10c (Example 3.3)...') 214 | 215 | figs = chapter3.example3() 216 | 217 | # Display the plot 218 | plt.draw() 219 | 220 | # Output to file 221 | if prefix is not None: 222 | labels = ['fig9', 'fig10a', 'fig10b', 'fig10c'] 223 | if len(labels) != len(figs): 224 | print('**Error saving figures 3.9 and 3.10; unexpected number of figures returned from Example 3.3.') 225 | else: 226 | for fig, label in zip(figs, labels): 227 | fig.savefig(prefix + label + '.svg') 228 | fig.savefig(prefix + label + '.png') 229 | 230 | return figs 231 | 232 | 233 | def make_figures_11_12(prefix=None, mc_params=None): 234 | """ 235 | Figures 3.11 and 3.12 236 | 237 | :param prefix: output directory to place generated figure 238 | :param mc_params: Optional struct to control Monte Carlo trial size 239 | :return: figure handle 240 | """ 241 | 242 | if mc_params is not None and 'force_recalc' in mc_params and not mc_params['force_recalc']: 243 | print('Skipping Figures 3.11, and 3.12 (re-run with mc_params[\'force_recalc\']=True to generate)...') 244 | return None, None 245 | 246 | print('Generating Figure 3.11, 3.12 (Example 3.4)...') 247 | 248 | figs = chapter3.example4(mc_params=mc_params) 249 | 250 | # Display the plot 251 | plt.draw() 252 | 253 | # Output to file 254 | if prefix is not None: 255 | labels = ['fig11', 'fig12'] 256 | if len(labels) != len(figs): 257 | print('**Error saving figures 3.11 and 3.12; unexpected number of figures returned from Example 3.4.') 258 | else: 259 | for fig, label in zip(figs, labels): 260 | fig.savefig(prefix + label + '.svg') 261 | fig.savefig(prefix + label + '.png') 262 | 263 | return figs 264 | 265 | 266 | if __name__ == "__main__": 267 | make_all_figures(close_figs=False, mc_params={'force_recalc': True, 'monte_carlo_decimation': 1, 'min_num_monte_carlo': 1}) 268 | -------------------------------------------------------------------------------- /src/ewgeo/prop/model.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | import ewgeo.atm as atm 4 | from ewgeo.utils.constants import speed_of_light, radius_earth_eff, radius_earth_true 5 | 6 | 7 | def get_path_loss(range_m, freq_hz, tx_ht_m, rx_ht_m, include_atm_loss=True, atmosphere=None): 8 | """ 9 | Computes the propagation loss according to a piece-wise model where free space is used at close range, and 10 | two-ray is used at long range. The cross-over range between the two is the Fresnel Zone. 11 | 12 | Ported from MATLAB Code 13 | 14 | Nicholas O'Donoughue 15 | 21 March 2021 16 | 17 | :param range_m: Range of link [m] 18 | :param freq_hz: Carrier frequency [Hz] 19 | :param tx_ht_m: Transmitter height [m] 20 | :param rx_ht_m: Receiver height [m] 21 | :param include_atm_loss: Boolean flag. If true (default) then atmospheric absorption is modeled [Default=True] 22 | :param atmosphere: Atmospheric loss parameter struct, must match the format expected by calcAtmLoss. If blank, then 23 | a standard atmosphere will be used. 24 | :return: Path Loss [dB] 25 | """ 26 | 27 | # Find the fresnel zone distance 28 | fz = get_fresnel_zone(freq_hz, tx_ht_m, rx_ht_m) 29 | 30 | # Compute free space path loss - w/out atmospherics 31 | loss_free_space = get_free_space_path_loss(range_m, freq_hz, False) 32 | loss_two_ray = get_two_ray_path_loss(range_m, freq_hz, tx_ht_m, rx_ht_m, False) 33 | broadcast_out = np.broadcast(loss_free_space, loss_two_ray) 34 | 35 | # Combine the free space and two ray path loss calculations, using binary singleton expansion to handle non-uniform 36 | # parameter sizes, so long as all non-singleton dimension match, this will succeed. 37 | free_space_mask = range_m < fz 38 | two_ray_mask = np.logical_not(free_space_mask) 39 | 40 | loss_path = np.zeros(shape=broadcast_out.shape) 41 | loss_path[free_space_mask] = loss_free_space[free_space_mask] 42 | loss_path[two_ray_mask] = loss_two_ray[two_ray_mask] 43 | 44 | if include_atm_loss: 45 | if atmosphere is None: 46 | atmosphere = atm.reference.get_standard_atmosphere(np.sort(np.unique((tx_ht_m, rx_ht_m)))) 47 | 48 | loss_atmosphere = atm.model.calc_atm_loss(freq_hz, gas_path_len_m=range_m, atmosphere=atmosphere) 49 | else: 50 | loss_atmosphere = 0 51 | 52 | return loss_path + loss_atmosphere 53 | 54 | 55 | def get_two_ray_path_loss(range_m, freq_hz, height_tx_m, height_rx_m=None, include_atm_loss=True, atmosphere=None): 56 | """ 57 | Computes the two-ray path loss according to 58 | L = 10*log10(R^4/(h_t^2*h_r^2)) 59 | 60 | This model is generally deemed appropriate for low altitude transmitters and receivers, with a flat Earth model. 61 | 62 | If the input includeAtmLoss is True (default), then a call is also made to the loss_atmosphere function, and the 63 | total loss is returned. 64 | 65 | Ported from MATLAB Code 66 | 67 | Nicholas O'Donoughue 68 | 21 March 2021 69 | 70 | :param range_m: Range [m] 71 | :param freq_hz: Carrier frequency [Hz] 72 | :param height_tx_m: Height of the transmitter [m] 73 | :param height_rx_m: Height of the receiver [m] 74 | :param include_atm_loss: Boolean flag indicating whether atmospheric loss should be included (Default = True) 75 | :param atmosphere: Optional atmosphere, to be used for atmospheric loss. 76 | :return: Path Loss [dB] 77 | """ 78 | 79 | if height_rx_m is None: 80 | height_rx_m = height_tx_m 81 | 82 | # Two-Ray Path Loss 83 | loss_two_ray = 10*np.log10(range_m ** 4 / (height_tx_m ** 2 * height_rx_m ** 2)) 84 | 85 | if include_atm_loss: 86 | if atmosphere is None: 87 | atmosphere = atm.reference.get_standard_atmosphere(np.sort(np.unique(height_tx_m, height_rx_m))) 88 | 89 | loss_atmosphere = atm.model.calc_atm_loss(freq_hz, gas_path_len_m=range_m, atmosphere=atmosphere) 90 | else: 91 | loss_atmosphere = 0 92 | 93 | return loss_two_ray + loss_atmosphere 94 | 95 | 96 | def get_knife_edge_path_loss(dist_tx_m, dist_rx_m, ht_above_los): 97 | """ 98 | Knife Edge path loss. 99 | 100 | Ported from MATLAB Code 101 | 102 | Nicholas O'Donoughue 103 | 21 March 2021 104 | 105 | :param dist_tx_m: Distance from the transmitter to the obstruction [m] 106 | :param dist_rx_m: Distance from the obstruction to the receiver [m] 107 | :param ht_above_los: Vertical distance between the top of the obstruction and the line of sight between the 108 | transmitter and receiver [m] 109 | :return: Path loss [dB] 110 | """ 111 | 112 | # Equation (B.5) 113 | nu = ht_above_los * np.sqrt(2) / (1 + dist_tx_m / dist_rx_m) 114 | 115 | # Initialize the output loss matrix 116 | loss_knife = np.zeros_like(nu) 117 | 118 | # First piece-wise component of (B.6) 119 | np.place(loss_knife, nu <= 0, 0) 120 | 121 | # Second piece-wise component of (B.6) 122 | mask = nu > 0 & nu <= 2.4 123 | loss_knife[mask] = 6+9*nu[mask]-1.27*nu[mask]**2 124 | 125 | # Third piece-wise component of (B.6) 126 | mask = nu > 2.4 127 | loss_knife[mask] = 13 + 20*np.log10(nu[mask]) 128 | 129 | return loss_knife 130 | 131 | 132 | def get_free_space_path_loss(range_m, freq_hz, include_atm_loss=True, atmosphere=None, height_tx_m=None, 133 | height_rx_m=None): 134 | """ 135 | Computes the free space path loss according to: 136 | L = 20*log10(4*pi*R/lambda) 137 | 138 | If the field includeAtmLoss is set to true (default), then atmospheric loss is computed for the path and returned, 139 | in addition to the path loss. 140 | 141 | Ported from MATLAB Code 142 | 143 | Nicholas O'Donoughue 144 | 21 March 2021 145 | 146 | :param range_m: Range [m] 147 | :param freq_hz: Carrier frequency [Hz] 148 | :param include_atm_loss: Boolean flag indicating whether atmospheric loss should be included (Default = True) 149 | :param atmosphere: Atmospheric loss parameter struct, must match the format expected by calcAtmLoss. 150 | :param height_tx_m: Optional transmitter altitude (used for atmospheric loss); default=0 [m] 151 | :param height_rx_m: Optional receiver altitude (used for atmospheric loss); default=0 [m] 152 | :return: Path loss [dB] 153 | """ 154 | 155 | # Convert from frequency to wavelength [m] 156 | lam = speed_of_light / freq_hz 157 | 158 | # Equation (B.1) 159 | loss_free_space = 20*np.log10(4 * np.pi * range_m / lam) 160 | 161 | # Add atmospheric loss, if called for 162 | if include_atm_loss: 163 | if atmosphere is None: 164 | atmosphere = atm.reference.get_standard_atmosphere(np.sort(np.unique((height_tx_m, height_rx_m)))) 165 | 166 | loss_atmosphere = atm.model.calc_atm_loss(freq_hz, gas_path_len_m=range_m, atmosphere=atmosphere) 167 | else: 168 | loss_atmosphere = 0 169 | 170 | return loss_free_space+loss_atmosphere 171 | 172 | 173 | def get_fresnel_zone(f0, ht, hr): 174 | """ 175 | Computes the Fresnel Zone for a given transmission, given by the equation: 176 | FZ = 4*pi*h_t*h_r / lambda 177 | 178 | Ported from MATLAB Code 179 | 180 | Nicholas O'Donoughue 181 | 21 March 2021 182 | 183 | :param f0: Carrier frequency [Hz] 184 | :param ht: Transmitter altitude [m] 185 | :param hr: Receiver altitude [m] 186 | :return: Fresnel Zone range [m] 187 | """ 188 | 189 | # Convert the carrier frequency to wavelength [m] 190 | lam = speed_of_light/f0 191 | 192 | # Equation (B.3) 193 | return 4*np.pi*ht*hr/lam 194 | 195 | 196 | def compute_radar_horizon(h1, h2, use_four_thirds_radius=True): 197 | """ 198 | Computes the radar horizon for transmitter and receiver at the given distances above a smooth, round Earth. It does 199 | not take into consideration terrain, or the Earth's ellipticity. 200 | 201 | Uses the approximation: R = (2 * h * Re + h^2)^0.5, where h is the observer height above the Earth's surface, 202 | and Re is the Earth's radius. 203 | 204 | Leverages the (4/3) Earth radius approximation common for electromagnetic propagation by default. 205 | 206 | Ref: Doerry, Armin Walter. Earth curvature and atmospheric refraction effects on radar signal propagation. 207 | doi:10.2172/1088060. 208 | 209 | Ported from MATLAB Code 210 | 211 | Nicholas O'Donoughue 212 | 21 March 2021 213 | 214 | :param h1: Height of transmitter [m] 215 | :param h2: Height of receiver [m] 216 | :param use_four_thirds_radius: Boolean flag. If True, will use 4/3 Earth radius approximation 217 | :return: Radar horizon [m] 218 | """ 219 | 220 | if use_four_thirds_radius: 221 | radius_earth = radius_earth_eff 222 | else: 223 | radius_earth = radius_earth_true 224 | 225 | range_1 = np.sqrt(2*h1*radius_earth + h1**2) 226 | range_2 = np.sqrt(2*h2*radius_earth + h2**2) 227 | 228 | return range_1 + range_2 229 | -------------------------------------------------------------------------------- /make_figures/chapter6.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 6 3 | 4 | This script generates all the figures that appear in Chapter 6 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 25 March 2021 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | from ewgeo.utils import init_output_dir, init_plot_style 16 | 17 | 18 | def make_all_figures(close_figs=False): 19 | """ 20 | Call all the figure generators for this chapter 21 | 22 | :close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 23 | Default=False 24 | :return: List of figure handles 25 | """ 26 | 27 | # Find the output directory 28 | prefix = init_output_dir('chapter6') 29 | init_plot_style() 30 | 31 | # Generate all figures 32 | fig1 = make_figure_1(prefix) 33 | fig2 = make_figure_2(prefix) 34 | fig3 = make_figure_3(prefix) 35 | fig4 = make_figure_4(prefix) 36 | 37 | figs = [fig1, fig2, fig3, fig4] 38 | 39 | if close_figs: 40 | for fig in figs: 41 | plt.close(fig) 42 | 43 | return None 44 | else: 45 | plt.show() 46 | 47 | return figs 48 | 49 | 50 | def make_figure_1(prefix=None): 51 | """ 52 | Figure 1, Bayesian Example 53 | 54 | Ported from MATLAB Code 55 | 56 | Nicholas O'Donoughue 57 | 25 March 2021 58 | 59 | :param prefix: output directory to place generated figure 60 | :return: figure handle 61 | """ 62 | 63 | print('Generating Figure 6.1...') 64 | 65 | d_lam_narrow_beam = 4 66 | num_array_elements = 10 67 | psi_0 = 135*np.pi/180 68 | # psi_0 = 95*np.pi/180 69 | 70 | # Narrow Beam (Marginal Distribution) 71 | def element_pattern(psi): 72 | return np.absolute(np.cos(psi-np.pi/2))**1.2 73 | 74 | def array_function_narrow(psi): 75 | numerator = np.sin(np.pi*d_lam_narrow_beam*num_array_elements*(np.cos(psi)-np.cos(psi_0))) 76 | denominator = np.sin(np.pi*d_lam_narrow_beam*(np.cos(psi)-np.cos(psi_0))) 77 | 78 | # Throw in a little error handling; division by zero throws a runtime warning 79 | limit_mask = denominator == 0 80 | valid_mask = np.logical_not(limit_mask) 81 | valid_result = np.absolute(numerator[valid_mask] / denominator[valid_mask])/num_array_elements 82 | 83 | return np.piecewise(x=psi, condlist=[limit_mask], 84 | funclist=[1, valid_result]) 85 | 86 | # Wide Beam (Prior Distribution) 87 | d_lam_wide_beam = .5 88 | 89 | def array_function_wide(psi): 90 | numerator = np.sin(np.pi*d_lam_wide_beam*num_array_elements*(np.cos(psi)-np.cos(psi_0))) 91 | denominator = np.sin(np.pi*d_lam_wide_beam*(np.cos(psi)-np.cos(psi_0))) 92 | 93 | # Throw in a little error handling; division by zero throws a runtime warning 94 | limit_mask = denominator == 0 95 | valid_mask = np.logical_not(limit_mask) 96 | valid_result = np.absolute(numerator[valid_mask] / denominator[valid_mask])/num_array_elements 97 | 98 | return np.piecewise(x=psi, condlist=[limit_mask], 99 | funclist=[1, valid_result]) 100 | 101 | fig1 = plt.figure() 102 | psi_vec = np.arange(start=0, step=np.pi/1000, stop=np.pi) 103 | plt.plot(psi_vec, element_pattern(psi_vec)*array_function_narrow(psi_vec), label='Narrow Beam (marginal)') 104 | plt.plot(psi_vec, array_function_wide(psi_vec), label='Wide Beam (prior)') 105 | plt.xlabel(r'$\psi$') 106 | plt.legend(loc='upper left') 107 | 108 | # Save figure 109 | if prefix is not None: 110 | fig1.savefig(prefix + 'fig1.svg') 111 | fig1.savefig(prefix + 'fig1.png') 112 | 113 | return fig1 114 | 115 | 116 | def make_figure_2(prefix=None): 117 | """ 118 | Figure 2, Convex Optimization Example 119 | 120 | Ported from MATLAB Code 121 | 122 | Nicholas O'Donoughue 123 | 25 March 2021 124 | 125 | :param prefix: output directory to place generated figure 126 | :return: figure handle 127 | """ 128 | 129 | print('Generating Figure 6.2...') 130 | 131 | # True position 132 | x0 = np.array([1, .5]) 133 | 134 | # Grid 135 | xx = np.expand_dims(np.arange(start=-5, step=.01, stop=5), axis=1) 136 | yy = np.expand_dims(np.arange(start=-3, step=.01, stop=3), axis=0) 137 | 138 | # Broadcast xx and yy 139 | out_shp = np.broadcast(xx, yy) 140 | xx_full = np.broadcast_to(xx, out_shp.shape) 141 | yy_full = np.broadcast_to(yy, out_shp.shape) 142 | 143 | # Crude cost function; the different weights force an elliptic shape 144 | f = 1.*(xx-x0[0])**2 + 5.*(yy-x0[1])**2 145 | 146 | # Iterative estimates 147 | x_est = np.array([[-3, 2], 148 | [2, -1.5], 149 | [1, 2.2], 150 | [1, .6]]) 151 | 152 | # Plot results 153 | fig2 = plt.figure() 154 | plt.contour(xx_full, yy_full, f) 155 | plt.scatter(x0[0], x0[1], marker='^', label='True Minimum') 156 | plt.plot(x_est[:, 0], x_est[:, 1], linestyle='--', marker='+', label='Estimate') 157 | 158 | plt.text(-3, 2.1, 'Initial Estimate', fontsize=10) 159 | plt.legend(loc='lower left') 160 | 161 | if prefix is not None: 162 | fig2.savefig(prefix + 'fig2.svg') 163 | fig2.savefig(prefix + 'fig2.png') 164 | 165 | return fig2 166 | 167 | 168 | def make_figure_3(prefix=None): 169 | """ 170 | Figure 3, Tracker Example 171 | 172 | Ported from MATLAB Code 173 | 174 | Nicholas O'Donoughue 175 | 25 March 2021 176 | 177 | :param prefix: output directory to place generated figure 178 | :return: figure handle 179 | """ 180 | 181 | print('Generating Figure 6.3...') 182 | 183 | # Measurements 184 | y = np.array([1, 1.1, 1.3, 1.4, 1.35, 1.3, .7, .75]) 185 | # Estimates 186 | x = np.array([1, 1.05, 1.2, 1.35, 1.45, 1.35, 1.2, .8]) 187 | # Confidence Intervals 188 | s2 = np.array([.8, .5, .4, .3, .3, .2, .2, .6]) 189 | 190 | num_updates = y.size 191 | 192 | # Plot result 193 | fig3 = plt.figure() 194 | for i in np.arange(start=1, stop=s2.size): 195 | plt.fill(i + np.array([.2, .2, -.2, -.2, .2]), 196 | x[i] + s2[i-1]*np.array([-1, 1, 1, -1, -1]), 197 | color=(.8, .8, .8), label=None) 198 | 199 | x_vec = np.arange(num_updates) 200 | plt.scatter(x_vec, y, marker='x', label='Measurement', zorder=10) 201 | plt.plot(x_vec, x, linestyle='-.', marker='o', fillstyle='none', label='Estimate') 202 | plt.legend(loc='upper left') 203 | plt.xlabel('Time') 204 | plt.ylabel(r'Parameter ($\theta$)') 205 | 206 | if prefix is not None: 207 | fig3.savefig(prefix + 'fig3.svg') 208 | fig3.savefig(prefix + 'fig3.png') 209 | 210 | return fig3 211 | 212 | 213 | def make_figure_4(prefix=None): 214 | """ 215 | Figure 4, Angle Error Variance 216 | 217 | Ported from MATLAB Code 218 | 219 | Nicholas O'Donoughue 220 | 25 March 2021 221 | 222 | :param prefix: output directory to place generated figure 223 | :return: figure handle 224 | """ 225 | 226 | print('Generating Figure 6.4...') 227 | 228 | # Sensor Coordinates 229 | x0 = np.array([0, 0]) 230 | # xs = np.array([2, 1.4]) 231 | 232 | # Bearing and confidence interval 233 | aoa = 40 234 | aoa_rad = np.deg2rad(aoa) 235 | std_dev = 8 236 | length = 5 237 | aoa_lwr_rad = np.deg2rad(aoa - std_dev) 238 | aoa_up_rad = np.deg2rad(aoa + std_dev) 239 | 240 | # Plot Result 241 | fig4 = plt.figure() 242 | plt.scatter(x0[0], x0[1], marker='o', zorder=3) 243 | plt.fill(x0[0] + np.array([0, length*np.cos(aoa_lwr_rad), length*np.cos(aoa_rad), length*np.cos(aoa_up_rad), 0]), 244 | x0[1] + np.array([0, length*np.sin(aoa_lwr_rad), length*np.sin(aoa_rad), length*np.sin(aoa_up_rad), 0]), 245 | color=[.8, .8, .8]) 246 | 247 | plt.plot(x0[0] + np.array([0, length*np.cos(aoa_rad)]), x0[1] + np.array([0, length*np.sin(aoa_rad)]), 248 | linestyle='-.', color='k') 249 | plt.plot(x0[0] + np.array([0, length*np.cos(aoa_lwr_rad)]), x0[1] + np.array([0, length*np.sin(aoa_lwr_rad)]), 250 | color='k') 251 | plt.plot(x0[0] + np.array([0, length*np.cos(aoa_up_rad)]), x0[1] + np.array([0, length*np.sin(aoa_up_rad)]), 252 | color='k') 253 | 254 | plt.text(-.3, .1, 'Sensor', fontsize=10) 255 | plt.text(.95, .85, 'Estimated Bearing', fontsize=10, rotation=45) 256 | plt.text(1.6, 1.6, 'Confidence Interval', fontsize=10) 257 | 258 | plt.plot(length/5*np.cos(np.linspace(start=aoa_rad, stop=aoa_up_rad, num=10)), 259 | length/5*np.sin(np.linspace(start=aoa_rad, stop=aoa_up_rad, num=10)), color='k', linewidth=.5) 260 | plt.text(length/5*np.cos(aoa_up_rad)-.2, length/5*np.sin(aoa_up_rad)+.1, 'RMSE', fontsize=10) 261 | 262 | plt.xlim([-.5, 2.5]) 263 | plt.ylim([-.2, 1.8]) 264 | 265 | if prefix is not None: 266 | fig4.savefig(prefix + 'fig4.svg') 267 | fig4.savefig(prefix + 'fig4.png') 268 | 269 | return fig4 270 | 271 | 272 | if __name__ == "__main__": 273 | make_all_figures(close_figs=False) 274 | -------------------------------------------------------------------------------- /src/ewgeo/utils/tracker.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import safe_2d_shape 4 | from .system import PassiveSurveillanceSystem 5 | 6 | 7 | def kf_update(x_prev, p_prev, zeta, cov, h): 8 | # Evaluate the Measurement and Jacobian at x_prev 9 | z = h @ x_prev 10 | 11 | # Compute the Innovation (or Residual) 12 | y = zeta - z 13 | 14 | # Compute the Innovation Covariance 15 | s = h @ p_prev @ h.T + cov 16 | 17 | # Compute the Kalman Gain 18 | k = p_prev@h.T/s 19 | 20 | # Update the Estimate 21 | x = x_prev + k @ y 22 | p = (np.eye(p_prev.shape[0]) - (k @ h)) @ p_prev 23 | 24 | return x, p 25 | 26 | 27 | def kf_predict(x_est, p_est, q, f): 28 | 29 | # Predict the next state 30 | x_pred = f @ x_est 31 | 32 | # Predict the next state error covariance 33 | p_pred = f @ p_est @ f.T + q 34 | 35 | return x_pred, p_pred 36 | 37 | def ekf_update(x_prev, p_prev, zeta, cov, z_fun, h_fun): 38 | """ 39 | 40 | """ 41 | 42 | # Evaluate the Measurement and Jacobian at x_prev 43 | z = z_fun(x_prev) 44 | h = h_fun(x_prev) 45 | 46 | # Compute the Innovation (or Residual) 47 | y = zeta - z 48 | 49 | # Compute the Innovation Covariance 50 | s = h @ p_prev @ h.T + cov 51 | 52 | # Compute the Kalman Gain 53 | k = p_prev @ h.T @ np.linalg.inv(s) 54 | 55 | # Update the Estimate 56 | x = x_prev + k @ y 57 | p = (np.eye(p_prev.shape[0])- (k @ h)) @ p_prev 58 | 59 | return x, p 60 | 61 | def ekf_predict(x_est, p_est, q, f_fun, g_fun): 62 | """ 63 | Conduct the Predict stage for an Extended Kalman Filter 64 | 65 | :param x_est: Current state estimate 66 | :param p_est: 67 | :param q: 68 | :param f_fun: Function handle for measurements 69 | :param g_fun: Function handle for generation of the system matrix G 70 | """ 71 | 72 | # Forward prediction of state 73 | x_pred = f_fun(x_est) 74 | 75 | # Forward prediction of state error covariance 76 | f = g_fun(x_est) 77 | p_pred = f @ p_est @ np.transpose(f) + q 78 | 79 | return x_pred, p_pred 80 | 81 | def make_kinematic_model(model_type: str, num_dims: int=3, process_covar: np.ndarray or None=None): 82 | """ 83 | 84 | :param model_type: 85 | :param num_dims: 86 | :param process_covar: 87 | :return f: 88 | :return q: 89 | :return state_space: 90 | """ 91 | 92 | # Parse Covariance Input 93 | if process_covar is None: 94 | process_covar = np.eye(num_dims) 95 | elif np.isscalar(process_covar) or np.size(process_covar) == 1: 96 | process_covar = process_covar*np.eye(num_dims) 97 | 98 | # Initialize Output 99 | state_space = {'num_dims': num_dims, 100 | 'num_states': None, 101 | 'has_pos': True, 102 | 'has_vel': None, 103 | 'pos_slice': None, 104 | 'vel_slice': None} 105 | 106 | # Define Kinematic Model and Process Noise 107 | model_type = model_type.lower() # ensure it's lowercase for ease of comparison 108 | if model_type == 'cv' or model_type == 'constant velocity': 109 | # Position and Velocity are tracked states 110 | # Acceleration is assumed zero-mean Gaussian 111 | state_space['num_states'] = 2*num_dims 112 | state_space['pos_slice'] = np.s_[:num_dims] 113 | state_space['vel_slice'] = np.s_[num_dims:2*num_dims] 114 | state_space['has_vel'] = True 115 | 116 | def f(t): 117 | return np.block([[np.eye(num_dims), t*np.eye(num_dims)], 118 | [np.zeros((num_dims, num_dims)), np.eye(num_dims)]]) 119 | 120 | def q(t): 121 | return np.block([[.25*t**4*process_covar, .5*t**3*process_covar], 122 | [.5*t**3*process_covar, t**2*process_covar]]) 123 | 124 | elif model_type == 'ca' or model_type == 'constant acceleration': 125 | # Position, Velocity, and Acceleration are tracked states 126 | # Acceleration is assumed to have non-zero-mean Gaussian 127 | # distribution 128 | # 129 | # State model is: 130 | # [px, py, pz, vx, vy, vz, ax, ay, az]' 131 | state_space['num_states'] = 3*num_dims 132 | state_space['pos_slice'] = np.s_[:num_dims] 133 | state_space['vel_slice'] = np.s_[num_dims:2*num_dims] 134 | state_space['has_vel'] = True 135 | 136 | def f(t): 137 | return np.block([[np.eye(num_dims), t*np.eye(num_dims), .5*t**2*np.eye(num_dims)], 138 | [np.zeros((num_dims, num_dims)), np.eye(num_dims), t*np.eye(num_dims)], 139 | [np.zeros((num_dims, 2*num_dims)), np.eye(num_dims)]]) 140 | 141 | # Process noise covariance 142 | # This assumes that accel_var is the same in all dimensions 143 | def q(t): 144 | return np.block([[.25*t**4*process_covar, .5*t**3*process_covar, .5*t**2*process_covar], 145 | [.5*t**3*process_covar, t**2*process_covar, t*process_covar], 146 | [.5*t**2*process_covar, t*process_covar, process_covar]]) 147 | 148 | elif model_type == 'cj' or model_type == 'constant jerk': 149 | # Position, Velocity, and Acceleration are tracked states 150 | # Acceleration is assumed to have non-zero-mean Gaussian 151 | # distribution 152 | # 153 | # State model is: 154 | # [px, py, pz, vx, vy, vz, ax, ay, az, jx, jy, jz]' 155 | state_space['num_states'] = 4*num_dims 156 | state_space['pos_slice'] = np.s_[:num_dims] 157 | state_space['vel_slice'] = np.s_[num_dims:2*num_dims] 158 | state_space['has_vel'] = True 159 | 160 | def f(t): 161 | return np.block([[np.eye(num_dims), t*np.eye(num_dims), .5*t**2*np.eye(num_dims), 1/6*t**3*np.eye(num_dims)], 162 | [np.zeros((num_dims, num_dims)), np.eye(num_dims), t*np.eye(num_dims), .5*t**2*np.eye(num_dims)], 163 | [np.zeros((num_dims, 2*num_dims)), np.eye(num_dims), t*np.eye(num_dims)], 164 | [np.zeros((num_dims, 3*num_dims)), np.eye(num_dims)]]) 165 | 166 | # Process noise covariance 167 | # This assumes that accel_var is the same in all dimensions 168 | def q(t): 169 | return np.block([[t**7/252*process_covar, t**6/72*process_covar, t**5/30*process_covar, t**4/24*process_covar], 170 | [t**6/72*process_covar, t**5/20*process_covar, t**4/8*process_covar, t**3/6*process_covar], 171 | [t**5/30*process_covar, t**4/8*process_covar, t**3/3*process_covar, t**2/2*process_covar], 172 | [t**4/24*process_covar, t**3/6*process_covar, t**2/2*process_covar, t*process_covar]]) 173 | 174 | # Implementation of the aerodynamic and ballistic models is left to 175 | # readers as an exercise 176 | elif model_type == 'brv' or model_type == 'ballistic reentry': 177 | raise NotImplementedError('%s kinematic model not yet implemented.', model_type) 178 | elif model_type == 'marv' or model_type == 'maneuvering reentry': 179 | raise NotImplementedError('%s kinematic model not yet implemented.', model_type) 180 | elif model_type == 'aero': 181 | raise NotImplementedError('%s kinematic model not yet implemented.', model_type) 182 | elif model_type == 'ballistic': 183 | raise NotImplementedError('%s kinematic model not yet implemented.', model_type) 184 | else: 185 | raise NotImplementedError('%s kinematic model option not recognized.', model_type) 186 | 187 | return f, q, state_space 188 | 189 | def make_measurement_model(pss:PassiveSurveillanceSystem, state_space:dict): 190 | 191 | # Define functions to sample the position/velocity components of the target state 192 | def pos_component(x): 193 | return x[state_space['pos_slice']] 194 | def vel_component(x): 195 | return x[state_space['vel_slice']] if state_space['has_vel'] else None 196 | 197 | # Non-Linear Measurement Function 198 | def z_fun(x): 199 | return pss.measurement(x_source=pos_component(x), v_source=vel_component(x)) 200 | 201 | # Measurement Function Jacobian 202 | def h_fun(x): 203 | j = pss.jacobian(x_source=pos_component(x), v_source=vel_component(x)) 204 | # Jacobian may be either w.r.t. position-only (pss.num_dim rows) or pos/vel, 205 | # depending on which type of pss we're calling. 206 | 207 | # Build the H matrix 208 | _, num_source_pos = safe_2d_shape(x) 209 | h = np.zeros((pss.num_measurements, state_space['num_states'], num_source_pos)) 210 | h[:, state_space['pos_slice'], :] = np.transpose(j[:pss.num_dim, :])[:, :, np.newaxis] 211 | if state_space['has_vel'] and j.shape[0] > pss.num_dim: 212 | # The state space has velocity components, and the pss returned rows for 213 | # the jacobian w.r.t. velocity. 214 | h[: state_space['vel_slice'], :] = np.transpose(j[pss.num_dim:, :])[:, :, np.newaxis] 215 | 216 | if num_source_pos == 1: 217 | # Collapse it to 2D, there's no need for the third dimension 218 | h = np.reshape(h, (pss.num_measurements, state_space['num_states'])) 219 | return h 220 | 221 | return z_fun, h_fun -------------------------------------------------------------------------------- /src/ewgeo/utils/geo.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import warnings 3 | 4 | from . import is_broadcastable, safe_2d_shape 5 | from .constants import radius_earth_eff, radius_earth_true, speed_of_light 6 | 7 | 8 | def calc_range(x1, x2): 9 | """ 10 | Computes the range between two N-dimensional position vectors, using 11 | the Euclidean (L2) norm. 12 | 13 | Ported from MATLAB Code. 14 | 15 | Nicholas O'Donoughue 16 | 16 January 2021 17 | 18 | :param x1: NxM1 matrix of N-dimensional positions 19 | :param x2: NxM2 matrix of N-dimensional positions 20 | :return r: M1xM2 matrix of pair-wise ranges 21 | """ 22 | 23 | # Find the input dimensions and test for compatibility 24 | sz_1 = safe_2d_shape(x1) 25 | sz_2 = safe_2d_shape(x2) 26 | if sz_1[0] != sz_2[0]: 27 | raise TypeError('First dimension of both inputs must match.') 28 | 29 | num_dims = sz_1[0] 30 | num_x1 = int(np.prod(sz_1[1:])) 31 | num_x2 = int(np.prod(sz_2[1:])) 32 | # out_shp = (num_x1, num_x2) 33 | 34 | # Reshape the inputs 35 | x1 = np.reshape(x1, (num_dims, num_x1, 1)) 36 | x2 = np.reshape(x2, (num_dims, 1, num_x2)) 37 | 38 | # Compute the Euclidean Norm 39 | return np.squeeze(np.linalg.norm(x1-x2, axis=0)) 40 | 41 | 42 | def calc_range_diff(x0, x1, x2): 43 | """ 44 | Computes the difference in range from the reference input (x0) to each 45 | of the input vectors x1 and x2. Difference is taken as the range from 46 | x0 to each column in x2 minus the range from x0 to each column in x1, 47 | pair-wise. 48 | 49 | The first dimension of all three inputs must match. The dimensions of 50 | x1 and x2 determine the dimensions of the output dr. 51 | 52 | Ported from MATLAB Code. 53 | 54 | Nicholas O'Donoughue 55 | 16 January 2021 56 | 57 | :param x0: Nx1 reference position 58 | :param x1: NxM1 vector of test positions 59 | :param x2: NxM2 vector of test positions 60 | :return dr: M1xM2 matrix of range differences 61 | """ 62 | 63 | # Compute the range from the reference position to each of the set of 64 | # test positions 65 | r1 = calc_range(x0, x1) # 1xM1 66 | r2 = calc_range(x0, x2) # 1xM2 67 | 68 | # Take the difference, with appropriate dimension reshaping 69 | return r1 - r2.T 70 | 71 | 72 | def calc_doppler(x1, v1, x2, v2, f): 73 | """ 74 | Given source and sensor at position x1 and x2 with velocity v1 and v2, 75 | compute the Doppler velocity shift 76 | 77 | Ported from MATLAB Code 78 | 79 | Nicholas O'Donoughue 80 | 16 January 2021 81 | 82 | :param x1: Position vector of num_sources sources (num_dims x num_sources), in m 83 | :param v1: Velocity vector of num_sources sources (num_dims x num_sources), in m/s 84 | :param x2: Position vector of num_sensors sensors (num_dims x num_sensors), in m 85 | :param v2: Velocity vector of num_sensors sensors (num_dims x num_sensors), in m/s 86 | :param f: Carrier frequency, in Hertz 87 | :return fd: Doppler shifts for each source, sensor pair (num_sources x num_sensors), in Hertz 88 | """ 89 | 90 | # Reshape inputs 91 | num_dims, num_sources = safe_2d_shape(x1) 92 | _, num_sources2 = safe_2d_shape(v1) 93 | num_dims2, num_sensors = safe_2d_shape(x2) 94 | _, num_sensors2 = safe_2d_shape(v2) 95 | 96 | if num_dims != num_dims2 or \ 97 | (not is_broadcastable(x1, v1)) or \ 98 | (not is_broadcastable(x2, v2)): 99 | raise TypeError('Input dimensions do not match.') 100 | 101 | x1 = np.reshape(x1.T, (num_sources, 1, num_dims)) 102 | v1 = np.reshape(v1.T, (num_sources2, 1, num_dims)) 103 | x2 = np.reshape(x2.T, (1, num_sensors, num_dims)) 104 | v2 = np.reshape(v2.T, (1, num_sensors2, num_dims)) 105 | 106 | # Unit vector from x1 to x2 107 | # Note: I'm not sure why, but just dividing dx/dist broadcasts the dimensions weirdly. Using the np.newaxis ensures 108 | # that dist has the same shape as dx, to avoid the broadcasting bug. 109 | dx = x2-x1 110 | dist = np.linalg.norm(dx, axis=2) 111 | u12 = np.divide(dx, dist[:, :, np.newaxis], out=np.zeros_like(dx), where=dist[:, :, np.newaxis]!=0) 112 | u21 = -u12 113 | 114 | # x1 velocity towards x2 115 | vv1 = np.sum(v1*u12, axis=2) 116 | vv2 = np.sum(v2*u21, axis=2) 117 | 118 | # Sum of combined velocity 119 | v = vv1 + vv2 120 | 121 | # Convert to Doppler 122 | c = speed_of_light 123 | return f * (1 + v/c) 124 | 125 | 126 | def calc_doppler_diff(x_source, v_source, x_ref, v_ref, x_test, v_test, f): 127 | """ 128 | Computes the difference in Doppler shift between reference and test 129 | sensor pairs and a source. The two sets of sensor positions (x1 and x2) 130 | must have the same size. Corresponding pairs will be compared. 131 | 132 | Ported from MATLAB Code. 133 | 134 | Nicholas O'Donoughue 135 | 16 January 2021 136 | 137 | :param x_source: Position vector for N sources (nDim x N), in m 138 | :param v_source: Velocity vector for N sources (nDim x N), in m/s 139 | :param x_ref: Position vector for M reference sensors (nDim x M), in m 140 | :param v_ref: Velocity vector for M reference sensors (nDim x M), in m/s 141 | :param x_test: Position vector for M test sensors (nDim x M), in m 142 | :param v_test: Velocity vector for M test sensors (nDim x M), in m/s 143 | :param f: Carrier frequency, in Hertz 144 | :return fd_diff: Differential Doppler shift (N x M), in Hertz 145 | """ 146 | 147 | # Compute Doppler velocity from reference to each set of test positions 148 | dop_ref = calc_doppler(x_source, v_source, x_ref, v_ref, f) # N x M 149 | dop_test = calc_doppler(x_source, v_source, x_test, v_test, f) # N x M 150 | 151 | # Doppler difference 152 | return dop_test - dop_ref 153 | 154 | 155 | def compute_slant_range(alt1, alt2, el_angle_deg, use_effective_earth=False): 156 | """ 157 | Computes the slant range between two points given the altitude above the 158 | Earth, and the elevation angle relative to ground plane horizontal. 159 | 160 | R = sqrt((r1*sin(th))^2 + r2^2 - r1^2) - r1*sin(th) 161 | 162 | where 163 | r1 = alt1 + radius_earth 164 | r2 = alt2 + radius_earth 165 | th = elevation angle (above horizon) in degrees at point 1 166 | 167 | If the fourth argument is specified and set to true, 168 | then radius_earth is the 4/3 Earth Radius used for RF propagation paths 169 | otherwise the true earth radius is used. 170 | 171 | Ported from MATLAB Code 172 | 173 | Nicholas O'Donoughue 174 | 16 January 2021 175 | 176 | :param alt1: Altitude at the start of the path, in meters 177 | :param alt2: Altitude at the end of the path, in meters 178 | :param el_angle_deg: Elevation angle, at the start of the path, in degrees above the local horizontal plane 179 | :param use_effective_earth: Binary flag [Default=False], specifying whether to use the 4/3 Earth Radius 180 | approximation common to RF propagation 181 | :return: Straight line slant range between the start and end point specified, in meters 182 | """ 183 | 184 | # Parse earth radius setting 185 | if use_effective_earth: 186 | # True radius 187 | radius_earth = radius_earth_true 188 | else: 189 | # 4/3 approximation -- to account for refraction 190 | radius_earth = radius_earth_eff 191 | 192 | # Compute the two radii 193 | r1 = radius_earth + alt1 194 | r2 = radius_earth + alt2 195 | r1c = r1 * np.sin(np.deg2rad(el_angle_deg)) 196 | 197 | # Compute the slant range 198 | return np.sqrt(r1c**2 + r2**2 - r1**2) - r1c 199 | 200 | 201 | def find_intersect(x0, psi0, x1, psi1): 202 | """ 203 | # Find the intersection of two lines of bearing with origins at x0 and x1, 204 | # and bearing given by psi0 and psi1. 205 | 206 | Ported from MATLAB Code 207 | 208 | Nicholas O'Donoughue 209 | 16 January 2021 210 | 211 | :param x0: 2-D position of the first sensor 212 | :param psi0: Bearing of the first line, which begins at x0 213 | :param x1: 2-D position of the second sensor 214 | :param psi1: Bearing of the second line, which begins at x0 215 | :return x_int: 2-D position of the intersection point 216 | """ 217 | 218 | # Find slope and intercept for each line of bearing 219 | m0 = np.sin(psi0)/np.cos(psi0) 220 | m1 = np.sin(psi1)/np.cos(psi1) 221 | 222 | b0 = x0[1] - m0*x0[0] 223 | b1 = x1[1] - m1*x1[0] 224 | 225 | if (np.isinf(m0) and np.isinf(m1)) or np.fabs(m0-m1) < 1e-12: 226 | # They're parallel, effectively 227 | warnings.warn('Slopes are almost parallel; the solution is ill-conditioned.') 228 | return x0 229 | 230 | # Check Boundary Cases 231 | x = np.zeros(shape=(2,), dtype=float) 232 | if np.abs(np.cos(psi0)) < 1e-10: 233 | # First LOB is parallel to y-axis; x is fixed 234 | x[0] = x0[0] 235 | 236 | # Use slope/intercept definition of second LOB to solve for y 237 | x[1] = m1 * x[0] + b1 238 | elif np.abs(np.cos(psi1)) < 1e-10: 239 | # Same issue, but with the second LOB being parallel to y-axis 240 | x[0] = x1[0] 241 | x[1] = m0 * x[0] + b0 242 | else: 243 | # Find the point of intersection 244 | x[0] = (b0-b1)/(m1-m0) 245 | x[1] = m1*x[0] + b1 246 | 247 | return x 248 | -------------------------------------------------------------------------------- /make_figures/chapter1.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Chapter 1 3 | 4 | This script generates all the figures that appear in Chapter 1 of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 21 March 2021 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | from ewgeo.utils import init_output_dir, init_plot_style 16 | from ewgeo.utils.geo import calc_range 17 | from ewgeo.utils.unit_conversions import lin_to_db, db_to_lin 18 | 19 | 20 | def make_all_figures(close_figs=False): 21 | """ 22 | Call all the figure generators for this chapter 23 | 24 | :close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 25 | Default=False 26 | :return: List of figure handles 27 | """ 28 | 29 | # Reset the random number generator, to ensure reproducibility 30 | rng = np.random.default_rng() 31 | 32 | # Find the output directory 33 | prefix = init_output_dir('chapter1') 34 | init_plot_style() 35 | 36 | # Generate all figures 37 | fig1 = make_figure_1(prefix, rng) 38 | fig2 = make_figure_2(prefix) 39 | fig3 = make_figure_3(prefix) 40 | 41 | figs = [fig1, fig2, fig3] 42 | if close_figs: 43 | for fig in figs: 44 | plt.close(fig) 45 | 46 | return None 47 | else: 48 | plt.show() 49 | 50 | return figs 51 | 52 | 53 | def make_figure_1(prefix=None, rng=np.random.default_rng()): 54 | """ 55 | Figure 1, Detection Threshold 56 | 57 | :param prefix: output directory to place generated figure 58 | :param rng: random number generator 59 | :return: figure handle 60 | """ 61 | 62 | print('Generating Figure 1.1...') 63 | 64 | # Initialize variables 65 | noise_pwr_db = 0 # noise power, dB 66 | num_samples = 512 # number of points 67 | n = np.sqrt(10**(noise_pwr_db/10)/2) * (rng.standard_normal((num_samples, 1)) 68 | + 1j*rng.standard_normal((num_samples, 1))) 69 | 70 | # Compute Threshold 71 | prob_false_alarm = 1e-12 72 | threshold = np.sqrt(-np.log10(prob_false_alarm)) 73 | 74 | # Manually spike one noise sample 75 | index_spike = rng.integers(low=0, high=num_samples-1, size=(1, )) 76 | n[index_spike] = threshold+1 77 | 78 | # Target Positions 79 | index_1 = int(np.fix(num_samples/3)) 80 | amplitude_1 = threshold + 3 81 | index_2 = int(np.fix(5*num_samples/8)) 82 | amplitude_2 = threshold - 1 83 | 84 | # Target signal - use the auto-correlation of a window of length N to generate the lobing structure 85 | t = 10*np.pi*np.linspace(start=-1, stop=1, num=num_samples) 86 | p = np.sinc(t)/np.sqrt(np.sum(np.sinc(t)**2, axis=0)) 87 | s = np.zeros((1, num_samples)) 88 | s = s + np.roll(p, index_1, axis=0) * db_to_lin(amplitude_1) 89 | s = s + np.roll(p, index_2, axis=0) * db_to_lin(amplitude_2) 90 | 91 | # Apply a matched filter with the signal p 92 | s = np.reshape(np.fft.ifft(np.fft.fft(s, num_samples) 93 | * np.conj(np.fft.fft(p, num_samples)), num_samples), (num_samples, 1)) 94 | 95 | # Plot Noise and Threshold 96 | fig1 = plt.figure() 97 | sample_vec = np.reshape(np.arange(num_samples), (num_samples, 1)) 98 | plt.plot(sample_vec, lin_to_db(np.abs(s)), label='Signals', linewidth=2) 99 | plt.plot(sample_vec, lin_to_db(np.abs(n)), label='Noise', linewidth=.5) 100 | plt.plot(np.array([1, num_samples]), threshold*np.array([1, 1]), linestyle='--', label='Threshold') 101 | 102 | # Set axes limits 103 | plt.ylim([-5, threshold+5]) 104 | plt.xlim([1, num_samples]) 105 | 106 | # Add overlay text 107 | plt.text(index_1+(num_samples/50), amplitude_1, 'Detection', fontsize=12) 108 | plt.text(index_2, amplitude_2+.5, 'Missed Detection', fontsize=12) 109 | plt.text(index_spike-10, threshold+4, 'False Alarm', fontsize=12) 110 | 111 | # Add legend 112 | plt.legend(loc='upper left') 113 | 114 | # Display the plot 115 | plt.draw() 116 | 117 | # Output to file 118 | if prefix is not None: 119 | fig1.savefig(prefix + 'fig1.svg') 120 | fig1.savefig(prefix + 'fig1.png') 121 | 122 | return fig1 123 | 124 | 125 | def make_figure_2(prefix=None): 126 | """ 127 | Figure 2, AOA Geometry 128 | 129 | :param prefix: output directory to place generated figure 130 | :return: figure handle 131 | """ 132 | 133 | print('Generating Figure 1.2...') 134 | 135 | # Compute an AOA slice from sensor 1 136 | 137 | # Initialize Detector/Source Locations 138 | x1 = np.array([0, 0]) 139 | xs = np.array([.1, .9]) 140 | 141 | # Compute Ranges 142 | r1 = calc_range(x1, xs) 143 | 144 | # Error Values 145 | angle_error = 5*np.pi/180 146 | 147 | # Find AOA 148 | lob = xs - x1 149 | aoa1 = np.arctan2(lob[1], lob[0]) 150 | x_aoa_1 = x1 + np.array([[0, np.cos(aoa1)], [0, np.sin(aoa1)]])*5*r1 151 | x_aoa_1_plus = x1 + np.array([[0, np.cos(aoa1+angle_error)], [0, np.sin(aoa1+angle_error)]])*5*r1 152 | x_aoa_1_minus = x1 + np.array([[0, np.cos(aoa1-angle_error)], [0, np.sin(aoa1-angle_error)]])*5*r1 153 | lob_fill1 = np.concatenate((x_aoa_1_plus, np.fliplr(x_aoa_1_minus), 154 | np.expand_dims(x_aoa_1_plus[:, 0], axis=1)), axis=1) 155 | 156 | # Draw Figure 157 | fig2 = plt.figure() 158 | 159 | # LOBs 160 | plt.plot(x_aoa_1[0, :], x_aoa_1[1, :], linestyle='-', color='k', label='AOA Solution') 161 | 162 | # Uncertainty Intervals 163 | plt.fill(lob_fill1[0, :], lob_fill1[1, :], linestyle='--', alpha=.1, edgecolor='k', label='Uncertainty Interval') 164 | 165 | # Position Markers 166 | plt.scatter(x1[0], x1[1], marker='o', label='Sensor', zorder=3) 167 | plt.scatter(xs[0], xs[1], marker='^', label='Transmitter', zorder=3) 168 | 169 | # Adjust Axes 170 | plt.legend(loc='lower right') 171 | plt.ylim([-.5, 1.5]) 172 | plt.xlim([-.5, .5]) 173 | plt.axis('off') 174 | 175 | # Draw the figure 176 | plt.draw() 177 | 178 | # Save figure 179 | if prefix is not None: 180 | fig2.savefig(prefix + 'fig2.svg') 181 | fig2.savefig(prefix + 'fig2.png') 182 | 183 | return fig2 184 | 185 | 186 | def make_figure_3(prefix=None): 187 | """ 188 | Figure 3, Geolocation Geometry 189 | Compute an isochrone between sensors 1 and 2, then draw an AOA slice from sensor 3 190 | 191 | Ported from MATLAB Code 192 | 193 | Nicholas O'Donoughue 194 | 22 March 2021 195 | 196 | :param prefix: output directory to place generated figure 197 | :return: figure handle 198 | """ 199 | 200 | print('Generating Figure 1.3...') 201 | 202 | # Initialize Detector/Source Locations 203 | x1 = np.array([0, 0]) 204 | x2 = np.array([1, 1]) 205 | xs = np.array([.1, .9]) 206 | 207 | # Compute Ranges 208 | r1 = calc_range(x1, xs) 209 | r2 = calc_range(x2, xs) 210 | 211 | # Error Values 212 | angle_error = 5*np.pi/180 213 | 214 | # Find AOA 215 | lob1 = xs - x1 216 | aoa1 = np.arctan2(lob1[1], lob1[0]) 217 | x_aoa_1 = x1 + np.array([[0, np.cos(aoa1)], [0, np.sin(aoa1)]])*5*r1 218 | x_aoa_1_plus = x1 + np.array([[0, np.cos(aoa1+angle_error)], [0, np.sin(aoa1+angle_error)]])*5*r1 219 | x_aoa_1_minus = x1 + np.array([[0, np.cos(aoa1-angle_error)], [0, np.sin(aoa1-angle_error)]])*5*r1 220 | lob_fill1 = np.concatenate((x_aoa_1_plus, np.fliplr(x_aoa_1_minus), 221 | np.expand_dims(x_aoa_1_plus[:, 0], axis=1)), axis=1) 222 | 223 | lob2 = xs - x2 224 | aoa2 = np.arctan2(lob2[1], lob2[0]) 225 | x_aoa_2 = x2 + np.array([[0, np.cos(aoa2)], [0, np.sin(aoa2)]])*5*r2 226 | x_aoa_2_plus = x2 + np.array([[0, np.cos(aoa2+angle_error)], [0, np.sin(aoa2+angle_error)]])*5*r2 227 | x_aoa_2_minus = x2 + np.array([[0, np.cos(aoa2-angle_error)], [0, np.sin(aoa2-angle_error)]])*5*r2 228 | lob_fill2 = np.concatenate((x_aoa_2_plus, np.fliplr(x_aoa_2_minus), 229 | np.expand_dims(x_aoa_2_plus[:, 0], axis=1)), axis=1) 230 | 231 | # Draw Figure 232 | fig3 = plt.figure() 233 | 234 | # LOBs 235 | plt.plot(x_aoa_1[0, :], x_aoa_1[1, :], linestyle='-', color='b', label='AOA Solution') 236 | plt.plot(x_aoa_2[0, :], x_aoa_2[1, :], linestyle='-', color='b', label=None) 237 | 238 | # Uncertainty Intervals 239 | plt.fill(lob_fill1[0, :], lob_fill1[1, :], facecolor='k', alpha=.1, linestyle='--', 240 | label='Uncertainty Interval') 241 | plt.fill(lob_fill2[0, :], lob_fill2[1, :], facecolor='k', alpha=.1, linestyle='--', label=None) 242 | 243 | # Position Markers 244 | plt.scatter(np.array([x1[0], x2[0]]), np.array([x1[1], x2[1]]), marker='o', label='Sensors', zorder=3) 245 | plt.scatter(xs[0], xs[1], marker='^', label='Transmitter', zorder=3) 246 | 247 | # Position Labels 248 | plt.text(x1[0]+.05, x1[1]-.1, '$S_1$') 249 | plt.text(x2[0]+.05, x2[1]-.1, '$S_2$') 250 | 251 | # Adjust Axes 252 | plt.legend(loc='lower right') 253 | plt.ylim([-.5, 1.5]) 254 | plt.xlim([-1, 2]) 255 | plt.axis('off') 256 | 257 | # Save figure 258 | if prefix is not None: 259 | fig3.savefig(prefix + 'fig3.svg') 260 | fig3.savefig(prefix + 'fig3.png') 261 | 262 | return fig3 263 | 264 | 265 | if __name__ == "__main__": 266 | make_all_figures(close_figs=False) 267 | -------------------------------------------------------------------------------- /src/ewgeo/tdoa/system.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | from . import model, perf, solvers 4 | from ewgeo.utils import parse_reference_sensor, safe_2d_shape, SearchSpace 5 | from ewgeo.utils.constants import speed_of_light 6 | from ewgeo.utils.covariance import CovarianceMatrix 7 | from ewgeo.utils.system import DifferencePSS 8 | 9 | 10 | class TDOAPassiveSurveillanceSystem(DifferencePSS): 11 | bias = None 12 | 13 | _default_tdoa_bias_search_epsilon = 1 # meters 14 | _default_tdoa_bias_search_size = 11 # num search points per dimension 15 | 16 | def __init__(self,x, cov, variance_is_toa=True, **kwargs): 17 | # Handle empty covariance matrix inputs 18 | if cov is None: 19 | # Make a dummy; unit variance 20 | _, num_sensors = safe_2d_shape(x) 21 | cov = CovarianceMatrix(np.eye(num_sensors)) 22 | 23 | # First, we need to convert from TOA to ROA 24 | if variance_is_toa: 25 | cov = cov.multiply(speed_of_light ** 2, overwrite=False) 26 | 27 | super().__init__(x, cov, **kwargs) 28 | 29 | # Overwrite uncertainty search defaults 30 | self.default_bias_search_epsilon = self._default_tdoa_bias_search_epsilon 31 | self.default_bias_search_size = self._default_tdoa_bias_search_size 32 | 33 | ## ============================================================================================================== ## 34 | ## Model Methods 35 | ## 36 | ## These methods handle the physical model for a TDOA-based PSS, and are just wrappers for the static 37 | ## functions defined in model.py 38 | ## ============================================================================================================== ## 39 | def measurement(self, x_source, x_sensor=None, bias=None, v_sensor=None, v_source=None): 40 | if x_sensor is None: x_sensor = self.pos 41 | if bias is None: bias = self.bias 42 | return model.measurement(x_sensor=x_sensor, x_source=x_source, ref_idx=self.ref_idx, bias=bias) 43 | 44 | def jacobian(self, x_source, v_source=None, x_sensor=None, v_sensor=None): 45 | if x_sensor is None: x_sensor = self.pos 46 | return model.jacobian(x_sensor=x_sensor, x_source=x_source, ref_idx=self.ref_idx) 47 | 48 | def jacobian_uncertainty(self, x_source, **kwargs): 49 | return model.jacobian_uncertainty(x_sensor=self.pos, x_source=x_source, ref_idx=self.ref_idx, **kwargs) 50 | 51 | def log_likelihood(self, zeta, x_source, x_sensor=None, bias=None, v_sensor=None, v_source=None, **kwargs): 52 | if x_sensor is None: x_sensor = self.pos 53 | if bias is None: bias = self.bias 54 | return model.log_likelihood(x_sensor=x_sensor, zeta=zeta, x_source=x_source, cov=self.cov, ref_idx=self.ref_idx, 55 | variance_is_toa=False, do_resample=False, bias=bias, **kwargs) 56 | 57 | # def log_likelihood_uncertainty(self, zeta, theta, **kwargs): 58 | # return model.log_likelihood_uncertainty(x_sensor=self.pos, zeta=zeta, theta=theta, cov=self.cov, 59 | # cov_pos=self.cov_pos, ref_idx=self.ref_idx, 60 | # variance_is_toa=False, do_resample=False, **kwargs) 61 | 62 | def grad_x(self, x_source): 63 | return model.grad_x(x_sensor=self.pos, x_source=x_source, ref_idx=self.ref_idx) 64 | 65 | def grad_bias(self, x_source): 66 | return model.grad_bias(x_sensor=self.pos, x_source=x_source, ref_idx=self.ref_idx) 67 | 68 | def grad_sensor_pos(self, x_source): 69 | return model.grad_sensor_pos(x_sensor=self.pos, x_source=x_source, ref_idx=self.ref_idx) 70 | 71 | ## ============================================================================================================== ## 72 | ## Solver Methods 73 | ## 74 | ## These methods handle the interface to solvers 75 | ## ============================================================================================================== ## 76 | def max_likelihood(self, zeta, search_space: SearchSpace, cal_data: dict=None, **kwargs): 77 | # Perform sensor calibration 78 | if cal_data is not None: 79 | x_sensor, v_sensor, bias = self.sensor_calibration(**cal_data) 80 | else: 81 | x_sensor, v_sensor, bias = self.pos, None, self.bias 82 | 83 | # Call the non-calibration solver 84 | return solvers.max_likelihood(x_sensor=x_sensor, zeta=zeta, cov=self.cov, ref_idx=self.ref_idx, 85 | search_space=search_space, bias=bias, 86 | do_resample=False, variance_is_toa=False, **kwargs) 87 | 88 | # def max_likelihood_uncertainty(self, zeta, x_ctr, search_size, epsilon=None, do_sensor_bias=False, cov_pos=None, 89 | # **kwargs): 90 | # if cov_pos is None: cov_pos = self.cov_pos 91 | # 92 | # return solvers.max_likelihood_uncertainty(x_sensor=self.pos, zeta=zeta, cov=self.cov, cov_pos=cov_pos, 93 | # ref_idx=self.ref_idx, x_ctr=x_ctr, search_size=search_size, 94 | # epsilon=epsilon, do_resample=False, variance_is_toa=False, 95 | # do_sensor_bias=do_sensor_bias, **kwargs) 96 | 97 | def gradient_descent(self, zeta, x_init, cal_data: dict=None, **kwargs): 98 | # Perform sensor calibration 99 | if cal_data is not None: 100 | x_sensor, v_sensor, bias = self.sensor_calibration(**cal_data) 101 | else: 102 | x_sensor, v_sensor, bias = self.pos, None, self.bias 103 | 104 | return solvers.gradient_descent(x_sensor=x_sensor, zeta=zeta, cov=self.cov, x_init=x_init, ref_idx=self.ref_idx, 105 | do_resample=False, variance_is_toa=False, **kwargs) 106 | 107 | def least_square(self, zeta, x_init, cal_data: dict=None, **kwargs): 108 | # Perform sensor calibration 109 | if cal_data is not None: 110 | x_sensor, v_sensor, bias = self.sensor_calibration(**cal_data) 111 | else: 112 | x_sensor, v_sensor, bias = self.pos, None, self.bias 113 | 114 | return solvers.least_square(x_sensor=x_sensor, zeta=zeta, cov=self.cov, x_init=x_init, ref_idx=self.ref_idx, 115 | do_resample=False, variance_is_toa=False, **kwargs) 116 | 117 | def bestfix(self, zeta, search_space: SearchSpace, pdf_type=None, cal_data: dict=None): 118 | # Perform sensor calibration 119 | if cal_data is not None: 120 | x_sensor, _, bias = self.sensor_calibration(**cal_data) 121 | else: 122 | x_sensor, _, bias = self.pos, None, self.bias 123 | 124 | # ToDo: Get bestfix to accept a bias term 125 | return solvers.bestfix(x_sensor=x_sensor, zeta=zeta, cov=self.cov, 126 | search_space=search_space, pdf_type=pdf_type, 127 | do_resample=False, variance_is_toa=False) 128 | 129 | def chan_ho(self, zeta, cal_data: dict=None): 130 | # Perform sensor calibration 131 | if cal_data is not None: 132 | x_sensor, _, bias = self.sensor_calibration(**cal_data) 133 | else: 134 | x_sensor, _, bias = self.pos, None, self.bias 135 | 136 | # ToDo: Get chan_ho to accept a bias term 137 | return solvers.chan_ho(x_sensor=x_sensor, zeta=zeta, cov=self.cov, ref_idx=self.ref_idx, do_resample=False, 138 | variance_is_toa=False) 139 | 140 | ## ============================================================================================================== ## 141 | ## Performance Methods 142 | ## 143 | ## These methods handle predictions of system performance 144 | ## ============================================================================================================== ## 145 | def compute_crlb(self, x_source, **kwargs): 146 | return perf.compute_crlb(x_sensor=self.pos, x_source=x_source, cov=self.cov, ref_idx=self.ref_idx, 147 | do_resample=False, variance_is_toa=False, **kwargs) 148 | 149 | ## ============================================================================================================== ## 150 | ## Helper Methods 151 | ## 152 | ## These are generic utility functions that are unique to this class 153 | ## ============================================================================================================== ## 154 | def error(self, x_source, x_max, num_pts): 155 | return model.error(x_sensor=self.pos, x_source=x_source, x_max=x_max, num_pts=num_pts, cov=self.cov, 156 | do_resample=False, variance_is_toa=False, ref_idx=self.ref_idx) 157 | 158 | def draw_isochrones(self, range_diff, num_pts, max_ortho, x_sensor=None): 159 | if x_sensor is None: 160 | x_sensor = self.pos 161 | 162 | test_idx_vec, ref_idx_vec = parse_reference_sensor(self.ref_idx, self.num_sensors) 163 | 164 | isochrones = [model.draw_isochrone(x_ref=x_sensor[:,ref_idx], x_test=x_sensor[:,test_idx], 165 | range_diff=this_range_diff, num_pts=num_pts, max_ortho=max_ortho) for 166 | (test_idx, ref_idx, this_range_diff) in zip(test_idx_vec, ref_idx_vec, range_diff)] 167 | return isochrones 168 | 169 | def generate_parameter_indices(self, do_bias=True): 170 | return model.generate_parameter_indices(x_sensor=self.pos, do_bias=do_bias) 171 | -------------------------------------------------------------------------------- /make_figures/appendixC.py: -------------------------------------------------------------------------------- 1 | """ 2 | Draw Figures - Appendix C 3 | 4 | This script generates all the figures that appear in Appendix C of the textbook. 5 | 6 | Ported from MATLAB Code 7 | 8 | Nicholas O'Donoughue 9 | 8 December 2022 10 | """ 11 | 12 | import matplotlib.pyplot as plt 13 | import numpy as np 14 | 15 | import ewgeo.atm as atm 16 | from ewgeo.utils import init_output_dir, init_plot_style 17 | 18 | 19 | def make_all_figures(close_figs=False): 20 | """ 21 | Call all the figure generators for this chapter 22 | 23 | :param close_figs: Boolean flag. If true, will close all figures after generating them; for batch scripting. 24 | Default=False 25 | :return: List of figure handles 26 | """ 27 | 28 | # Find the output directory 29 | prefix = init_output_dir('appendixC') 30 | init_plot_style() 31 | 32 | # Random Number Generator 33 | # rng = np.random.default_rng(0) 34 | 35 | # Colormap 36 | # colors = plt.get_cmap("tab10") 37 | 38 | # Generate all figures 39 | fig2 = make_figure_2(prefix) 40 | fig3 = make_figure_3(prefix) 41 | fig4 = make_figure_4(prefix) 42 | fig5 = make_figure_5(prefix) 43 | 44 | figs = [fig2, fig3, fig4, fig5] 45 | if close_figs: 46 | for fig in figs: 47 | plt.close(fig) 48 | 49 | return None 50 | else: 51 | plt.show() 52 | 53 | return figs 54 | 55 | 56 | def make_figure_2(prefix=None): 57 | """ 58 | Figure 2 - Dry Air and Water Vapor 59 | 60 | Ported from MATLAB Code 61 | 62 | Nicholas O'Donoughue 63 | 8 December 2022 64 | 65 | :param prefix: output directory to place generated figure 66 | :return: figure handle 67 | """ 68 | 69 | print('Generating Figure C.2...') 70 | 71 | # Open the Figure and Initialize Labels 72 | fig2 = plt.figure() 73 | ao_label = 'Dry Air Only' 74 | aw_label = 'Water Vapor' 75 | a_tot_label = 'Total' 76 | 77 | # Set up frequencies 78 | fo, fw = atm.reference.get_spectral_lines() 79 | freq_vec = np.sort(np.concatenate((fo, fw, fo+50e6, fw+50e6, fo-100e6, fw-100e6, 80 | np.arange(start=1e9, step=1e9, stop=350e9)), 81 | axis=0)) 82 | 83 | # Iterate over altitude bands 84 | for alt_m in np.array([0, 10, 20])*1e3: 85 | atmosphere = atm.reference.get_standard_atmosphere(alt_m) 86 | 87 | # Compute Loss Coefficients 88 | ao, aw = atm.model.get_gas_loss_coeff(freq_hz=freq_vec, press=atmosphere.press, 89 | water_vapor_press=atmosphere.water_vapor_press, temp=atmosphere.temp) 90 | 91 | # Plot 92 | handle = plt.loglog(freq_vec/1e9, np.squeeze(ao), linestyle=':', label=ao_label) 93 | plt.loglog(freq_vec/1e9, np.squeeze(aw), linestyle='--', color=handle[0].get_color(), label=aw_label) 94 | plt.loglog(freq_vec/1e9, np.squeeze(ao + aw), linestyle='-', color=handle[0].get_color(), label=a_tot_label) 95 | 96 | # Clear the labels -- keeps the legend clean (don't print labels for subsequent altitude bands) 97 | ao_label = None 98 | aw_label = None 99 | a_tot_label = None 100 | 101 | # Adjust Plot Display 102 | plt.xlim([1, 350]) 103 | plt.ylim([1e-5, 1e2]) 104 | plt.xlabel('Frequency [GHz]') 105 | plt.ylabel(r'Gas Loss Coefficient $\gamma_g$ [dB/km]') 106 | plt.legend(loc='upper left') 107 | 108 | # Text Annotation 109 | plt.text(2, 1.5e-2, '0 km') 110 | plt.text(2, 1.5e-3, '10 km') 111 | plt.text(2, 8e-5, '20 km') 112 | 113 | if prefix is not None: 114 | fig2.savefig(prefix + 'fig2.png') 115 | fig2.savefig(prefix + 'fig2.svg') 116 | 117 | return fig2 118 | 119 | 120 | def make_figure_3(prefix=None, colors=None): 121 | """ 122 | Figure 3 - Rain Loss Coefficient 123 | 124 | Ported from MATLAB Code 125 | 126 | Nicholas O'Donoughue 127 | 8 December 2022 128 | 129 | :param prefix: output directory to place generated figure 130 | :param colors: list of colors to use for plotting (if not specified, will use the matplotlib default color order) 131 | :return: figure handle 132 | """ 133 | 134 | print('Generating Figure C.3...') 135 | 136 | if colors is None: 137 | colors = plt.rcParams['axes.prop_cycle'].by_key()['color'] 138 | 139 | # Initialize Parameters 140 | rain_rate_set = [1., 4., 16., 100.] 141 | colors = colors[:len(rain_rate_set)] # Keep as many colors as there are rainfall rate settings 142 | 143 | pol_ang_vec = [0, np.pi / 2] 144 | pol_set = ('Horizontal', 'Vertical') 145 | line_style_set = ('-', '-.') 146 | 147 | freq_hz = np.arange(start=1, stop=100, step=.5) * 1e9 148 | el_ang_rad = 0*np.pi/180 149 | 150 | # Open Figure 151 | fig3 = plt.figure() 152 | 153 | # Iterate over rainfall rate conditions and polarity conditions 154 | for rain_rate, this_color in zip(rain_rate_set, colors): 155 | for pol_ang_rad, pol_label, this_line_style in zip(pol_ang_vec, pol_set, line_style_set): 156 | # Compute rain loss coefficients 157 | gamma = atm.model.get_rain_loss_coeff(freq_hz=freq_hz, pol_angle_rad=pol_ang_rad, 158 | el_angle_rad=el_ang_rad, rainfall_rate=rain_rate) 159 | 160 | plt.loglog(freq_hz/1e9, gamma, linestyle=this_line_style, color=this_color, label=pol_label) 161 | 162 | # Clear the polarization labels; so we only get one set of legend entries 163 | pol_set = (None, None) 164 | 165 | plt.grid(True) 166 | plt.ylim([.01, 50]) 167 | plt.xlim([freq_hz[0]/1e9, freq_hz[-1]/1e9]) 168 | 169 | # Add rainfall condition labels 170 | plt.text(10, 6, 'Very Heavy', rotation=25) # set(ht,'rotation',25) 171 | plt.text(10, .6, 'Heavy', rotation=40) # set(ht,'rotation',40) 172 | plt.text(10, .12, 'Moderate', rotation=40) # set(ht,'rotation',40) 173 | plt.text(10, .023, 'Light', rotation=40) # set(ht,'rotation',40) 174 | 175 | plt.xlabel('Frequency [GHz]') 176 | plt.ylabel(r'Rain Loss Coefficient $\gamma_r$ [dB/km]') 177 | plt.legend(loc='upper left') 178 | 179 | if prefix is not None: 180 | fig3.savefig(prefix + 'fig3.png') 181 | fig3.savefig(prefix + 'fig3.svg') 182 | 183 | return fig3 184 | 185 | 186 | def make_figure_4(prefix=None): 187 | """ 188 | Figure 4 - Cloud/Fog Loss 189 | 190 | Ported from MATLAB Code 191 | 192 | Nicholas O'Donoughue 193 | 8 December 2022 194 | 195 | :param prefix: output directory to place generated figure 196 | :return: figure handle 197 | """ 198 | 199 | print('Generating Figure C.4...') 200 | 201 | # Initialize Parameters 202 | fog_set = [.032, .32, 2.3] 203 | fog_names = ['600 m Visibility', '120 m Visibility', '30 m Visibility'] 204 | 205 | freq_hz = np.arange(start=1, stop=100, step=.5)*1e9 206 | 207 | # Open the figure 208 | fig4 = plt.figure() 209 | 210 | # Iterate over fog conditions 211 | for this_fog, this_fog_label in zip(fog_set, fog_names): 212 | gamma = atm.model.get_fog_loss_coeff(f=freq_hz, cloud_dens=this_fog, temp_k=None) 213 | 214 | plt.loglog(freq_hz/1e9, gamma, label=this_fog_label) 215 | 216 | plt.grid(True) 217 | 218 | # ht=text(10,.2,'30 m Visibility');set(ht,'rotation',30); 219 | # ht=text(10,.025,'120 m Visibility');set(ht,'rotation',30); 220 | # ht=text(32,.025,'600 m Visibility');set(ht,'rotation',30); 221 | 222 | plt.ylim([.01, 10]) 223 | plt.xlabel('Frequency [GHz]') 224 | plt.ylabel(r'Cloud Loss Coefficient $\gamma_c$ [dB/km]') 225 | plt.legend(loc='upper left') 226 | 227 | if prefix is not None: 228 | fig4.savefig(prefix + 'fig4.png') 229 | fig4.savefig(prefix + 'fig4.svg') 230 | 231 | return fig4 232 | 233 | 234 | def make_figure_5(prefix=None): 235 | """ 236 | Figure 5 - Zenith Loss 237 | 238 | Ported from MATLAB Code 239 | 240 | Nicholas O'Donoughue 241 | 8 December 2022 242 | 243 | :param prefix: output directory to place generated figure 244 | :return: figure handle 245 | """ 246 | 247 | print('Generating Figure C.5...') 248 | 249 | # Set up frequencies 250 | fo, fw = atm.reference.get_spectral_lines() 251 | freq_vec = np.sort(np.concatenate((fo, fw, fo + 50e6, fw + 50e6, fo - 100e6, fw - 100e6, 252 | np.arange(start=1e9, step=1e9, stop=350e9)), 253 | axis=0)) 254 | 255 | # Set of nadir angles to calculate 256 | nadir_deg_set = [0, 10, 30, 60] 257 | degree_sign = u'\N{DEGREE SIGN}' 258 | nadir_labels = ['{}{} from Zenith'.format(this_nadir, degree_sign) for this_nadir in nadir_deg_set] 259 | nadir_labels[0] = 'Zenith' 260 | 261 | # Open figure 262 | fig5 = plt.figure() 263 | 264 | # Iterate over nadir angles 265 | for this_nadir, this_label in zip(nadir_deg_set, nadir_labels): 266 | # Order of outputs is (total_loss, loss_from_oxygen, loss_from_water_vapor) 267 | loss, _, _ = atm.model.calc_zenith_loss(freq_hz=freq_vec, alt_start_m=0, zenith_angle_deg=this_nadir) 268 | 269 | plt.loglog(freq_vec/1e9, loss, label=this_label) 270 | 271 | plt.xlabel('Frequency [GHz]') 272 | plt.ylabel('Zenith Attenuation') 273 | plt.legend(loc='upper left') 274 | 275 | plt.grid(True) 276 | plt.xlim([1, 350]) 277 | plt.ylim([1e-2, 1e3]) 278 | 279 | if prefix is not None: 280 | fig5.savefig(prefix + 'fig5.png') 281 | fig5.savefig(prefix + 'fig5.svg') 282 | 283 | return fig5 284 | 285 | 286 | if __name__ == "__main__": 287 | make_all_figures(close_figs=False) 288 | --------------------------------------------------------------------------------