Skip to content

Commit f4a71df

Browse files
authored
Fix/v0.2.0 bugs testing and error path detection (#10)
* fix: normalize vendor-specific JSON keys to canonical schema names Adds tests on the resolution provided to #5 which was causing report generation to fail. * test: scaffold pytest harness with synthetic pattern fixtures * fix(parser): guard calculate_top_3db_point against missing -3dB crossing * fix(parser): reject unknown source coordinate systems instead of silent no-op * fix(parser): use equality not substring check for internal coord system * fix(report): use relative imports to break circular dependency * fix(parser): guard beam efficiency against zero overall power * fix(parser): validate Data_Set is non-empty on init * fix: replace deprecated importlib.resources APIs (removed in 3.14) * fix(report): use module logger instead of root logger * fix(parser): reject out-of-range theta after coordinate transform * test(parser): pin phi-boundary consistency across coord transforms (bug 42 non-repro) * fix(parser): use theta grid step instead of hardcoded 1deg in 3dB search * fix(parser): reject out-of-range phi after coordinate transform * fix(parser): raise when validation requested but schema unavailable * fix(report): guard empty subband list before pd.concat * fix(report): surface skipped files and fail clearly when all skipped * fix(parser): show zero values in __str__ using is-not-None check * refactor(parser): collapse coordinate-transform dispatch into lookup table * style: keep string literals on single lines for greppability * test(parser): add _normalize_json vendor-key tests (issue #5)
1 parent 9c71286 commit f4a71df

13 files changed

Lines changed: 754 additions & 87 deletions

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ nbformat = "^5.10.4"
5959
ruff = "^0.11.12"
6060
mypy = "^1.16.0"
6161
pre-commit = "^4.2.0"
62+
pytest = "^8.0"
6263

6364
[tool.poetry.group.typing.dependencies]
6465
pandas-stubs = "2.2.2.240909"
@@ -154,6 +155,14 @@ indent-style = "space"
154155
docstring-code-format = true
155156

156157

158+
[tool.pytest.ini_options]
159+
minversion = "8.0"
160+
testpaths = ["tests"]
161+
addopts = "-q"
162+
filterwarnings = [
163+
"ignore::DeprecationWarning",
164+
]
165+
157166
[tool.mypy]
158167
python_version = "3.13"
159168
warn_return_any = true

src/eas_3d_pattern/parser.py

Lines changed: 94 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,18 @@
4141
"Theta_Tilt": "Theta_Electrical_Tilt",
4242
}
4343

44+
# Coordinate transforms into the internal SPCS_Ericsson frame.
45+
# Each entry maps (theta, phi) arrays -> (theta, phi) arrays. The phi operator
46+
# (>= vs >) and the leading negation differ per system and are load-bearing:
47+
# CW/Geo negate the wrapped value, so phi=180 maps consistently to -180 across
48+
# all four systems. The dict keys also serve as the whitelist of source systems.
49+
_TO_ERICSSON = {
50+
"SPCS_Polar": lambda t, p: (t, np.where(p >= 180, p - 360, p)),
51+
"SPCS_CW": lambda t, p: (t + 90, -np.where(p > 180, p - 360, p)),
52+
"SPCS_CCW": lambda t, p: (t + 90, np.where(p >= 180, p - 360, p)),
53+
"SPCS_Geo": lambda t, p: (np.flip(t), -np.where(p > 180, p - 360, p)),
54+
}
55+
4456

4557
class AntennaPattern:
4658
"""Antenna pattern class to read, calculate and visualize JSON antenna pattern data.
@@ -78,7 +90,21 @@ def __init__(self, data_filepath: str, validate: bool = False):
7890
self.raw_data: dict[str, Any] = self._normalize_json(
7991
self._load_data_from_file(data_filepath)
8092
)
81-
if validate and self._schema is not None:
93+
if not self.raw_data.get("Data_Set"):
94+
logger.error(
95+
f"AntennaPattern: 'Data_Set' is empty or missing in {data_filepath}."
96+
)
97+
raise ValueError(
98+
f"AntennaPattern: 'Data_Set' is empty or missing in {data_filepath}."
99+
)
100+
if validate:
101+
if self._schema is None:
102+
logger.error(
103+
"AntennaPattern: Validation requested but no schema is available."
104+
)
105+
raise ValueError(
106+
"AntennaPattern: Validation requested but no schema is available."
107+
)
82108
self._validate_data_against_schema(self.raw_data, self._schema)
83109

84110
# ---- Process the pattern data into one normalized format ----
@@ -142,12 +168,7 @@ def _validate_data_against_schema(
142168
error_path_str = (
143169
" -> ".join(map(str, e.path)) if e.path else "document root"
144170
)
145-
full_error_message = (
146-
f"Antenna data validation FAILED for '{self.data_filepath}'.\n"
147-
f"Schema source: '{NGMNSchema.source_message}'.\n"
148-
f"Error at data path: '{error_path_str}'.\n"
149-
f"Validation Message: {e.message} (Validator: '{e.validator}')"
150-
)
171+
full_error_message = f"Antenna data validation FAILED for '{self.data_filepath}'.\nSchema source: '{NGMNSchema.source_message}'.\nError at data path: '{error_path_str}'.\nValidation Message: {e.message} (Validator: '{e.validator}')"
151172
logger.error(full_error_message)
152173
raise ValidationError(full_error_message) from e
153174

@@ -462,7 +483,7 @@ def _process_pattern_data(self) -> xr.Dataset:
462483
)
463484

464485
# coordinate system and grid
465-
if self.coordinate_system not in DEFAULT_INTERNAL_COORD_SYSTEM:
486+
if self.coordinate_system != DEFAULT_INTERNAL_COORD_SYSTEM:
466487
logger.warning(
467488
f"AntennaPattern: Coordinate system {self.coordinate_system} not used for calculations. Transforming 'Pattern_3D' attribute to {DEFAULT_INTERNAL_COORD_SYSTEM}."
468489
)
@@ -498,32 +519,38 @@ def _change_coordinate_system(
498519
raise NotImplementedError(
499520
f"Antenna Pattern: Change to coordinate system {to_system} not implemented yet. Use the default (SPCS_Ericsson) for now."
500521
)
522+
transformable_systems = tuple(_TO_ERICSSON)
523+
if from_system not in _TO_ERICSSON:
524+
logger.error(
525+
f"AntennaPattern: Unsupported source coordinate system '{from_system}'. Expected one of {transformable_systems}."
526+
)
527+
raise ValueError(
528+
f"AntennaPattern: Unsupported source coordinate system '{from_system}'. Expected one of {transformable_systems}."
529+
)
501530
phi = Pattern_3D.coords["Phi"].values
502531
theta = Pattern_3D.coords["Theta"].values
503-
if from_system == "SPCS_Polar":
504-
if to_system == "SPCS_Ericsson":
505-
Pattern_3D = Pattern_3D.assign_coords(
506-
Theta=("Theta", theta),
507-
Phi=("Phi", np.where(phi >= 180, phi - 360, phi)),
508-
)
509-
if from_system == "SPCS_CW":
510-
if to_system == "SPCS_Ericsson":
511-
Pattern_3D = Pattern_3D.assign_coords(
512-
Theta=("Theta", theta + 90),
513-
Phi=("Phi", -np.where(phi > 180, phi - 360, phi)),
514-
)
515-
if from_system == "SPCS_CCW":
516-
if to_system == "SPCS_Ericsson":
517-
Pattern_3D = Pattern_3D.assign_coords(
518-
Theta=("Theta", theta + 90),
519-
Phi=("Phi", np.where(phi >= 180, phi - 360, phi)),
520-
)
521-
if from_system == "SPCS_Geo":
522-
if to_system == "SPCS_Ericsson":
523-
Pattern_3D = Pattern_3D.assign_coords(
524-
Theta=("Theta", np.flip(theta)),
525-
Phi=("Phi", -np.where(phi > 180, phi - 360, phi)),
526-
)
532+
# to_system is guaranteed SPCS_Ericsson by the guard above.
533+
new_theta, new_phi = _TO_ERICSSON[from_system](theta, phi)
534+
Pattern_3D = Pattern_3D.assign_coords(
535+
Theta=("Theta", new_theta),
536+
Phi=("Phi", new_phi),
537+
)
538+
new_theta = Pattern_3D.coords["Theta"].values
539+
if new_theta.min() < 0 or new_theta.max() > 180:
540+
logger.error(
541+
f"AntennaPattern: Transformed theta out of range [0, 180] ([{new_theta.min()}, {new_theta.max()}]) converting from '{from_system}'. Input data is likely out of spec for that system."
542+
)
543+
raise ValueError(
544+
f"AntennaPattern: Transformed theta out of range [0, 180] ([{new_theta.min()}, {new_theta.max()}]) converting from '{from_system}'. Input data is likely out of spec for that system."
545+
)
546+
new_phi = Pattern_3D.coords["Phi"].values
547+
if new_phi.min() < -180 or new_phi.max() > 179:
548+
logger.error(
549+
f"AntennaPattern: Transformed phi out of range [-180, 179] ([{new_phi.min()}, {new_phi.max()}]) converting from '{from_system}'. Input data is likely out of spec for that system."
550+
)
551+
raise ValueError(
552+
f"AntennaPattern: Transformed phi out of range [-180, 179] ([{new_phi.min()}, {new_phi.max()}]) converting from '{from_system}'. Input data is likely out of spec for that system."
553+
)
527554
Pattern_3D = Pattern_3D.assign_attrs(
528555
coordinate_system=to_system,
529556
)
@@ -700,6 +727,13 @@ def calculate_beam_efficiency(
700727

701728
weighted_field_values = self.Pattern_3D["dOmega"] * field_values
702729
Sp_overall = float(weighted_field_values.sum())
730+
if Sp_overall == 0:
731+
logger.error(
732+
"AntennaPattern: Overall power is zero; cannot compute beam efficiency."
733+
)
734+
raise ValueError(
735+
"AntennaPattern: Overall power is zero; cannot compute beam efficiency."
736+
)
703737

704738
operators_dict = {
705739
"<": operator.lt,
@@ -814,19 +848,37 @@ def calculate_top_3db_point(self, power: bool = False) -> float:
814848
vertical_cut_normed = vertical_cut["P_tp_dB"]
815849
else:
816850
vertical_cut_normed = vertical_cut["P_co_dB"]
851+
# Fallback: if no point at/below -3 dB exists above the peak (e.g. a very
852+
# narrow beam peaking at the top of the cut), the 3 dB border collapses to
853+
# the peak theta itself instead of leaving ``top_border`` unbound.
854+
top_border = float(theta_val_peak)
855+
# Advance the border by the actual theta grid step rather than a hardcoded
856+
# 1 deg, so the result is correct for any sampling resolution.
857+
theta_axis = np.sort(vertical_cut_normed["Theta"].values)
858+
if theta_axis.size > 1:
859+
grid_step = float(np.median(np.diff(theta_axis)))
860+
else:
861+
grid_step = 1.0
817862
for theta_val in np.flip(
818863
vertical_cut_normed.sel(Theta=slice(0, theta_val_peak))["Theta"]
819864
):
820865
if vertical_cut_normed.sel(Theta=theta_val) <= -3:
821-
top_border = float((theta_val + 1).values)
866+
top_border = float(theta_val.values) + grid_step
822867
break
868+
else:
869+
logger.warning(
870+
"AntennaPattern: No -3 dB crossing found above the peak; using peak theta as top 3 dB border."
871+
)
823872

824873
# enrich with top_3db_point
825874
self.Pattern_3D.attrs["top_3db_point"] = top_border
826875
return top_border
827876

828877
def plot(
829-
self, component_name: str = "P_tp_dB", show_fig: bool = True, remove_layout_components: bool = False
878+
self,
879+
component_name: str = "P_tp_dB",
880+
show_fig: bool = True,
881+
remove_layout_components: bool = False,
830882
) -> None | go.Figure:
831883
"""Plots the radiation pattern as heatmap.
832884
@@ -860,13 +912,8 @@ def plot(
860912
zmin=-30,
861913
zmax=0,
862914
colorbar={"title": component_name, "thickness": 9},
863-
hovertemplate=(
864-
"φ = %{x:.0f}°<br>"
865-
"θ = %{y:.0f}°<br>"
866-
"val = %{z:.2f}<br>"
867-
"<extra></extra>"
868-
),
869-
showscale=not(remove_layout_components),
915+
hovertemplate="φ = %{x:.0f}°<br>θ = %{y:.0f}°<br>val = %{z:.2f}<br><extra></extra>",
916+
showscale=not (remove_layout_components),
870917
)
871918
)
872919
fig.update_yaxes(autorange="reversed")
@@ -884,7 +931,7 @@ def plot(
884931
"zeroline": False,
885932
"showline": False,
886933
},
887-
plot_bgcolor="rgba(0,0,0,0)", # sin fondo gris en el área del heatmap
934+
plot_bgcolor="rgba(0,0,0,0)", # sin fondo gris en el área del heatmap
888935
paper_bgcolor="rgba(0,0,0,0)", # sin fondo/gris alrededor
889936
margin={"t": 0, "l": 0, "r": 0, "b": 0}, # recorta al mínimo
890937
height=500,
@@ -1077,11 +1124,11 @@ def __str__(self) -> str:
10771124
"==== Parameters ====",
10781125
f" Gain [dbi]: {self.gain_dbi if self.gain_dbi is not None else 'N/A'}",
10791126
f" EIRP [dBm]: {self.eirp_dbm if self.eirp_dbm is not None else 'N/A'}",
1080-
f" Phi HPBW [deg]: {self.phi_hpbw if self.phi_hpbw else 'N/A'}",
1081-
f" Theta HPBW [deg]: {self.theta_hpbw if self.theta_hpbw else 'N/A'}",
1082-
f" Front to Back [db]: {self.front_to_back if self.front_to_back else 'N/A'}",
1127+
f" Phi HPBW [deg]: {self.phi_hpbw if self.phi_hpbw is not None else 'N/A'}",
1128+
f" Theta HPBW [deg]: {self.theta_hpbw if self.theta_hpbw is not None else 'N/A'}",
1129+
f" Front to Back [db]: {self.front_to_back if self.front_to_back is not None else 'N/A'}",
10831130
"==== Frequency & Tilt ====",
1084-
f" Frequency [Hz]: {self.frequency_hz if self.frequency_hz else 'N/A'}",
1131+
f" Frequency [Hz]: {self.frequency_hz if self.frequency_hz is not None else 'N/A'}",
10851132
f" Frequency Range [Hz]: {self.frequency_range if self.frequency_range is not None else 'N/A'}",
10861133
f" Theta Electrical Tilt [deg]: {self.theta_eletrical_tilt if self.theta_eletrical_tilt is not None else 'N/A'}",
10871134
f" Phi Electrical Pan [deg]: {self.phi_eletrical_pan if self.phi_eletrical_pan is not None else 'N/A'}",
@@ -1093,7 +1140,4 @@ def __str__(self) -> str:
10931140
return "\n".join(lines)
10941141

10951142
def __repr__(self) -> str:
1096-
return (
1097-
f"<AntennaPattern(data_filepath='{self.data_filepath}', "
1098-
f"model='{self.antenna_model}', supplier='{self.supplier}')>"
1099-
)
1143+
return f"<AntennaPattern(data_filepath='{self.data_filepath}', model='{self.antenna_model}', supplier='{self.supplier}')>"

src/eas_3d_pattern/sample_data/__init__.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,28 @@
77
SAMPLE_JSON: list[Path] = []
88

99
try:
10-
resource_names = list(importlib.resources.contents(__package__))
11-
resource_names.sort()
10+
anchor = importlib.resources.files(__package__)
11+
resources = sorted(anchor.iterdir(), key=lambda res: res.name)
1212

13-
for item_name in resource_names:
14-
if item_name.endswith(".json") and importlib.resources.is_resource(
15-
__package__, item_name
16-
):
13+
for resource in resources:
14+
if resource.name.endswith(".json") and resource.is_file():
1715
try:
18-
with importlib.resources.path(__package__, item_name) as path_context:
16+
with importlib.resources.as_file(resource) as path_context:
1917
resolved_path = Path(path_context)
2018

2119
if resolved_path.is_file():
2220
SAMPLE_JSON.append(resolved_path)
2321
else:
2422
logger.warning(
25-
f"Path for sample '{item_name}' in '{__package__}' "
26-
f" ('{resolved_path}') was not a file after context. Skipping."
23+
f"Path for sample '{resource.name}' in '{__package__}' ('{resolved_path}') was not a file after context. Skipping."
2724
)
2825
except FileNotFoundError:
2926
logger.warning(
30-
f"Sample file '{item_name}' listed but not found by "
31-
f"importlib.resources.path in '{__package__}'. Skipping."
27+
f"Sample file '{resource.name}' listed but not found by importlib.resources in '{__package__}'. Skipping."
3228
)
3329
except Exception as e_path:
3430
logger.error(
35-
f"Error resolving path for sample '{item_name}' in '{__package__}': {e_path}"
31+
f"Error resolving path for sample '{resource.name}' in '{__package__}': {e_path}"
3632
)
3733

3834
if SAMPLE_JSON:
@@ -41,9 +37,7 @@
4137
)
4238
else:
4339
logger.debug(
44-
f"No .json sample files found or resolved in '{__package__}'. "
45-
"Ensure files exist, have .json extension, and 'include' "
46-
"in pyproject.toml is correct for 'sample_data/*.json'."
40+
f"No .json sample files found or resolved in '{__package__}'. Ensure files exist, have .json extension, and 'include' in pyproject.toml is correct for 'sample_data/*.json'."
4741
)
4842

4943
except ModuleNotFoundError:

src/eas_3d_pattern/schema_manager.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -160,9 +160,11 @@ def _load_bundled(self) -> dict[str, Any]:
160160
f"SchemaManager: Attempting to load bundled schema: {self.bundled_package_ref}/{self.bundled_filename}"
161161
)
162162
try:
163-
with importlib.resources.open_text(
164-
self.bundled_package_ref, self.bundled_filename
165-
) as sf:
163+
schema_resource = (
164+
importlib.resources.files(self.bundled_package_ref)
165+
/ self.bundled_filename
166+
)
167+
with schema_resource.open(encoding="utf-8") as sf:
166168
content = json.load(sf)
167169
self.source_message = (
168170
f"Bundled Schema ({self.bundled_package_ref}/{self.bundled_filename})"
@@ -205,11 +207,7 @@ def _load_and_validate_schema(self):
205207
) from e
206208

207209
def __repr__(self) -> str:
208-
return (
209-
f"<{self.__class__.__name__}(id=0x{id(self):x}, "
210-
f"schema_loaded={'True' if self.schema_content else 'False'}, "
211-
f"source={self.source_message})>"
212-
)
210+
return f"<{self.__class__.__name__}(id=0x{id(self):x}, schema_loaded={'True' if self.schema_content else 'False'}, source={self.source_message})>"
213211

214212
def __str__(self) -> str:
215213
return str(self.schema_content)

src/eas_3d_pattern/sector_definitions.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,7 @@ def __post_init__(self):
5959
)
6060

6161
def __str__(self):
62-
return (
63-
f"'{self.name}': \t"
64-
f"[{self.theta_min[0]:.1f}{self.theta_min[1]}Theta{self.theta_max[1]}{self.theta_max[0]:.1f}], "
65-
f"[{self.phi_min[0]:.1f}{self.phi_min[1]}Phi{self.phi_max[1]}{self.phi_max[0]:.1f}]"
66-
)
62+
return f"'{self.name}': \t[{self.theta_min[0]:.1f}{self.theta_min[1]}Theta{self.theta_max[1]}{self.theta_max[0]:.1f}], [{self.phi_min[0]:.1f}{self.phi_min[1]}Phi{self.phi_max[1]}{self.phi_max[0]:.1f}]"
6763

6864

6965
class SectorDefinition:

0 commit comments

Comments
 (0)