diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..f589df317 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "python.testing.pytestArgs": [ + "pycraf" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true, + "python-envs.defaultEnvManager": "ms-python.python:conda", + "python-envs.defaultPackageManager": "ms-python.python:conda" +} \ No newline at end of file diff --git a/licenses/LICENSE_ASTROPY_PACKAGE_TEMPLATE.rst b/licenses/LICENSE_ASTROPY_PACKAGE_TEMPLATE.rst deleted file mode 100644 index f52987b2b..000000000 --- a/licenses/LICENSE_ASTROPY_PACKAGE_TEMPLATE.rst +++ /dev/null @@ -1,25 +0,0 @@ -Copyright (c) year, Astropy Developers -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. -* Redistributions in binary form must reproduce the above copyright notice, this - list of conditions and the following disclaimer in the documentation and/or - other materials provided with the distribution. -* Neither the name of the Astropy Team nor the names of its contributors may be - used to endorse or promote products derived from this software without - specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR -ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES -(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; -LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON -ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/pycraf/conversions/conversions.py b/pycraf/conversions/conversions.py index 9b575530b..772523183 100644 --- a/pycraf/conversions/conversions.py +++ b/pycraf/conversions/conversions.py @@ -25,6 +25,7 @@ 'dB_mW_MHz', 'dBm_MHz', # this is often used in engineering (dBm/MHz) 'dB_1_m', 'dB_uV_m', + 'dB_uA_m', ] __all__ = [ @@ -57,6 +58,7 @@ dBm = dB_mW = apu.dB(apu.mW) dBm_MHz = dB_mW_MHz = apu.dB(apu.mW / apu.MHz) dB_uV_m = apu.dB(apu.uV ** 2 / apu.m ** 2) +dB_uA_m = apu.dB(apu.uA ** 2 / apu.m ** 2) dB_1_m = apu.dB(1. / apu.m) # for antenna factor # Astropy.unit equivalency between linear and logscale field strength @@ -94,8 +96,29 @@ def efield_equivalency(): )] +def magfield_equivalency(): + ''' + `~astropy.units` equivalency to handle log-scale magnetic field units. + + For magnetic fields, the Decibel scale is define via the amplitude + of the field squared, :math:`{\\vert\\vec B\\vert}^2` which is + proportional to the power. + + Returns + ------- + equivalency : list + The returned list contains one tuple with the equivalency. + ''' + return [( + apu.uA / apu.m, + (apu.uA / apu.m) ** 2, + lambda x: x ** 2, + lambda x: x ** 0.5 + )] + # apu.add_enabled_equivalencies(apu.logarithmic()) apu.add_enabled_equivalencies(efield_equivalency()) +apu.add_enabled_equivalencies(magfield_equivalency()) # define some useful constants MU0_VALUE = 1.2566370614359173e-06 diff --git a/pycraf/conversions/tests/test_conversions.py b/pycraf/conversions/tests/test_conversions.py index ec7af8bff..7df70282e 100644 --- a/pycraf/conversions/tests/test_conversions.py +++ b/pycraf/conversions/tests/test_conversions.py @@ -74,6 +74,11 @@ def test_db_scales(self): _val ) + assert_quantity_allclose( + (_db * cnv.dB_uA_m).to((apu.uA / apu.m) ** 2).value, + _val + ) + def test_constants(self): assert_quantity_allclose( diff --git a/pycraf/pathprof/__init__.py b/pycraf/pathprof/__init__.py index 693d92840..6b051ae3c 100644 --- a/pycraf/pathprof/__init__.py +++ b/pycraf/pathprof/__init__.py @@ -17,6 +17,7 @@ from .bel import * from .imt import * from .srtm import * +from .low_freq_propagation import * _clutter_table = ''' +-------+-------------------+------+------+ diff --git a/pycraf/pathprof/low_freq_propagation.py b/pycraf/pathprof/low_freq_propagation.py new file mode 100644 index 000000000..044306672 --- /dev/null +++ b/pycraf/pathprof/low_freq_propagation.py @@ -0,0 +1,328 @@ +# -*- coding: utf-8 -*- +""" +Protection-distance assessment between inductive (loop) systems and +radiocommunication services below 30 MHz, after ITU-R SM.2028-0 (2012). + +From a magnetic field strength measured at a known distance, the model +recovers the magnetic dipole moment and effective radiated power of the +interferer, then returns the separation distance at which the field drops +to the victim's permissible limit. Both victim geometries of the +Recommendation are supported: a ground-wave path (40 dB/dec then +20 dB/dec roll-off, eqs. 9-13) and a free-space path (20 dB/dec only, +eqs. 11-13). Ground electrical parameters and the 40 dB/dec asymptote +field strength are taken from ITU-R P.368 / SM.2028-0 Table 1. + +Public API: :func:`findE40`, :func:`low_prop`, :func:`protection_distance`. +""" +import numpy as np +import astropy.units as apu +from astropy import constants +from .. import utils, conversions as cnv + +Easy20_dB = 109.5 # Field strength at 20 dB/dec roll-off [dB(uV/m)] +C_M_S = constants.c.to_value(apu.m / apu.s) # Speed of light [m/s] + +# Physical units carried by the Table 1 data (the arrays below are stored +# unit-less for indexing/log-domain arithmetic; these constants document and +# re-attach the units, e.g. ``TAB368_TYPES * E40_UNIT``). +E40_UNIT = cnv.dB_uV_m # 40 dB/dec asymptote field strength +SIGMA_UNIT = apu.S / apu.m # ground conductivity sigma +EPS_UNIT = cnv.dimless # relative permittivity epsilon_r + +__all__ = [ + "findE40", + "low_prop", + "protection_distance", +] + +TAB368_KHZ = np.array([ + 10, 15, 20, 30, 40, 50, 75, 100, 150, 200, 300, 400, 500, 750, 1000, + 1500, 2000, 3000, 4000, 5000, 7500, 10000, 15000, 20000, 30000 + ]) # column frequencies [kHz] of ITU-R P.368 / SM.2028-0 Table 1 + +# 40 dB/dec asymptote field strength E_asymptote,40, in dB(uV/m) at 1 km for +# 1 kW ERP (see ``E40_UNIT``). Rows follow the ``Ground_Types`` order; columns +# follow ``TAB368_KHZ``. Stored unit-less so it can be indexed and combined in +# the log domain; multiply by ``E40_UNIT`` to obtain a Quantity. +TAB368_TYPES = np.array([ + [166,164,163,162,162,161,160,159,158,158,157,156,156,154,152,151,150,147,144,142,136,132,126,120,113], + [166,165,164,163,162,161,160,159,158,158,157,156,155,154,153,153,152,151,149,148,146,143,138,134,127], + [165,164,163,162,161,159,157,155,151,147,141,136,132,126,122,118,115,111,108,107,103,101, 97, 95, 91], + [167,165,164,163,162,162,161,160,158,157,155,153,150,146,142,135,129,123,117,113,105,100, 95, 91, 87], + [165,163,163,162,161,161,159,158,156,154,150,147,143,137,132,124,119,112,107,103, 97, 94, 89, 87, 83], + [165,164,163,163,162,161,158,156,153,148,142,135,134,127,120,114,109,103, 99, 97, 93, 90, 87, 84, 80], + [165,164,163,161,160,158,154,150,144,140,132,127,123,117,112,107,103, 98, 95, 93, 89, 87, 83, 81, 77], + [164,163,162,158,155,152,146,142,134,129,122,117,113,107,103, 98, 95, 93, 90, 87, 84, 81, 77, 75, 72], + [163,160,157,152,148,144,137,132,124,119,112,107,103, 98, 96, 92, 89, 86, 83, 81, 78, 76, 72, 70, 66], + [159,154,149,142,137,133,126,121,115,111,107,104,102, 98, 96, 92, 89, 86, 84, 82, 78, 76, 72, 70, 66], + [151,144,139,132,128,124,119,116,112,109,106,103,102, 98, 96, 92, 89, 86, 83, 82, 78, 76, 72, 70, 66], + ], dtype=float) + +# Ground electrical parameters, ITU Handbook on Ground Wave Propagation (2014), +# Table 2 (= ITU-R P.368 figure constants): (sigma, eps_r). Conductivity in its +# natural unit (S/m, mS/m or uS/m); permittivity dimensionless. +_mS = apu.mS / apu.m +_uS = apu.uS / apu.m +Ground_Types = { + "sea_water_low_salinity": (1.0 * SIGMA_UNIT, 80 * EPS_UNIT), + "sea_water_average_salinity": (5.0 * SIGMA_UNIT, 80 * EPS_UNIT), + "fresh_water": (3 * _mS, 80 * EPS_UNIT), + "land_very_wet": (30 * _mS, 40 * EPS_UNIT), + "wet_ground": (10 * _mS, 30 * EPS_UNIT), + "land": (3 * _mS, 22 * EPS_UNIT), + "medium_dry_ground": (1 * _mS, 15 * EPS_UNIT), + "dry_ground": (0.3 * _mS, 7 * EPS_UNIT), + "very_dry_ground": (0.1 * _mS, 3 * EPS_UNIT), + "fresh_water_ice_-1_c": (30 * _uS, 3 * EPS_UNIT), + "fresh_water_ice_-10_c": (10 * _uS, 3 * EPS_UNIT), +} + +# Lookup keyed by ground type, row aligned to Ground_Types order. Sigma reduced +# to canonical S/m; sigma/eps_r stored as floats (findE40 re-attaches units). +_GT_DATA = { + k: (TAB368_TYPES[i], sigma.to_value(SIGMA_UNIT), eps.to_value(EPS_UNIT)) + for i, (k, (sigma, eps)) in enumerate(Ground_Types.items()) +} +# Bin edges midway between adjacent table frequencies: searchsorted then maps a +# frequency to its nearest tabulated column (step-wise table, not interpolated). +_TAB_MIDPT = (TAB368_KHZ[:-1] + TAB368_KHZ[1:]) / 2.0 + +# Allowed victim location strings (normalised) +_VICTIM_LOCATIONS = ('ground_wave', 'free_space') +_REGIMES_GW = np.array(['near-field', 'close near-field', 'GW 40dB/dec', 'GW 20dB/dec'], dtype=' 0). + freq : `~astropy.units.Quantity` [MHz] + Operating frequency, 0.01 to 30 MHz. Scalar or array. + ground_type : str + Propagation-path ground type; see ``Ground_Types``. + E_limit : `~astropy.units.Quantity` [dB(uV/m)] + Permissible field strength at the victim. + BWR : `~astropy.units.Quantity` [dB], optional + Bandwidth ratio added to `E_limit` (eq. 22). Default 0. + victim_location : {'ground_wave', 'free_space'}, optional + Victim path model. Ground wave (default) uses eqs. 9, 11-13; + free space uses eqs. 11-13. + + Returns + ------- + m : `~astropy.units.Quantity` [A m^2] + Magnetic dipole moment (coaxial if d < 2.354*lam_r, else coplanar). + m_type : ndarray of str + ``'coaxial'`` or ``'coplanar'``. + d_transition : `~astropy.units.Quantity` [m] + 40-to-20 dB/dec transition distance (ground wave only; NaN otherwise). + ERP : `~astropy.units.Quantity` [dBW] + Effective radiated power. + distance : `~astropy.units.Quantity` [km] + Protection (separation) distance. + regime : ndarray of str + Regime applied: ``'near-field'``, ``'close near-field'``, + ``'GW 40dB/dec'``/``'GW 20dB/dec'`` (ground wave) or ``'FS 20dB/dec'``. + + Raises + ------ + ValueError + If `victim_location` or `ground_type` is invalid. + + Notes + ----- + `Hm`, `d`, `freq` and `E_limit` are fully vectorised: pass arrays and call + once rather than looping in Python. Most of the per-call cost is astropy's + unit-checking overhead, so one call over N inputs is far faster than N + scalar calls (orders of magnitude for large N). + """ + return _low_prop(Hm, d, freq, ground_type, E_limit, BWR, victim_location) + + +@utils.ranged_quantity_input( + m=(None, None, apu.A * apu.m**2), + ERP=(None, None, cnv.dB_W), + Easy40=(None, None, cnv.dB_uV_m), + E_limit=(None, None, apu.dB(apu.uV / apu.m)), + lam_r=(0, None, apu.m), + d_tr=(0, None, apu.m), + strip_input_units=True, + output_unit=(apu.m, None), +) +def protection_distance(m, ERP, Easy40, E_limit, lam_r, d_tr, + BWR=0.0, victim_location='ground_wave'): + """ + Protection distance for a given propagation regime (ITU-R SM.2028-0 Sec. 4). + + Selects the applicable roll-off regime (top-down: 40 dB/dec, 20 dB/dec, + close near-field, near-field) and returns the corresponding separation + distance. + + Parameters + ---------- + m : `~astropy.units.Quantity` [A m^2] + Magnetic dipole moment. + ERP : `~astropy.units.Quantity` [dBW] + Effective radiated power. + Easy40 : `~astropy.units.Quantity` [dB(uV/m)] + 40 dB/dec asymptote field strength (from ``findE40``). + E_limit : `~astropy.units.Quantity` [dB(uV/m)] + Permissible field strength at the victim. + lam_r : `~astropy.units.Quantity` [m] + Radian wavelength, lambda/(2*pi). + d_tr : `~astropy.units.Quantity` [m] + 40-to-20 dB/dec transition distance (ground wave only). + BWR : `~astropy.units.Quantity` [dB], optional + Bandwidth ratio added to `E_limit`. Default 0. + victim_location : {'ground_wave', 'free_space'}, optional + Victim path model. Default ``'ground_wave'``. + + Returns + ------- + distance : `~astropy.units.Quantity` [m] + Protection (separation) distance. + regime : ndarray of str + Regime applied (see :func:`low_prop`). + + Raises + ------ + ValueError + If `victim_location` is invalid. + """ + if hasattr(BWR, 'value'): BWR = BWR.value # accept Quantity or plain float + ERP_dBkW = ERP - 30.0 # dBW -> dB(kW) + E_eff = E_limit + BWR # eq. 22: E_interference = E_limit + BWR + return _protection_distance(m, ERP_dBkW, Easy40, E_eff, lam_r, d_tr, + victim_location) + + +def _findE40(freq, ground_type): + key = ground_type.lower() + if key not in _GT_DATA: + raise ValueError( + f"Ground type '{ground_type}' not recognized. " + f"Valid names: {list(Ground_Types)}" + ) + row, cond_gr, permit_gr = _GT_DATA[key] + freq_kHz = np.atleast_1d(freq) * 1e3 + idx = np.clip(np.searchsorted(_TAB_MIDPT, freq_kHz), 0, len(TAB368_KHZ) - 1) + E40 = row[idx] # row is already float64 + return (E40.item() if E40.size == 1 else E40), cond_gr, permit_gr + + +def _protection_distance(m, ERP_dBkW, Easy40_dB, E_limit, lam_r, d_tr, + victim_location='ground_wave'): + # ERC Report 69 Sec.8 top-down priority (worst case): 40->20->close NF->inside NF + Hl = np.power(10.0, (E_limit - 171.5) / 20.0) # eq. 8 [A/m] + cr = 2.354 * lam_r + r_nf = np.cbrt(m / (2.0 * np.pi * Hl)) # eq. 13 [m] + r_cnf = np.sqrt(m / (2.0 * np.pi * Hl * lam_r)) # eq. 12 [m] + r_20 = np.power(10.0, (169.5 + ERP_dBkW - E_limit) / 20.0) # eq. 11 [m] + if victim_location == 'ground_wave': + r_40 = 1000.0 * np.power(10.0, (Easy40_dB + ERP_dBkW - E_limit) / 40.0) # eq. 9 + idx = np.where((r_40 >= d_tr) & (r_40 >= cr), 2, + np.where(r_20 >= cr, 3, + np.where(r_cnf >= lam_r, 1, 0))) + # elementwise pick (np.array([...])[idx] would broadcast to N x N) + r = np.choose(idx, np.broadcast_arrays(r_nf, r_cnf, r_40, r_20)) + return r, _REGIMES_GW[idx] + idx = np.where(r_20 >= cr, 2, np.where(r_cnf >= lam_r, 1, 0)) + r = np.choose(idx, np.broadcast_arrays(r_nf, r_cnf, r_20)) + return r, _REGIMES_FS[idx] + + +def _low_prop(Hm, d, freq, ground_type, E_limit, BWR=0.0, victim_location='ground_wave'): + loc = victim_location.lower().replace(' ', '_') + if loc not in _VICTIM_LOCATIONS: + raise ValueError( + f"victim_location '{victim_location}' not recognized. " + f"Valid values: {list(_VICTIM_LOCATIONS)}" + ) + if hasattr(BWR, 'value'): BWR = BWR.value # accept Quantity or plain float + Hm, d, freq, E_limit, BWR = (np.asarray(x) for x in (Hm, d, freq, E_limit, BWR)) + E_limit = E_limit + BWR # eq. 22 + Easy40_dB, _, _ = _findE40(freq, ground_type) + lam_r = C_M_S / (freq * 1e6) / (2 * np.pi) + Hm_abs = np.power(10.0, (Hm - 120.0) / 20.0) # dB(uA/m) -> A/m + + m1 = Hm_abs * (2 * np.pi * lam_r * d**3) / np.sqrt(lam_r**2 + d**2) # eq. 1 + m2 = Hm_abs * (4 * np.pi * lam_r**2 * d**3) / np.sqrt(lam_r**4 - lam_r**2*d**2 + d**4) # eq. 2 + + crossover = 2.354 * lam_r + coaxial = d < crossover # spec Sec. 2 + m = np.where(coaxial, m1, m2) + m_type = np.where(coaxial, 'coaxial', 'coplanar') # already dtype +30 for dB(W) + return (10 * np.log10((20 / LAM_R**4) * m_val**2 / 1000) + 30) * cnv.dB_W + + +def test_findE40(): + # SM.2028 Table 1: land (GT6) @ 1/10 MHz; case-insensitive lookup + E40, sigma, eps = sm2028.findE40(np.array([1.0, 10.0]) * apu.MHz, 'land') + assert_quantity_allclose(E40, [120.0, 90.0] * cnv.dB_uV_m, **TOL) + assert_quantity_allclose(sigma, 3.e-3 * apu.S / apu.m, **TOL) + assert_quantity_allclose(eps, 22.0 * cnv.dimless, **TOL) + assert_quantity_allclose( + sm2028.findE40(1.0 * apu.MHz, 'LAND')[0], E40[0], **TOL) + assert_quantity_allclose( + sm2028.findE40(1.0 * apu.MHz, 'sea_water_average_salinity')[0], + 153.0 * cnv.dB_uV_m, **TOL) + + with pytest.raises(ValueError, match='not recognized'): + sm2028.findE40(1.0 * apu.MHz, 'ocean') + with pytest.raises(Exception): + sm2028.findE40(50.0 * apu.MHz, 'land') # out of range + + +def test_low_prop(): + # SM.2028 Table 3 reference + m, m_type, d_tr, ERP, dist, regime = sm2028.low_prop( + 18.0 * apu.dB(apu.uA / apu.m), 3.0 * apu.m, 1.0 * apu.MHz, 'land', E_LIM) + assert_quantity_allclose(m, 1.344e-3 * apu.A * apu.m**2, rtol=1.e-3) + assert_quantity_allclose(ERP, -111.58 * cnv.dB_W, **TOL) + assert_quantity_allclose(d_tr, 3349.65 * apu.m, rtol=1.e-4) + assert_quantity_allclose(dist, 25.e-3 * apu.km, rtol=1.e-3) + assert_equal((m_type, regime), ('coaxial', 'near-field')) + + with pytest.raises(ValueError, match='not recognized'): + sm2028.low_prop(18.0 * apu.dB(apu.uA / apu.m), 3.0 * apu.m, + 1.0 * apu.MHz, 'land', E_LIM, victim_location='airborne') + + +@pytest.mark.parametrize('Hm, d, freq, E, loc, regime, mtype, dist', [ + (-20.0, 10.0, 13.385, -54.86, 'ground_wave', 'GW 40dB/dec', 'coplanar', 4.832e3), + (45.39, 3.0, 1.0, 14.23, 'ground_wave', 'GW 20dB/dec', 'coaxial', 113.5), + (-20.0, 10.0, 13.385, -54.86, 'free_space', 'FS 20dB/dec', 'coplanar', None), +]) +def test_low_prop_regimes(Hm, d, freq, E, loc, regime, mtype, dist): + m_, m_type, d_tr, _, r, reg = sm2028.low_prop( + Hm * apu.dB(apu.uA / apu.m), d * apu.m, freq * apu.MHz, 'land', + E * apu.dB(apu.uV / apu.m), victim_location=loc) + assert_equal((m_type, reg), (mtype, regime)) + if loc == 'free_space': + assert np.isnan(d_tr.value) + else: + assert_quantity_allclose(r, dist * apu.m, rtol=1.e-2) + + +def test_low_prop_bwr(): + args = (18.0 * apu.dB(apu.uA / apu.m), 3.0 * apu.m, 1.0 * apu.MHz, 'land', E_LIM) + d0 = sm2028.low_prop(*args)[4] + d3 = [sm2028.low_prop(*args, BWR=b)[4] for b in (3.0, 3.0 * cnv.dB, 3.0 * apu.dB)] + d6 = sm2028.low_prop(*args, BWR=6.0)[4] + + assert d0 > d3[0] > d6 # threshold up -> distance down + assert_quantity_allclose(d3[0], d3[1], **TOL) + assert_quantity_allclose(d3[0], d3[2], **TOL) + + +@pytest.mark.parametrize('m, E, regime, dist', [ + (1.344890e-3, 14.23, 'near-field', 25.0), + (0.861, 50.0, 'close near-field', 58.42), + (3.15e-2, 14.23, 'GW 20dB/dec', 113.5), +]) + + +def test_protection_distance(m, E, regime, dist): + m_q = m * apu.A * apu.m**2 + r, reg = sm2028.protection_distance( + m_q, _erp(m), 120.0 * cnv.dB_uV_m, + E * apu.dB(apu.uV / apu.m), LAM_R * apu.m, 3349.65 * apu.m) + assert_equal(reg, regime) + assert_quantity_allclose(r, dist * apu.m, rtol=1.e-2) + + +def test_protection_distance_bwr(): + m = 1.344890e-3 * apu.A * apu.m**2 + head = (m, _erp(m.value), 120.0 * cnv.dB_uV_m) + tail = (LAM_R * apu.m, 3349.65 * apu.m) + + d_manual = sm2028.protection_distance( + *head, (14.23 + 3.0) * apu.dB(apu.uV / apu.m), *tail)[0] + d_bwr = sm2028.protection_distance( + *head, E_LIM, *tail, BWR=3.0)[0] + assert_quantity_allclose(d_manual, d_bwr, **TOL) + +def test_low_prop_vectorised(): + # guards against the N x N broadcasting bug in regime selection: + # array inputs must yield (N,)-shaped outputs, matching scalar calls. + N = 8 + Hm = np.linspace(0.0, 40.0, N) * apu.dB(apu.uA / apu.m) + d = np.full(N, 3.0) * apu.m + freq = np.full(N, 1.0) * apu.MHz + E = np.full(N, 14.23) * apu.dB(apu.uV / apu.m) + + m, m_type, d_tr, ERP, dist, regime = sm2028.low_prop(Hm, d, freq, 'land', E) + assert dist.shape == (N,) + assert regime.shape == (N,) + + # each element matches the equivalent scalar call + for i in range(N): + *_, dist_i, reg_i = sm2028.low_prop( + Hm[i], d[i], freq[i], 'land', E[i]) + assert_quantity_allclose(dist[i], dist_i, **TOL) + assert_equal(regime[i], reg_i) \ No newline at end of file diff --git a/pycraf/viewpano.npy b/pycraf/viewpano.npy new file mode 100644 index 000000000..b7cfe0256 Binary files /dev/null and b/pycraf/viewpano.npy differ diff --git a/viewpano.npy b/viewpano.npy new file mode 100644 index 000000000..b7cfe0256 Binary files /dev/null and b/viewpano.npy differ