eas-3d-pattern 0.1.4__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/PKG-INFO +1 -1
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/pyproject.toml +17 -1
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/__init__.py +3 -2
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/metrics/__init__.py +36 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/metrics/directivity.py +75 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/metrics/efficiency.py +128 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/metrics/peak.py +133 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/metrics/quadrature.py +63 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/ngmn/__init__.py +41 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/ngmn/coordinates.py +129 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/ngmn/loader.py +146 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/ngmn/metadata.py +388 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/parser.py +415 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/plotting.py +243 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/processing.py +257 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/schema_manager.py +138 -5
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/README.md +77 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/__init__.py +44 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/_compat.py +122 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/definitions.py +145 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/eas_preset.py +83 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/ngmn_type_a_preset.py +148 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/sector/presets.py +110 -0
- eas_3d_pattern-0.2.0/src/eas_3d_pattern/util_func/guards.py +39 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/util_func/report.py +11 -10
- eas_3d_pattern-0.1.4/src/eas_3d_pattern/parser.py +0 -1211
- eas_3d_pattern-0.1.4/src/eas_3d_pattern/sector_definitions.py +0 -385
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/LICENSE +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/README.md +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/sample_data/VENDOR_ANTMODEL1_2100_A-Y1_RFp01_T00p0_P45_NonUniformSampling_3drp.json +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/sample_data/VENDOR_ANTMODEL2_2100_A-Y1_RFp01_T00p0_P45_UniformSampling_3drp.json +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/sample_data/VENDOR_ANTMODEL3_3700_EID12345_T00p0_P45_NonUniformSampling_3drp.json +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/sample_data/VENDOR_ANTMODEL4_3700_BID67890_T00p0_P45_UniformSampling_3drp.json +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/sample_data/__init__.py +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/schemas/NGMN_BASTA_AA_3drp_JSON_Schema_WP3_0_latest.json +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/schemas/__init__.py +0 -0
- {eas_3d_pattern-0.1.4 → eas_3d_pattern-0.2.0}/src/eas_3d_pattern/util_func/__init__.py +0 -0
|
@@ -38,7 +38,7 @@ classifiers = [
|
|
|
38
38
|
"Operating System :: OS Independent",
|
|
39
39
|
"Topic :: Scientific/Engineering :: Visualization",
|
|
40
40
|
]
|
|
41
|
-
version = "0.
|
|
41
|
+
version = "0.2.0"
|
|
42
42
|
|
|
43
43
|
[project.urls]
|
|
44
44
|
homepage = "https://www.ericsson.com/antenna-system"
|
|
@@ -60,6 +60,11 @@ ruff = "^0.11.12"
|
|
|
60
60
|
mypy = "^1.16.0"
|
|
61
61
|
pre-commit = "^4.2.0"
|
|
62
62
|
pytest = "^8.0"
|
|
63
|
+
jupyterlab = "^4.6.2"
|
|
64
|
+
matplotlib = "^3.10.0"
|
|
65
|
+
pytest-mpl = "^0.17.0"
|
|
66
|
+
pytest-cov = "^7.0.0"
|
|
67
|
+
scipy = "^1.14"
|
|
63
68
|
|
|
64
69
|
[tool.poetry.group.typing.dependencies]
|
|
65
70
|
pandas-stubs = "2.2.2.240909"
|
|
@@ -142,6 +147,9 @@ ignore = [
|
|
|
142
147
|
"src/eas_3d_pattern/parser.py" = [
|
|
143
148
|
"D102", # Missing docstring in public method (very common for test methods)
|
|
144
149
|
]
|
|
150
|
+
"tests/**" = [
|
|
151
|
+
"D102", # Test method names are self-documenting; class docstrings cover intent
|
|
152
|
+
]
|
|
145
153
|
|
|
146
154
|
[tool.ruff.lint.pydocstyle]
|
|
147
155
|
convention = "google"
|
|
@@ -159,8 +167,16 @@ docstring-code-format = true
|
|
|
159
167
|
minversion = "8.0"
|
|
160
168
|
testpaths = ["tests"]
|
|
161
169
|
addopts = "-q"
|
|
170
|
+
markers = [
|
|
171
|
+
"image: compares a rendered figure against a PNG baseline; deselect with -m 'not image' if a runner's renderer differs",
|
|
172
|
+
"slow: exercises a multi-megabyte bundled sample; deselect with -m 'not slow'",
|
|
173
|
+
]
|
|
162
174
|
filterwarnings = [
|
|
163
175
|
"ignore::DeprecationWarning",
|
|
176
|
+
# The library's own deprecations are FutureWarning (visible to end users by
|
|
177
|
+
# default). Promote them to errors so internal code and tests cannot quietly
|
|
178
|
+
# depend on a deprecated entry point; assert them with pytest.warns instead.
|
|
179
|
+
"error::FutureWarning",
|
|
164
180
|
]
|
|
165
181
|
|
|
166
182
|
[tool.mypy]
|
|
@@ -3,8 +3,8 @@ from importlib.metadata import PackageNotFoundError, version
|
|
|
3
3
|
|
|
4
4
|
from .parser import AntennaPattern
|
|
5
5
|
from .sample_data import SAMPLE_JSON
|
|
6
|
-
from .schema_manager import NGMNSchema
|
|
7
|
-
from .
|
|
6
|
+
from .schema_manager import NGMNSchema, SchemaSource
|
|
7
|
+
from .sector import SectorDefinition
|
|
8
8
|
from .util_func.report import generate_report_eas
|
|
9
9
|
|
|
10
10
|
LIBRARY_PACKAGE_NAME = __name__
|
|
@@ -20,6 +20,7 @@ __all__ = [
|
|
|
20
20
|
"SAMPLE_JSON",
|
|
21
21
|
"AntennaPattern",
|
|
22
22
|
"NGMNSchema",
|
|
23
|
+
"SchemaSource",
|
|
23
24
|
"SectorDefinition",
|
|
24
25
|
"generate_report_eas",
|
|
25
26
|
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""Antenna pattern metrics.
|
|
10
|
+
|
|
11
|
+
Figures of merit derived from the processed pattern dataset — directivity,
|
|
12
|
+
ohmic losses, and the shared solid-angle quadrature weights they integrate
|
|
13
|
+
against. Implemented as pure functions taking the dataset, so they can be used
|
|
14
|
+
and tested without constructing an ``AntennaPattern``.
|
|
15
|
+
|
|
16
|
+
Not re-exported from the top-level package: ``AntennaPattern`` remains the
|
|
17
|
+
supported entry point while the API-naming work (plan section 2.9 / M2-14) is
|
|
18
|
+
still open.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from eas_3d_pattern.metrics.directivity import directivity, losses
|
|
24
|
+
from eas_3d_pattern.metrics.efficiency import beam_efficiency
|
|
25
|
+
from eas_3d_pattern.metrics.peak import find_peak, top_3db_border
|
|
26
|
+
from eas_3d_pattern.metrics.quadrature import DOMEGA, ensure_domega
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"DOMEGA",
|
|
30
|
+
"beam_efficiency",
|
|
31
|
+
"directivity",
|
|
32
|
+
"ensure_domega",
|
|
33
|
+
"find_peak",
|
|
34
|
+
"losses",
|
|
35
|
+
"top_3db_border",
|
|
36
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""Directivity and ohmic-loss metrics.
|
|
10
|
+
|
|
11
|
+
Pure functions over the processed pattern dataset: data in, number out. Neither
|
|
12
|
+
reads nor mutates ``AntennaPattern`` state, beyond the deliberate ``dOmega``
|
|
13
|
+
cache injected by :func:`~eas_3d_pattern.metrics.quadrature.ensure_domega`.
|
|
14
|
+
|
|
15
|
+
Split out of ``parser.py`` (Phase 3a of the god-class decomposition, plan
|
|
16
|
+
section 2.1.3).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
from typing import cast
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import xarray as xr
|
|
26
|
+
|
|
27
|
+
from eas_3d_pattern.metrics.quadrature import DOMEGA, ensure_domega
|
|
28
|
+
from eas_3d_pattern.util_func.guards import verify
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def directivity(pattern_3d: xr.Dataset) -> float:
|
|
34
|
+
"""Calculate the directivity of the antenna pattern data.
|
|
35
|
+
|
|
36
|
+
Directivity is calculated with the average radiation intensity over the whole
|
|
37
|
+
sphere and the maximum radiation intensity. Can only be calculated if data is
|
|
38
|
+
complete (full sphere). Regular grids are advised.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
pattern_3d (xr.Dataset): Processed pattern dataset. Gains a ``dOmega``
|
|
42
|
+
data variable as a side effect if it is not already present.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
float: The directivity value in dBi.
|
|
46
|
+
"""
|
|
47
|
+
logger.debug("AntennaPattern: Calculating directivity of antenna pattern data.")
|
|
48
|
+
ensure_domega(pattern_3d)
|
|
49
|
+
Umax = float(pattern_3d["P_tp_lin"].max())
|
|
50
|
+
Uavg = float(
|
|
51
|
+
(pattern_3d["P_tp_lin"] * pattern_3d[DOMEGA]).sum(("Theta", "Phi"))
|
|
52
|
+
/ (pattern_3d[DOMEGA].sum())
|
|
53
|
+
)
|
|
54
|
+
return float(10 * np.log10(Umax / Uavg))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def losses(pattern_3d: xr.Dataset, gain_dbi: float | None) -> float:
|
|
58
|
+
"""Calculate the ohmic losses of the antenna pattern data.
|
|
59
|
+
|
|
60
|
+
Antenna losses are the declared header gain minus the calculated directivity.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
pattern_3d (xr.Dataset): Processed pattern dataset.
|
|
64
|
+
gain_dbi (float | None): Declared gain in dBi from the pattern metadata.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If no gain is declared in the pattern metadata.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
float: The loss value in dB (gain - directivity).
|
|
71
|
+
"""
|
|
72
|
+
logger.debug("AntennaPattern: Calculating losses of antenna pattern data.")
|
|
73
|
+
verify(gain_dbi is not None, "AntennaPattern: 'Gain' not provided, it's required.")
|
|
74
|
+
# verify() guarantees gain_dbi is non-None; cast narrows for mypy.
|
|
75
|
+
return float(cast("float", gain_dbi) - directivity(pattern_3d))
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""Beam efficiency over sector definitions.
|
|
10
|
+
|
|
11
|
+
Solid-angle-weighted power fraction falling inside each declared sector. Pure
|
|
12
|
+
function over the processed pattern dataset and a
|
|
13
|
+
:class:`~eas_3d_pattern.sector.SectorDefinition`.
|
|
14
|
+
|
|
15
|
+
Preset *dispatch* deliberately stays on ``AntennaPattern``: choosing a preset
|
|
16
|
+
requires pattern-derived inputs (the measured 3 dB border, the beam peak) that
|
|
17
|
+
are produced by side-effecting methods, and threading those through a pure
|
|
18
|
+
function would change which dataset attributes end up published.
|
|
19
|
+
|
|
20
|
+
Split out of ``parser.py`` (Phase 3c of the god-class decomposition, plan
|
|
21
|
+
section 2.1.3).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
import operator
|
|
28
|
+
from collections.abc import Callable
|
|
29
|
+
from types import MappingProxyType
|
|
30
|
+
|
|
31
|
+
import xarray as xr
|
|
32
|
+
|
|
33
|
+
from eas_3d_pattern.metrics.quadrature import DOMEGA, ensure_domega
|
|
34
|
+
from eas_3d_pattern.sector import BoundaryBox, Sector
|
|
35
|
+
from eas_3d_pattern.util_func.guards import verify
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
#: Component holding the linear total-power pattern.
|
|
40
|
+
POWER_COMPONENT_LIN = "P_tp_lin"
|
|
41
|
+
#: Component holding the linear co-polar pattern.
|
|
42
|
+
COPOLAR_COMPONENT_LIN = "P_co_lin"
|
|
43
|
+
|
|
44
|
+
#: Comparison operators a sector boundary may declare. Read-only lookup table.
|
|
45
|
+
BOUNDARY_OPERATORS: MappingProxyType[str, Callable[[float, float], bool]] = (
|
|
46
|
+
MappingProxyType(
|
|
47
|
+
{
|
|
48
|
+
"<": operator.lt,
|
|
49
|
+
"<=": operator.le,
|
|
50
|
+
">": operator.gt,
|
|
51
|
+
">=": operator.ge,
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def beam_efficiency(
|
|
58
|
+
pattern: xr.Dataset,
|
|
59
|
+
sectors: Sector,
|
|
60
|
+
powersum: bool = True,
|
|
61
|
+
) -> dict[str, float]:
|
|
62
|
+
"""Calculate the beam efficiency of the antenna pattern data.
|
|
63
|
+
|
|
64
|
+
Beam efficiency is the ratio of the solid-angle-weighted power inside each
|
|
65
|
+
sector to the overall weighted power. Calculations are based only on the
|
|
66
|
+
summation method for now.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
pattern (xr.Dataset): Processed pattern dataset. Gains a ``dOmega``
|
|
70
|
+
data variable as a side effect if it is not already present.
|
|
71
|
+
sectors (Sector): Sector boundaries to integrate over.
|
|
72
|
+
powersum (bool, optional): If True, efficiency is calculated on total
|
|
73
|
+
power. If False, on the co-polar pattern. Defaults to True.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
ValueError: If the overall power sums to zero, which would make every
|
|
77
|
+
efficiency nan or inf.
|
|
78
|
+
TypeError: If any sector is not a ``BoundaryBox``.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
dict[str, float]: Sector name to efficiency fraction.
|
|
82
|
+
"""
|
|
83
|
+
logger.debug("AntennaPattern: Calculating beam efficiency of antenna pattern data.")
|
|
84
|
+
component = POWER_COMPONENT_LIN if powersum else COPOLAR_COMPONENT_LIN
|
|
85
|
+
field_values = pattern[component]
|
|
86
|
+
|
|
87
|
+
ensure_domega(pattern)
|
|
88
|
+
|
|
89
|
+
weighted_field_values = pattern[DOMEGA] * field_values
|
|
90
|
+
Sp_overall = float(weighted_field_values.sum())
|
|
91
|
+
verify(Sp_overall != 0, "Overall power is zero; cannot compute beam efficiency.")
|
|
92
|
+
|
|
93
|
+
efficiency = {}
|
|
94
|
+
for sector_name, box in sectors.sectors.items():
|
|
95
|
+
verify(isinstance(box, BoundaryBox),
|
|
96
|
+
f"Sector Definitions need to be class 'BoundaryBox' but is class {type(box)}.",
|
|
97
|
+
TypeError)
|
|
98
|
+
efficiency[sector_name] = float(
|
|
99
|
+
_sector_sum(weighted_field_values, box) / Sp_overall
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return efficiency
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sector_sum(
|
|
106
|
+
weighted_field_values: xr.DataArray, box: BoundaryBox
|
|
107
|
+
) -> xr.DataArray:
|
|
108
|
+
"""Sum the weighted field inside one rectangular sector.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
weighted_field_values (xr.DataArray): Field already multiplied by ``dOmega``.
|
|
112
|
+
box (BoundaryBox): Theta/Phi bounds, each paired with the comparison
|
|
113
|
+
operator to apply.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
xr.DataArray: Zero-dimensional array holding the weighted power inside the sector.
|
|
117
|
+
"""
|
|
118
|
+
theta = weighted_field_values.Theta
|
|
119
|
+
phi = weighted_field_values.Phi
|
|
120
|
+
return (
|
|
121
|
+
weighted_field_values.where(
|
|
122
|
+
BOUNDARY_OPERATORS[box.theta_min[1]](box.theta_min[0], theta), drop=True
|
|
123
|
+
)
|
|
124
|
+
.where(BOUNDARY_OPERATORS[box.theta_max[1]](theta, box.theta_max[0]), drop=True)
|
|
125
|
+
.where(BOUNDARY_OPERATORS[box.phi_min[1]](box.phi_min[0], phi), drop=True)
|
|
126
|
+
.where(BOUNDARY_OPERATORS[box.phi_max[1]](phi, box.phi_max[0]), drop=True)
|
|
127
|
+
.sum()
|
|
128
|
+
)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""Beam peak location and the top 3 dB border.
|
|
10
|
+
|
|
11
|
+
Pure functions over the processed pattern dataset. Neither reads nor mutates
|
|
12
|
+
``AntennaPattern`` state: the dataset attributes that ``util_func/report.py``
|
|
13
|
+
depends on (``peak_coordinates``, ``top_3db_point``) are published by the
|
|
14
|
+
``AntennaPattern`` wrappers, which own that side effect.
|
|
15
|
+
|
|
16
|
+
``top_3db_border`` takes the peak as an argument rather than locating it itself,
|
|
17
|
+
so the caller decides whether locating the peak should also publish it.
|
|
18
|
+
|
|
19
|
+
Split out of ``parser.py`` (Phase 3b of the god-class decomposition, plan
|
|
20
|
+
section 2.1.3).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import logging
|
|
26
|
+
|
|
27
|
+
import numpy as np
|
|
28
|
+
import xarray as xr
|
|
29
|
+
|
|
30
|
+
from eas_3d_pattern.util_func.guards import verify
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
#: Component holding the total-power pattern in dB.
|
|
35
|
+
POWER_COMPONENT = "P_tp_dB"
|
|
36
|
+
#: Component holding the co-polar pattern in dB.
|
|
37
|
+
COPOLAR_COMPONENT = "P_co_dB"
|
|
38
|
+
#: Half-power level relative to the beam peak, in dB.
|
|
39
|
+
HALF_POWER_DB = -3.0
|
|
40
|
+
#: Grid step assumed when the theta axis has too few points to measure one.
|
|
41
|
+
FALLBACK_GRID_STEP_DEG = 1.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def component_name(power: bool) -> str:
|
|
45
|
+
"""Return the dataset component to search for a given ``power`` flag.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
power (bool): Whether to use the total-power component instead of co-polar.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
str: Name of the data variable to read.
|
|
52
|
+
"""
|
|
53
|
+
return POWER_COMPONENT if power else COPOLAR_COMPONENT
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_peak(pattern: xr.Dataset, power: bool = False) -> tuple[float, float]:
|
|
57
|
+
"""Find the Theta/Phi coordinates of the pattern peak.
|
|
58
|
+
|
|
59
|
+
Searches for the maximum value within the pattern data array. If two points
|
|
60
|
+
represent a maximum value, the first one is returned.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
pattern (xr.Dataset): Processed pattern dataset.
|
|
64
|
+
power (bool, optional): Whether to search the power component instead of
|
|
65
|
+
the co-polarized one. Defaults to False.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If the component yields no peak coordinate pair, which happens
|
|
69
|
+
when the dataset is not indexed by both Theta and Phi.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
tuple[float, float]: (theta, phi) coordinates of the peak in degrees.
|
|
73
|
+
"""
|
|
74
|
+
component = component_name(power)
|
|
75
|
+
logger.debug(f"AntennaPattern: Searching for peak of component {component}")
|
|
76
|
+
peak_tuple = (
|
|
77
|
+
pattern.stack(pt=("Theta", "Phi")).idxmax("pt")[component].values.item()
|
|
78
|
+
)
|
|
79
|
+
verify(isinstance(peak_tuple, tuple),
|
|
80
|
+
"AntennaPattern: Failed to find peak coordinates for the component. Make sure to select a component with data.")
|
|
81
|
+
theta_val_peak, phi_val_peak = peak_tuple
|
|
82
|
+
logger.debug(f"AntennaPattern: Peak coordinates found: {peak_tuple}")
|
|
83
|
+
return theta_val_peak, phi_val_peak
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def top_3db_border(
|
|
87
|
+
pattern: xr.Dataset,
|
|
88
|
+
theta_peak: float,
|
|
89
|
+
phi_peak: float,
|
|
90
|
+
power: bool = False,
|
|
91
|
+
) -> float:
|
|
92
|
+
"""Find the Theta border where the beam falls 3 dB below its peak.
|
|
93
|
+
|
|
94
|
+
No interpolation is done; this is a simplistic search for the last point
|
|
95
|
+
reported above -3 dB, advanced by one theta grid step.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
pattern (xr.Dataset): Processed pattern dataset.
|
|
99
|
+
theta_peak (float): Theta coordinate of the beam peak, in degrees.
|
|
100
|
+
phi_peak (float): Phi coordinate of the beam peak, in degrees.
|
|
101
|
+
power (bool, optional): Whether to cut the power component instead of the
|
|
102
|
+
co-polarized one. Defaults to False, which complies with the NGMN standard.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
float: Theta border for the top 3 dB point in degrees.
|
|
106
|
+
"""
|
|
107
|
+
vertical_cut = pattern.sel(Phi=phi_peak)
|
|
108
|
+
vertical_cut_normed = vertical_cut[component_name(power)]
|
|
109
|
+
|
|
110
|
+
# Fallback: if no point at/below -3 dB exists above the peak (e.g. a very
|
|
111
|
+
# narrow beam peaking at the top of the cut), the 3 dB border collapses to
|
|
112
|
+
# the peak theta itself instead of leaving ``top_border`` unbound.
|
|
113
|
+
top_border = float(theta_peak)
|
|
114
|
+
|
|
115
|
+
# Advance the border by the actual theta grid step rather than a hardcoded
|
|
116
|
+
# 1 deg, so the result is correct for any sampling resolution.
|
|
117
|
+
theta_axis = np.sort(vertical_cut_normed["Theta"].values)
|
|
118
|
+
if theta_axis.size > 1:
|
|
119
|
+
grid_step = float(np.median(np.diff(theta_axis)))
|
|
120
|
+
else:
|
|
121
|
+
grid_step = FALLBACK_GRID_STEP_DEG
|
|
122
|
+
|
|
123
|
+
for theta_val in np.flip(
|
|
124
|
+
vertical_cut_normed.sel(Theta=slice(0, theta_peak))["Theta"]
|
|
125
|
+
):
|
|
126
|
+
if vertical_cut_normed.sel(Theta=theta_val) <= HALF_POWER_DB:
|
|
127
|
+
top_border = float(theta_val.values) + grid_step
|
|
128
|
+
break
|
|
129
|
+
else:
|
|
130
|
+
logger.warning(
|
|
131
|
+
"AntennaPattern: No -3 dB crossing found above the peak; using peak theta as top 3 dB border."
|
|
132
|
+
)
|
|
133
|
+
return top_border
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""Solid-angle quadrature weights for integration over the sphere.
|
|
10
|
+
|
|
11
|
+
Provides the ``dOmega`` weights that turn a plain sum over grid points into a
|
|
12
|
+
solid-angle-weighted integral. Shared by the directivity and beam-efficiency
|
|
13
|
+
metrics, which is why this lives in its own module rather than beside either.
|
|
14
|
+
|
|
15
|
+
Split out of ``parser.py`` (Phase 3a of the god-class decomposition, plan
|
|
16
|
+
section 2.1.3).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
import xarray as xr
|
|
23
|
+
|
|
24
|
+
#: Name of the data variable holding the solid-angle weights on the dataset.
|
|
25
|
+
DOMEGA = "dOmega"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ensure_domega(pattern_3d: xr.Dataset) -> None:
|
|
29
|
+
"""Ensure the solid-angle weight ``dOmega`` exists on ``pattern_3d``.
|
|
30
|
+
|
|
31
|
+
Computes ``dOmega = sin(theta) * dTheta * dPhi`` and injects it as a data variable,
|
|
32
|
+
which is what turns a plain sum over grid points into a solid-angle-weighted
|
|
33
|
+
integral over the sphere. No-op if it is already present, so the cost is paid once
|
|
34
|
+
per pattern.
|
|
35
|
+
|
|
36
|
+
``np.gradient`` is applied to the coordinate arrays themselves, so it yields the
|
|
37
|
+
local spacing at each point and therefore handles unevenly spaced grids as well as
|
|
38
|
+
regular ones.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
pattern_3d (xr.Dataset): Processed pattern dataset, modified in place.
|
|
42
|
+
|
|
43
|
+
Note:
|
|
44
|
+
This mutates ``pattern_3d`` in place. The side effect is deliberate and relied
|
|
45
|
+
upon as a cache by the directivity and beam-efficiency metrics.
|
|
46
|
+
"""
|
|
47
|
+
if DOMEGA in pattern_3d.data_vars:
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
# The transpose operation for 1D vectors has no effect
|
|
51
|
+
weight = np.repeat(
|
|
52
|
+
np.sin(np.deg2rad(pattern_3d.Theta.values)).T[:, None],
|
|
53
|
+
len(pattern_3d.Phi),
|
|
54
|
+
axis=1,
|
|
55
|
+
)
|
|
56
|
+
dTheta = np.abs(np.gradient(np.deg2rad(pattern_3d["Theta"]))).reshape(-1, 1)
|
|
57
|
+
dPhi = np.abs(np.gradient(np.deg2rad(pattern_3d["Phi"]))).reshape(1, -1)
|
|
58
|
+
pattern_3d[DOMEGA] = xr.DataArray(
|
|
59
|
+
weight * (dTheta * dPhi),
|
|
60
|
+
dims=("Theta", "Phi"),
|
|
61
|
+
coords={"Theta": pattern_3d.Theta, "Phi": pattern_3d.Phi},
|
|
62
|
+
name=DOMEGA,
|
|
63
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""NGMN BASTA domain package.
|
|
10
|
+
|
|
11
|
+
Groups code bound to the NGMN BASTA specification: reading and validating 3drp
|
|
12
|
+
JSON files (:mod:`~eas_3d_pattern.ngmn.loader`), the read-only :class:`Metadata`
|
|
13
|
+
accessors, and the coordinate-system conversions
|
|
14
|
+
(:mod:`~eas_3d_pattern.ngmn.coordinates`).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from eas_3d_pattern.ngmn.coordinates import (
|
|
20
|
+
DEFAULT_INTERNAL_COORD_SYSTEM,
|
|
21
|
+
EXPECTED_COORDINATE_SYSTEMS,
|
|
22
|
+
to_internal_frame,
|
|
23
|
+
)
|
|
24
|
+
from eas_3d_pattern.ngmn.loader import (
|
|
25
|
+
json_load,
|
|
26
|
+
load_json_file,
|
|
27
|
+
normalize_keys,
|
|
28
|
+
validate_against_schema,
|
|
29
|
+
)
|
|
30
|
+
from eas_3d_pattern.ngmn.metadata import Metadata
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"DEFAULT_INTERNAL_COORD_SYSTEM",
|
|
34
|
+
"EXPECTED_COORDINATE_SYSTEMS",
|
|
35
|
+
"Metadata",
|
|
36
|
+
"json_load",
|
|
37
|
+
"load_json_file",
|
|
38
|
+
"normalize_keys",
|
|
39
|
+
"to_internal_frame",
|
|
40
|
+
"validate_against_schema",
|
|
41
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# SPDX-FileCopyrightText: 2025-2026 Ericsson
|
|
3
|
+
#
|
|
4
|
+
# Author: Mattia Milani <mattia.milani@ericsson.com>
|
|
5
|
+
#
|
|
6
|
+
# This file is part of eas-3d-pattern and is distributed under the terms of the
|
|
7
|
+
# MIT License. See the LICENSE file at the repository root for the full text,
|
|
8
|
+
# including the warranty disclaimer and redistribution conditions.
|
|
9
|
+
"""NGMN spherical pattern coordinate systems and conversion to the internal frame.
|
|
10
|
+
|
|
11
|
+
All calculations in this library run in ``SPCS_Ericsson``, which matches
|
|
12
|
+
``SPCS_Polar`` except that phi is defined on [-180, 179]. This module owns the
|
|
13
|
+
per-system transforms into that frame and the post-condition checks that reject
|
|
14
|
+
out-of-spec input.
|
|
15
|
+
|
|
16
|
+
Split out of ``parser.py`` (Phase 4a of the god-class decomposition, plan
|
|
17
|
+
section 2.1.3).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
import xarray as xr
|
|
26
|
+
|
|
27
|
+
from eas_3d_pattern.util_func.guards import verify
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
#: Coordinate systems named by the NGMN BASTA specification, plus the internal frame.
|
|
32
|
+
#: Currently informational only — the transform whitelist is ``_TO_ERICSSON``.
|
|
33
|
+
EXPECTED_COORDINATE_SYSTEMS = [
|
|
34
|
+
"SPCS_Polar",
|
|
35
|
+
"SPCS_CW",
|
|
36
|
+
"SPCS_CCW",
|
|
37
|
+
"SPCS_Geo",
|
|
38
|
+
"SPCS_Ericsson",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
#: The frame every calculation in this library is performed in.
|
|
42
|
+
DEFAULT_INTERNAL_COORD_SYSTEM = "SPCS_Ericsson"
|
|
43
|
+
|
|
44
|
+
#: Valid theta range of the internal frame, in degrees.
|
|
45
|
+
THETA_RANGE_DEG = (0, 180)
|
|
46
|
+
#: Valid phi range of the internal frame, in degrees.
|
|
47
|
+
PHI_RANGE_DEG = (-180, 179)
|
|
48
|
+
|
|
49
|
+
# Coordinate transforms into the internal SPCS_Ericsson frame.
|
|
50
|
+
# Each entry maps (theta, phi) arrays -> (theta, phi) arrays. The phi operator
|
|
51
|
+
# (>= vs >) and the leading negation differ per system and are load-bearing:
|
|
52
|
+
# CW/Geo negate the wrapped value, so phi=180 maps consistently to -180 across
|
|
53
|
+
# all four systems. The dict keys also serve as the whitelist of source systems.
|
|
54
|
+
_TO_ERICSSON = {
|
|
55
|
+
"SPCS_Polar": lambda t, p: (t, np.where(p >= 180, p - 360, p)),
|
|
56
|
+
"SPCS_CW": lambda t, p: (t + 90, -np.where(p > 180, p - 360, p)),
|
|
57
|
+
"SPCS_CCW": lambda t, p: (t + 90, np.where(p >= 180, p - 360, p)),
|
|
58
|
+
"SPCS_Geo": lambda t, p: (np.flip(t), -np.where(p > 180, p - 360, p)),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def to_internal_frame(
|
|
63
|
+
pattern_3d: xr.Dataset,
|
|
64
|
+
from_system: str,
|
|
65
|
+
to_system: str,
|
|
66
|
+
) -> xr.Dataset:
|
|
67
|
+
"""Convert the Theta/Phi index of a pattern dataset into the internal frame.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
pattern_3d (xr.Dataset): Pattern dataset indexed by Theta and Phi.
|
|
71
|
+
from_system (str): Declared source coordinate system.
|
|
72
|
+
to_system (str): Target system. Only ``SPCS_Ericsson`` is implemented.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
NotImplementedError: If ``to_system`` is not ``SPCS_Ericsson``.
|
|
76
|
+
ValueError: If ``from_system`` is not a supported source system, or if the
|
|
77
|
+
transformed theta/phi fall outside the internal ranges, which means the
|
|
78
|
+
input was out of spec for the declared system.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
xr.Dataset: Dataset re-indexed into the internal frame and sorted by Theta, Phi.
|
|
82
|
+
|
|
83
|
+
Note:
|
|
84
|
+
``SPCS_Ericsson`` uses the same coordinate system as ``SPCS_Polar``, however
|
|
85
|
+
phi is defined between -180 and 179.
|
|
86
|
+
"""
|
|
87
|
+
verify(to_system == DEFAULT_INTERNAL_COORD_SYSTEM,
|
|
88
|
+
f"Antenna Pattern: Change to coordinate system {to_system} not implemented yet. Use the default ({DEFAULT_INTERNAL_COORD_SYSTEM}) for now.",
|
|
89
|
+
NotImplementedError)
|
|
90
|
+
transformable_systems = tuple(_TO_ERICSSON)
|
|
91
|
+
verify(from_system in _TO_ERICSSON,
|
|
92
|
+
f"AntennaPattern: Unsupported source coordinate system '{from_system}'. Expected one of {transformable_systems}.")
|
|
93
|
+
|
|
94
|
+
phi = pattern_3d.coords["Phi"].values
|
|
95
|
+
theta = pattern_3d.coords["Theta"].values
|
|
96
|
+
# to_system is guaranteed SPCS_Ericsson by the guard above.
|
|
97
|
+
new_theta, new_phi = _TO_ERICSSON[from_system](theta, phi)
|
|
98
|
+
pattern_3d = pattern_3d.assign_coords(
|
|
99
|
+
Theta=("Theta", new_theta),
|
|
100
|
+
Phi=("Phi", new_phi),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
_reject_out_of_range(pattern_3d, from_system)
|
|
104
|
+
|
|
105
|
+
pattern_3d = pattern_3d.assign_attrs(
|
|
106
|
+
coordinate_system=to_system,
|
|
107
|
+
)
|
|
108
|
+
return pattern_3d.sortby(["Theta", "Phi"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _reject_out_of_range(pattern_3d: xr.Dataset, from_system: str) -> None:
|
|
112
|
+
"""Raise if transformed coordinates left the valid internal ranges.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
pattern_3d (xr.Dataset): Dataset already re-indexed into the internal frame.
|
|
116
|
+
from_system (str): Source system, named in the error for diagnosis.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
ValueError: If theta or phi fall outside the internal frame's ranges.
|
|
120
|
+
"""
|
|
121
|
+
theta_min, theta_max = THETA_RANGE_DEG
|
|
122
|
+
new_theta = pattern_3d.coords["Theta"].values
|
|
123
|
+
verify(new_theta.min() >= theta_min and new_theta.max() <= theta_max,
|
|
124
|
+
f"AntennaPattern: Transformed theta out of range [{theta_min}, {theta_max}] ([{new_theta.min()}, {new_theta.max()}]) converting from '{from_system}'. Input data is likely out of spec for that system.")
|
|
125
|
+
|
|
126
|
+
phi_min, phi_max = PHI_RANGE_DEG
|
|
127
|
+
new_phi = pattern_3d.coords["Phi"].values
|
|
128
|
+
verify(new_phi.min() >= phi_min and new_phi.max() <= phi_max,
|
|
129
|
+
f"AntennaPattern: Transformed phi out of range [{phi_min}, {phi_max}] ([{new_phi.min()}, {new_phi.max()}]) converting from '{from_system}'. Input data is likely out of spec for that system.")
|