Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
397,703 changes: 56 additions & 397,647 deletions notebooks/Showcase_EAS_3D_Pattern.ipynb

Large diffs are not rendered by default.

82 changes: 75 additions & 7 deletions src/eas_3d_pattern/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ def __init__(self, data_filepath: str, validate: bool = False):
self._validate_data_against_schema(self.raw_data, self._schema)

# ---- Process the pattern data into one normalized format ----
self._sector_preset: str = "eas"
self.Pattern_3D: xr.Dataset = self._process_pattern_data()

def _load_data_from_file(self, filepath: str) -> dict[str, Any]:
Expand Down Expand Up @@ -352,6 +353,44 @@ def nominal_polarization(self) -> str:
def optional_comments(self) -> str:
return str(self.raw_data["Optional_Comments"])

@property
def sector_preset(self) -> str:
"""The active sector preset name used by ``calculate_beam_efficiency()``.

Defaults to ``"eas"``. Set to a different preset name to change the
sector geometry used when ``sector_definitions`` is not explicitly passed.

Raises:
ValueError: If assigned a name not in ``available_sector_presets()``.
"""
return self._sector_preset

@sector_preset.setter
def sector_preset(self, value: str) -> None:
available = SectorDefinition.presets()
if value not in available:
raise ValueError(
f"AntennaPattern: Unknown preset '{value}'. "
f"Available presets: {available}"
)
self._sector_preset = value

@classmethod
def available_sector_presets(cls) -> list[str]:
"""Return the list of available sector preset names.

Convenience accessor so users can discover presets directly from
AntennaPattern without importing SectorDefinition separately.

Returns:
list[str]: Names that can be passed to ``sector_preset``.

Example:
>>> AntennaPattern.available_sector_presets()
['eas', 'ngmn-v13-type-a']
"""
return SectorDefinition.presets()

@property
def theta_sampling(self) -> np.ndarray | None:
theta_sampling_list = self.raw_data.get("Theta_Sampling")
Expand Down Expand Up @@ -651,6 +690,41 @@ def calculate_losses(self) -> float:
)
return float(self.gain_dbi - self.calculate_directivity())

def _build_sectors_from_preset(self) -> SectorDefinition:
"""Build a SectorDefinition from the active preset and pattern metadata.

Dispatches to the correct preset builder based on ``self._sector_preset``.

Returns:
SectorDefinition: Configured sector definition for this pattern.

Raises:
ValueError: If required metadata is missing for the selected preset.
"""
if self._sector_preset == "eas":
top_border = self.calculate_top_3db_point(power=False)
return SectorDefinition.from_preset("eas", top_border=top_border)

if self._sector_preset == "ngmn-v13-type-a":
theta_peak, _ = self.find_peak_coordinates(power=False)
theta_hpbw = self.raw_data.get("Theta_HPBW")
phi_hpbw = self.raw_data.get("Phi_HPBW")
if theta_hpbw is None or phi_hpbw is None:
raise ValueError(
"AntennaPattern: NGMN Type A preset requires 'Theta_HPBW' and 'Phi_HPBW' in the pattern metadata."
)
phi_nominal = self.phi_eletrical_pan or 0.0
return SectorDefinition.from_preset(
"ngmn-v13-type-a",
theta_beam_peak=theta_peak,
theta_hpbw=float(theta_hpbw),
phi_nominal_direction=phi_nominal,
nominal_sector_phi=float(phi_hpbw),
)

# Fallback for future presets registered externally
return SectorDefinition.from_preset(self._sector_preset)

def calculate_beam_efficiency(
self, sector_definitions: SectorDefinition | None = None, powersum: bool = True
) -> dict[str, float]:
Expand Down Expand Up @@ -691,13 +765,7 @@ def calculate_beam_efficiency(
"AntennaPattern: Calculating beam efficiency of antenna pattern data."
)
if sector_definitions is None:
logger.warning(
"AntennaPattern: SectorDefinition is not defined. Taking default settings for beam efficiency calculation."
)
top_border = self.calculate_top_3db_point(power=False)
sector_definitions = SectorDefinition(
load_default=True, top_border=top_border
)
sector_definitions = self._build_sectors_from_preset()

if powersum:
field_values = self.Pattern_3D["P_tp_lin"]
Expand Down
216 changes: 205 additions & 11 deletions src/eas_3d_pattern/sector_definitions.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,23 @@
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

logger = logging.getLogger(__name__)

# --- Preset constants ---
NGMN_TYPE_A_THETA_HPBW_MIN: float = 0.5
NGMN_TYPE_A_THETA_HPBW_MAX: float = 25.0
NGMN_TYPE_A_SECTOR_PHI_MIN: float = 50.0
NGMN_TYPE_A_SECTOR_PHI_MAX: float = 130.0
NGMN_TYPE_A_THETA3: float = 165.0
NGMN_TYPE_A_THETA4: float = 180.0

EAS_THETA_WASTED_BORDER: float = 70.0
EAS_THETA_EMF_BORDER: float = 165.0
EAS_THETA_LOWER: float = 180.0
EAS_SECTOR_PHI_HALF: float = 60.0


@dataclass(frozen=True)
class BoundaryBoxSquare:
Expand Down Expand Up @@ -95,42 +110,42 @@ def _load_default_sectors(self, top_border: float) -> None:
self.add_sector(
name="Cell",
theta_min=(top_border, "<="),
theta_max=(165, "<="),
phi_min=(-60.0, "<="),
phi_max=(60.0, "<="),
theta_max=(EAS_THETA_EMF_BORDER, "<="),
phi_min=(-EAS_SECTOR_PHI_HALF, "<="),
phi_max=(EAS_SECTOR_PHI_HALF, "<="),
)
self.add_sector(
name="Int1",
theta_min=(top_border, "<="),
theta_max=(165, "<="),
theta_max=(EAS_THETA_EMF_BORDER, "<="),
phi_min=(-180.0, "<="),
phi_max=(-60.0, "<"),
phi_max=(-EAS_SECTOR_PHI_HALF, "<"),
)
self.add_sector(
name="Int2",
theta_min=(top_border, "<="),
theta_max=(165, "<="),
phi_min=(60.0, "<"),
theta_max=(EAS_THETA_EMF_BORDER, "<="),
phi_min=(EAS_SECTOR_PHI_HALF, "<"),
phi_max=(180.0, "<"),
)
self.add_sector(
name="Int3",
theta_min=(70.0, "<="),
theta_min=(EAS_THETA_WASTED_BORDER, "<="),
theta_max=(top_border, "<"),
phi_min=(-180.0, "<="),
phi_max=(180.0, "<"),
)
self.add_sector(
name="EMF",
theta_min=(165.0, "<"),
theta_max=(180.0, "<="),
theta_min=(EAS_THETA_EMF_BORDER, "<"),
theta_max=(EAS_THETA_LOWER, "<="),
phi_min=(-180.0, "<="),
phi_max=(180.0, "<"),
)
self.add_sector(
name="Wasted",
theta_min=(0.0, "<="),
theta_max=(70.0, "<"),
theta_max=(EAS_THETA_WASTED_BORDER, "<"),
phi_min=(-180.0, "<="),
phi_max=(180.0, "<"),
)
Expand Down Expand Up @@ -182,10 +197,189 @@ def clear_sectors(self) -> None:
self.sectors = {}
logger.info("SectorDefinition: All sectors cleared from SectorDefinition.")

@classmethod
def presets(cls) -> list[str]:
"""Return the list of available sector preset names.

Returns:
list[str]: Names that can be passed to ``from_preset()``.
"""
return list(_PRESET_REGISTRY.keys())

@classmethod
def from_preset(cls, name: str, **kwargs: Any) -> "SectorDefinition":
"""Create a SectorDefinition from a named preset.

Args:
name: Preset identifier (see ``presets()`` for valid names).
**kwargs: Parameters required by the specific preset builder.

Returns:
SectorDefinition: Configured instance with sectors loaded.

Raises:
ValueError: If the preset name is not recognized.

Example:
>>> sectors = SectorDefinition.from_preset("eas", top_border=85.0)
>>> sectors = SectorDefinition.from_preset(
... "ngmn-v13-type-a",
... theta_beam_peak=96.0,
... theta_hpbw=7.0,
... nominal_sector_phi=120.0,
... )
"""
if name not in _PRESET_REGISTRY:
available = ", ".join(_PRESET_REGISTRY.keys())
raise ValueError(
f"SectorDefinition: Unknown preset '{name}'. Available presets: {available}"
)
builder = _PRESET_REGISTRY[name]
return builder(**kwargs)

def __str__(self):
if not self.sectors:
return "SectorDefinition (No sectors defined)"
output = [f"SectorDefinition ({len(self.sectors)} defined Sectors)"]
for sector_box in self.sectors.values():
output.append(f"{sector_box}")
return "\n".join(output)


# --- Preset builder functions ---


def _build_eas_preset(top_border: float, **_kwargs: Any) -> SectorDefinition:
"""Build the traditional EAS sector definition.

Args:
top_border: Upper theta boundary in degrees (typically from -3 dB point).
**_kwargs: Unused, absorbed for registry interface compatibility.

Returns:
SectorDefinition with the 6 EAS sectors.
"""
return SectorDefinition(load_default=True, top_border=top_border)


def _build_ngmn_type_a_preset(
theta_beam_peak: float,
theta_hpbw: float,
phi_nominal_direction: float = 0.0,
nominal_sector_phi: float = 120.0,
**_kwargs: Any,
) -> SectorDefinition:
"""Build NGMN BASTA V13 Type A sector definition.

Computes AR boundaries per NGMN BASTA V13.0, Section 7.2.4, Table 7-1,
Type A: Macro BS Beam.

The Interference AR (non-rectangular) is decomposed into 3 rectangular
sub-regions: left, right, and upper strips around the Service AR.

Args:
theta_beam_peak: Beam peak theta in degrees (internal coord system).
theta_hpbw: Elevation half-power beamwidth in degrees.
phi_nominal_direction: Nominal azimuth direction in degrees. Defaults to 0.
nominal_sector_phi: Nominal sector width in degrees. Defaults to 120.
**_kwargs: Unused, absorbed for registry interface compatibility.

Returns:
SectorDefinition with 6 sectors: Service, Interference_Left,
Interference_Right, Interference_Upper, Upper, Lower.

Raises:
ValueError: If parameters fall outside NGMN Type A applicability constraints.
"""
if not (NGMN_TYPE_A_THETA_HPBW_MIN <= theta_hpbw <= NGMN_TYPE_A_THETA_HPBW_MAX):
raise ValueError(
f"SectorDefinition: NGMN Type A requires HPBW_θ in "
f"[{NGMN_TYPE_A_THETA_HPBW_MIN}°, {NGMN_TYPE_A_THETA_HPBW_MAX}°], "
f"got {theta_hpbw}°."
)
if not (
NGMN_TYPE_A_SECTOR_PHI_MIN <= nominal_sector_phi <= NGMN_TYPE_A_SECTOR_PHI_MAX
):
raise ValueError(
f"SectorDefinition: NGMN Type A requires NominalSector_φ in "
f"[{NGMN_TYPE_A_SECTOR_PHI_MIN}°, {NGMN_TYPE_A_SECTOR_PHI_MAX}°], "
f"got {nominal_sector_phi}°."
)

# Table 7-1 boundary formulas
theta_1 = min(90.0, theta_beam_peak - theta_hpbw)
theta_2 = theta_beam_peak - theta_hpbw / 2.0
theta_3 = NGMN_TYPE_A_THETA3
theta_4 = NGMN_TYPE_A_THETA4
phi_1 = phi_nominal_direction - nominal_sector_phi / 2.0
phi_2 = phi_nominal_direction + nominal_sector_phi / 2.0

instance = SectorDefinition(load_default=False)

# Service AR: [theta_2, theta_3] x [phi_1, phi_2]
instance.add_sector(
name="Service",
theta_min=(theta_2, "<="),
theta_max=(theta_3, "<="),
phi_min=(phi_1, "<="),
phi_max=(phi_2, "<="),
)

# Interference AR decomposed into 3 rectangles:
# Left strip: [theta_1, theta_3] x [-180, phi_1)
instance.add_sector(
name="Interference_Left",
theta_min=(theta_1, "<="),
theta_max=(theta_3, "<="),
phi_min=(-180.0, "<="),
phi_max=(phi_1, "<"),
)

# Right strip: [theta_1, theta_3] x (phi_2, 180)
instance.add_sector(
name="Interference_Right",
theta_min=(theta_1, "<="),
theta_max=(theta_3, "<="),
phi_min=(phi_2, "<"),
phi_max=(180.0, "<"),
)

# Upper strip: [theta_1, theta_2) x [phi_1, phi_2]
instance.add_sector(
name="Interference_Upper",
theta_min=(theta_1, "<="),
theta_max=(theta_2, "<"),
phi_min=(phi_1, "<="),
phi_max=(phi_2, "<="),
)

# Upper AR: [0, theta_1) x [-180, 180)
instance.add_sector(
name="Upper",
theta_min=(0.0, "<="),
theta_max=(theta_1, "<"),
phi_min=(-180.0, "<="),
phi_max=(180.0, "<"),
)

# Lower AR: (theta_3, 180] x [-180, 180)
instance.add_sector(
name="Lower",
theta_min=(theta_3, "<"),
theta_max=(theta_4, "<="),
phi_min=(-180.0, "<="),
phi_max=(180.0, "<"),
)

logger.debug(
f"SectorDefinition: Loaded NGMN Type A preset with ϑ₁={theta_1:.1f}°, "
f"ϑ₂={theta_2:.1f}°, ϑ₃={theta_3:.1f}°, φ₁={phi_1:.1f}°, φ₂={phi_2:.1f}°."
)
return instance


# --- Preset registry ---
_PRESET_REGISTRY: dict[str, Callable[..., SectorDefinition]] = {
"eas": _build_eas_preset,
"ngmn-v13-type-a": _build_ngmn_type_a_preset,
}
Loading
Loading