├── 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 |
--------------------------------------------------------------------------------