phased-array-systems 0.1.0__py3-none-any.whl
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.
- phased_array_systems/__about__.py +4 -0
- phased_array_systems/__init__.py +10 -0
- phased_array_systems/architecture/__init__.py +15 -0
- phased_array_systems/architecture/config.py +152 -0
- phased_array_systems/cli.py +25 -0
- phased_array_systems/constants.py +55 -0
- phased_array_systems/evaluate.py +136 -0
- phased_array_systems/io/__init__.py +13 -0
- phased_array_systems/io/config_loader.py +86 -0
- phased_array_systems/io/exporters.py +171 -0
- phased_array_systems/io/schema.py +145 -0
- phased_array_systems/models/__init__.py +5 -0
- phased_array_systems/models/antenna/__init__.py +15 -0
- phased_array_systems/models/antenna/adapter.py +190 -0
- phased_array_systems/models/antenna/metrics.py +166 -0
- phased_array_systems/models/base.py +30 -0
- phased_array_systems/models/comms/__init__.py +9 -0
- phased_array_systems/models/comms/link_budget.py +171 -0
- phased_array_systems/models/comms/propagation.py +84 -0
- phased_array_systems/models/swapc/__init__.py +9 -0
- phased_array_systems/models/swapc/cost.py +98 -0
- phased_array_systems/models/swapc/power.py +102 -0
- phased_array_systems/requirements/__init__.py +15 -0
- phased_array_systems/requirements/core.py +244 -0
- phased_array_systems/scenarios/__init__.py +11 -0
- phased_array_systems/scenarios/base.py +30 -0
- phased_array_systems/scenarios/comms.py +56 -0
- phased_array_systems/scenarios/radar.py +42 -0
- phased_array_systems/trades/__init__.py +16 -0
- phased_array_systems/trades/design_space.py +241 -0
- phased_array_systems/trades/doe.py +146 -0
- phased_array_systems/trades/pareto.py +266 -0
- phased_array_systems/trades/runner.py +245 -0
- phased_array_systems/types.py +54 -0
- phased_array_systems/utils/__init__.py +8 -0
- phased_array_systems/utils/hashing.py +70 -0
- phased_array_systems/viz/__init__.py +9 -0
- phased_array_systems/viz/plots.py +324 -0
- phased_array_systems-0.1.0.dist-info/METADATA +174 -0
- phased_array_systems-0.1.0.dist-info/RECORD +43 -0
- phased_array_systems-0.1.0.dist-info/WHEEL +4 -0
- phased_array_systems-0.1.0.dist-info/entry_points.txt +2 -0
- phased_array_systems-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Configuration schema definitions using Pydantic."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from phased_array_systems.architecture import (
|
|
8
|
+
Architecture,
|
|
9
|
+
ArrayConfig,
|
|
10
|
+
CostConfig,
|
|
11
|
+
RFChainConfig,
|
|
12
|
+
)
|
|
13
|
+
from phased_array_systems.requirements import Requirement, RequirementSet
|
|
14
|
+
from phased_array_systems.scenarios import CommsLinkScenario, RadarDetectionScenario
|
|
15
|
+
from phased_array_systems.types import ComparisonOp, Severity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RequirementConfig(BaseModel):
|
|
19
|
+
"""Configuration for a single requirement."""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
name: str
|
|
23
|
+
metric_key: str
|
|
24
|
+
op: ComparisonOp
|
|
25
|
+
value: float
|
|
26
|
+
units: str | None = None
|
|
27
|
+
severity: Severity = "must"
|
|
28
|
+
|
|
29
|
+
def to_requirement(self) -> Requirement:
|
|
30
|
+
"""Convert to Requirement object."""
|
|
31
|
+
return Requirement(
|
|
32
|
+
id=self.id,
|
|
33
|
+
name=self.name,
|
|
34
|
+
metric_key=self.metric_key,
|
|
35
|
+
op=self.op,
|
|
36
|
+
value=self.value,
|
|
37
|
+
units=self.units,
|
|
38
|
+
severity=self.severity,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DesignVariableConfig(BaseModel):
|
|
43
|
+
"""Configuration for a design variable in DOE studies."""
|
|
44
|
+
|
|
45
|
+
name: str = Field(description="Variable name (e.g., 'array.nx')")
|
|
46
|
+
type: Literal["int", "float", "categorical"] = "float"
|
|
47
|
+
low: float | None = Field(default=None, description="Lower bound")
|
|
48
|
+
high: float | None = Field(default=None, description="Upper bound")
|
|
49
|
+
values: list[Any] | None = Field(default=None, description="Categorical values")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class DOEConfig(BaseModel):
|
|
53
|
+
"""Configuration for Design of Experiments."""
|
|
54
|
+
|
|
55
|
+
method: Literal["grid", "random", "lhs"] = "lhs"
|
|
56
|
+
n_samples: int = Field(default=100, ge=1, description="Number of samples")
|
|
57
|
+
seed: int | None = Field(default=None, description="Random seed")
|
|
58
|
+
variables: list[DesignVariableConfig] = Field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StudyConfig(BaseModel):
|
|
62
|
+
"""Top-level configuration for a study.
|
|
63
|
+
|
|
64
|
+
Supports both single-case evaluation and DOE trade studies.
|
|
65
|
+
|
|
66
|
+
Example YAML:
|
|
67
|
+
```yaml
|
|
68
|
+
name: "Comms Array Study"
|
|
69
|
+
architecture:
|
|
70
|
+
array:
|
|
71
|
+
nx: 8
|
|
72
|
+
ny: 8
|
|
73
|
+
dx_lambda: 0.5
|
|
74
|
+
rf:
|
|
75
|
+
tx_power_w_per_elem: 1.0
|
|
76
|
+
scenario:
|
|
77
|
+
type: comms
|
|
78
|
+
freq_hz: 10e9
|
|
79
|
+
bandwidth_hz: 10e6
|
|
80
|
+
range_m: 100e3
|
|
81
|
+
required_snr_db: 10.0
|
|
82
|
+
requirements:
|
|
83
|
+
- id: REQ-001
|
|
84
|
+
name: Minimum EIRP
|
|
85
|
+
metric_key: eirp_dbw
|
|
86
|
+
op: ">="
|
|
87
|
+
value: 40.0
|
|
88
|
+
```
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
name: str = Field(default="Unnamed Study", description="Study name")
|
|
92
|
+
version: str = Field(default="1.0", description="Config version")
|
|
93
|
+
|
|
94
|
+
# Architecture configuration
|
|
95
|
+
architecture: Architecture | None = None
|
|
96
|
+
array: ArrayConfig | None = Field(default=None, description="Array config (shorthand)")
|
|
97
|
+
rf: RFChainConfig | None = Field(default=None, description="RF config (shorthand)")
|
|
98
|
+
cost: CostConfig | None = Field(default=None, description="Cost config (shorthand)")
|
|
99
|
+
|
|
100
|
+
# Scenario configuration
|
|
101
|
+
scenario: dict[str, Any] | None = Field(default=None, description="Scenario definition")
|
|
102
|
+
|
|
103
|
+
# Requirements
|
|
104
|
+
requirements: list[RequirementConfig] = Field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
# DOE configuration (optional, for trade studies)
|
|
107
|
+
doe: DOEConfig | None = None
|
|
108
|
+
|
|
109
|
+
# Output configuration
|
|
110
|
+
output_dir: str = Field(default="./results", description="Output directory")
|
|
111
|
+
output_format: Literal["parquet", "csv", "json"] = "parquet"
|
|
112
|
+
|
|
113
|
+
def get_architecture(self) -> Architecture:
|
|
114
|
+
"""Get the Architecture object, building from shorthand if needed."""
|
|
115
|
+
if self.architecture is not None:
|
|
116
|
+
return self.architecture
|
|
117
|
+
|
|
118
|
+
# Build from shorthand configs
|
|
119
|
+
array = self.array or ArrayConfig(nx=8, ny=8)
|
|
120
|
+
rf = self.rf or RFChainConfig(tx_power_w_per_elem=1.0)
|
|
121
|
+
cost = self.cost or CostConfig()
|
|
122
|
+
|
|
123
|
+
return Architecture(array=array, rf=rf, cost=cost, name=self.name)
|
|
124
|
+
|
|
125
|
+
def get_scenario(self) -> CommsLinkScenario | RadarDetectionScenario | None:
|
|
126
|
+
"""Get the Scenario object from config."""
|
|
127
|
+
if self.scenario is None:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
scenario_dict = self.scenario.copy()
|
|
131
|
+
scenario_type = scenario_dict.pop("type", "comms")
|
|
132
|
+
|
|
133
|
+
if scenario_type == "comms":
|
|
134
|
+
return CommsLinkScenario(**scenario_dict)
|
|
135
|
+
elif scenario_type == "radar":
|
|
136
|
+
return RadarDetectionScenario(**scenario_dict)
|
|
137
|
+
else:
|
|
138
|
+
raise ValueError(f"Unknown scenario type: {scenario_type}")
|
|
139
|
+
|
|
140
|
+
def get_requirement_set(self) -> RequirementSet:
|
|
141
|
+
"""Get RequirementSet from config."""
|
|
142
|
+
req_set = RequirementSet(name=f"{self.name} Requirements")
|
|
143
|
+
for req_config in self.requirements:
|
|
144
|
+
req_set.add(req_config.to_requirement())
|
|
145
|
+
return req_set
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Antenna modeling adapter wrapping phased-array-modeling."""
|
|
2
|
+
|
|
3
|
+
from phased_array_systems.models.antenna.adapter import PhasedArrayAdapter
|
|
4
|
+
from phased_array_systems.models.antenna.metrics import (
|
|
5
|
+
compute_beamwidth,
|
|
6
|
+
compute_scan_loss,
|
|
7
|
+
compute_sidelobe_level,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"PhasedArrayAdapter",
|
|
12
|
+
"compute_beamwidth",
|
|
13
|
+
"compute_scan_loss",
|
|
14
|
+
"compute_sidelobe_level",
|
|
15
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Adapter wrapping phased-array-modeling for consistent metric extraction."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from phased_array_systems.architecture import Architecture
|
|
8
|
+
from phased_array_systems.models.antenna.metrics import (
|
|
9
|
+
compute_beamwidth,
|
|
10
|
+
compute_directivity_rectangular,
|
|
11
|
+
compute_scan_loss,
|
|
12
|
+
compute_sidelobe_level,
|
|
13
|
+
)
|
|
14
|
+
from phased_array_systems.types import MetricsDict, Scenario
|
|
15
|
+
|
|
16
|
+
# Try to import phased-array-modeling, fall back to stub if not available
|
|
17
|
+
try:
|
|
18
|
+
from phased_array_modeling import (
|
|
19
|
+
RectangularArray,
|
|
20
|
+
compute_array_factor,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
HAS_PAM = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
HAS_PAM = False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PhasedArrayAdapter:
|
|
29
|
+
"""Adapter for phased-array-modeling library.
|
|
30
|
+
|
|
31
|
+
Provides a consistent interface for computing antenna pattern metrics
|
|
32
|
+
using the phased-array-modeling library, with fallback to analytical
|
|
33
|
+
approximations when the library is not available.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
name: Model block name for identification
|
|
37
|
+
use_analytical_fallback: If True, use analytical approximations
|
|
38
|
+
when phased-array-modeling is not available
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
name: str = "antenna"
|
|
42
|
+
|
|
43
|
+
def __init__(self, use_analytical_fallback: bool = True):
|
|
44
|
+
"""Initialize the adapter.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
use_analytical_fallback: Use analytical methods if PAM unavailable
|
|
48
|
+
"""
|
|
49
|
+
self.use_analytical_fallback = use_analytical_fallback
|
|
50
|
+
|
|
51
|
+
if not HAS_PAM and not use_analytical_fallback:
|
|
52
|
+
raise ImportError(
|
|
53
|
+
"phased-array-modeling not installed. Install with: "
|
|
54
|
+
"pip install phased-array-modeling"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def evaluate(
|
|
58
|
+
self, arch: Architecture, scenario: Scenario, context: dict[str, Any]
|
|
59
|
+
) -> MetricsDict:
|
|
60
|
+
"""Evaluate antenna performance metrics.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
arch: Architecture configuration
|
|
64
|
+
scenario: Scenario with frequency and scan angle info
|
|
65
|
+
context: Additional context (unused)
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dictionary with antenna metrics:
|
|
69
|
+
- g_peak_db: Peak array gain (dB)
|
|
70
|
+
- beamwidth_az_deg: Azimuth beamwidth (degrees)
|
|
71
|
+
- beamwidth_el_deg: Elevation beamwidth (degrees)
|
|
72
|
+
- sll_db: Peak sidelobe level (dB, negative)
|
|
73
|
+
- scan_loss_db: Scan loss at operating angle (dB)
|
|
74
|
+
- directivity_db: Array directivity (dB)
|
|
75
|
+
- n_elements: Number of array elements
|
|
76
|
+
"""
|
|
77
|
+
# Extract scan angle from scenario if available
|
|
78
|
+
scan_angle_deg = getattr(scenario, "scan_angle_deg", 0.0)
|
|
79
|
+
|
|
80
|
+
if HAS_PAM:
|
|
81
|
+
return self._evaluate_with_pam(arch, scenario, scan_angle_deg)
|
|
82
|
+
else:
|
|
83
|
+
return self._evaluate_analytical(arch, scenario, scan_angle_deg)
|
|
84
|
+
|
|
85
|
+
def _evaluate_with_pam(
|
|
86
|
+
self, arch: Architecture, scenario: Scenario, scan_angle_deg: float
|
|
87
|
+
) -> MetricsDict:
|
|
88
|
+
"""Evaluate using phased-array-modeling library."""
|
|
89
|
+
wavelength_m = scenario.wavelength_m if hasattr(scenario, "wavelength_m") else None
|
|
90
|
+
|
|
91
|
+
if wavelength_m is None:
|
|
92
|
+
from phased_array_systems.constants import C
|
|
93
|
+
wavelength_m = C / scenario.freq_hz
|
|
94
|
+
|
|
95
|
+
# Create array object
|
|
96
|
+
array = RectangularArray(
|
|
97
|
+
nx=arch.array.nx,
|
|
98
|
+
ny=arch.array.ny,
|
|
99
|
+
dx=arch.array.dx_lambda * wavelength_m,
|
|
100
|
+
dy=arch.array.dy_lambda * wavelength_m,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Compute array factor over theta range
|
|
104
|
+
theta_deg = np.linspace(-90, 90, 361)
|
|
105
|
+
theta_rad = np.radians(theta_deg)
|
|
106
|
+
|
|
107
|
+
# Compute AF at phi=0 (azimuth cut)
|
|
108
|
+
af_az = compute_array_factor(
|
|
109
|
+
array,
|
|
110
|
+
theta_rad,
|
|
111
|
+
phi=0,
|
|
112
|
+
wavelength=wavelength_m,
|
|
113
|
+
scan_theta=np.radians(scan_angle_deg),
|
|
114
|
+
scan_phi=0,
|
|
115
|
+
)
|
|
116
|
+
af_az_db = 20 * np.log10(np.abs(af_az) + 1e-12)
|
|
117
|
+
af_az_db = af_az_db - np.max(af_az_db) # Normalize to peak
|
|
118
|
+
|
|
119
|
+
# Compute AF at phi=90 (elevation cut)
|
|
120
|
+
af_el = compute_array_factor(
|
|
121
|
+
array,
|
|
122
|
+
theta_rad,
|
|
123
|
+
phi=np.pi / 2,
|
|
124
|
+
wavelength=wavelength_m,
|
|
125
|
+
scan_theta=np.radians(scan_angle_deg),
|
|
126
|
+
scan_phi=0,
|
|
127
|
+
)
|
|
128
|
+
af_el_db = 20 * np.log10(np.abs(af_el) + 1e-12)
|
|
129
|
+
af_el_db = af_el_db - np.max(af_el_db)
|
|
130
|
+
|
|
131
|
+
# Extract metrics
|
|
132
|
+
beamwidth_az = compute_beamwidth(af_az_db, theta_deg)
|
|
133
|
+
beamwidth_el = compute_beamwidth(af_el_db, theta_deg)
|
|
134
|
+
sll = compute_sidelobe_level(af_az_db, theta_deg)
|
|
135
|
+
scan_loss = compute_scan_loss(scan_angle_deg)
|
|
136
|
+
directivity = compute_directivity_rectangular(
|
|
137
|
+
arch.array.nx, arch.array.ny, arch.array.dx_lambda, arch.array.dy_lambda
|
|
138
|
+
)
|
|
139
|
+
g_peak = directivity - scan_loss # Account for scan loss
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
"g_peak_db": g_peak,
|
|
143
|
+
"beamwidth_az_deg": beamwidth_az,
|
|
144
|
+
"beamwidth_el_deg": beamwidth_el,
|
|
145
|
+
"sll_db": sll,
|
|
146
|
+
"scan_loss_db": scan_loss,
|
|
147
|
+
"directivity_db": directivity,
|
|
148
|
+
"n_elements": arch.array.n_elements,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
def _evaluate_analytical(
|
|
152
|
+
self, arch: Architecture, scenario: Scenario, scan_angle_deg: float
|
|
153
|
+
) -> MetricsDict:
|
|
154
|
+
"""Evaluate using analytical approximations.
|
|
155
|
+
|
|
156
|
+
Uses standard phased array formulas when the full simulation
|
|
157
|
+
library is not available.
|
|
158
|
+
"""
|
|
159
|
+
# Directivity from aperture size
|
|
160
|
+
directivity_db = compute_directivity_rectangular(
|
|
161
|
+
arch.array.nx, arch.array.ny, arch.array.dx_lambda, arch.array.dy_lambda
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Scan loss
|
|
165
|
+
scan_loss = compute_scan_loss(scan_angle_deg)
|
|
166
|
+
|
|
167
|
+
# Peak gain (accounting for scan)
|
|
168
|
+
g_peak = directivity_db - scan_loss
|
|
169
|
+
|
|
170
|
+
# Beamwidth approximations for uniform rectangular array
|
|
171
|
+
# BW ≈ 0.886 * lambda / (N * d) in radians, for uniform taper
|
|
172
|
+
# With d in wavelengths: BW ≈ 0.886 / (N * d_lambda) radians
|
|
173
|
+
bw_az_rad = 0.886 / (arch.array.nx * arch.array.dx_lambda)
|
|
174
|
+
bw_el_rad = 0.886 / (arch.array.ny * arch.array.dy_lambda)
|
|
175
|
+
|
|
176
|
+
beamwidth_az_deg = np.degrees(bw_az_rad)
|
|
177
|
+
beamwidth_el_deg = np.degrees(bw_el_rad)
|
|
178
|
+
|
|
179
|
+
# Sidelobe level for uniform taper (theoretical: -13.2 dB)
|
|
180
|
+
sll_db = -13.2
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
"g_peak_db": g_peak,
|
|
184
|
+
"beamwidth_az_deg": beamwidth_az_deg,
|
|
185
|
+
"beamwidth_el_deg": beamwidth_el_deg,
|
|
186
|
+
"sll_db": sll_db,
|
|
187
|
+
"scan_loss_db": scan_loss,
|
|
188
|
+
"directivity_db": directivity_db,
|
|
189
|
+
"n_elements": arch.array.n_elements,
|
|
190
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Antenna pattern metric extraction utilities."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import NDArray
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_beamwidth(
|
|
10
|
+
pattern_db: NDArray[np.floating],
|
|
11
|
+
angles_deg: NDArray[np.floating],
|
|
12
|
+
level_db: float = -3.0,
|
|
13
|
+
) -> float:
|
|
14
|
+
"""Compute beamwidth at specified level below peak.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
pattern_db: Pattern magnitude in dB
|
|
18
|
+
angles_deg: Corresponding angles in degrees
|
|
19
|
+
level_db: Level below peak to measure (default -3 dB)
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
Beamwidth in degrees, or NaN if not found
|
|
23
|
+
"""
|
|
24
|
+
peak_db = np.max(pattern_db)
|
|
25
|
+
threshold = peak_db + level_db # level_db is negative
|
|
26
|
+
|
|
27
|
+
# Find peak index
|
|
28
|
+
peak_idx = np.argmax(pattern_db)
|
|
29
|
+
|
|
30
|
+
# Search left from peak
|
|
31
|
+
left_idx = peak_idx
|
|
32
|
+
for i in range(peak_idx, -1, -1):
|
|
33
|
+
if pattern_db[i] < threshold:
|
|
34
|
+
left_idx = i
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
# Search right from peak
|
|
38
|
+
right_idx = peak_idx
|
|
39
|
+
for i in range(peak_idx, len(pattern_db)):
|
|
40
|
+
if pattern_db[i] < threshold:
|
|
41
|
+
right_idx = i
|
|
42
|
+
break
|
|
43
|
+
|
|
44
|
+
if left_idx == peak_idx or right_idx == peak_idx:
|
|
45
|
+
return float("nan")
|
|
46
|
+
|
|
47
|
+
# Linear interpolation for more accurate crossing points
|
|
48
|
+
left_angle = np.interp(threshold, [pattern_db[left_idx], pattern_db[left_idx + 1]],
|
|
49
|
+
[angles_deg[left_idx], angles_deg[left_idx + 1]])
|
|
50
|
+
right_angle = np.interp(threshold, [pattern_db[right_idx], pattern_db[right_idx - 1]],
|
|
51
|
+
[angles_deg[right_idx], angles_deg[right_idx - 1]])
|
|
52
|
+
|
|
53
|
+
return abs(right_angle - left_angle)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def compute_sidelobe_level(
|
|
57
|
+
pattern_db: NDArray[np.floating],
|
|
58
|
+
angles_deg: NDArray[np.floating],
|
|
59
|
+
main_lobe_width_deg: float | None = None,
|
|
60
|
+
) -> float:
|
|
61
|
+
"""Compute peak sidelobe level relative to main beam.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
pattern_db: Pattern magnitude in dB
|
|
65
|
+
angles_deg: Corresponding angles in degrees
|
|
66
|
+
main_lobe_width_deg: Width of main lobe to exclude (auto-detected if None)
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
Peak sidelobe level in dB (negative value)
|
|
70
|
+
"""
|
|
71
|
+
peak_db = np.max(pattern_db)
|
|
72
|
+
peak_idx = np.argmax(pattern_db)
|
|
73
|
+
peak_angle = angles_deg[peak_idx]
|
|
74
|
+
|
|
75
|
+
# Auto-detect main lobe width if not provided
|
|
76
|
+
if main_lobe_width_deg is None:
|
|
77
|
+
bw = compute_beamwidth(pattern_db, angles_deg, -3.0)
|
|
78
|
+
if np.isnan(bw):
|
|
79
|
+
bw = 10.0 # Default fallback
|
|
80
|
+
main_lobe_width_deg = bw * 2 # Use 2x beamwidth as exclusion zone
|
|
81
|
+
|
|
82
|
+
# Mask out main lobe region
|
|
83
|
+
half_width = main_lobe_width_deg / 2
|
|
84
|
+
mask = np.abs(angles_deg - peak_angle) > half_width
|
|
85
|
+
|
|
86
|
+
if not np.any(mask):
|
|
87
|
+
return float("-inf")
|
|
88
|
+
|
|
89
|
+
sidelobe_pattern = pattern_db[mask]
|
|
90
|
+
peak_sidelobe_db = np.max(sidelobe_pattern)
|
|
91
|
+
|
|
92
|
+
return peak_sidelobe_db - peak_db
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def compute_scan_loss(scan_angle_deg: float, model: str = "cosine") -> float:
|
|
96
|
+
"""Compute scan loss for a phased array at given scan angle.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
scan_angle_deg: Scan angle from boresight (degrees)
|
|
100
|
+
model: Scan loss model ("cosine" or "cosine_squared")
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Scan loss in dB (positive value representing loss)
|
|
104
|
+
"""
|
|
105
|
+
if scan_angle_deg >= 90:
|
|
106
|
+
return float("inf")
|
|
107
|
+
|
|
108
|
+
scan_rad = math.radians(scan_angle_deg)
|
|
109
|
+
|
|
110
|
+
if model == "cosine":
|
|
111
|
+
# Standard cos(theta) scan loss
|
|
112
|
+
loss_linear = math.cos(scan_rad)
|
|
113
|
+
elif model == "cosine_squared":
|
|
114
|
+
# More aggressive cos^2(theta) model
|
|
115
|
+
loss_linear = math.cos(scan_rad) ** 2
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError(f"Unknown scan loss model: {model}")
|
|
118
|
+
|
|
119
|
+
if loss_linear <= 0:
|
|
120
|
+
return float("inf")
|
|
121
|
+
|
|
122
|
+
return -10 * math.log10(loss_linear)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def compute_array_gain(n_elements: int, element_gain_db: float = 0.0) -> float:
|
|
126
|
+
"""Compute ideal array gain.
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
n_elements: Number of array elements
|
|
130
|
+
element_gain_db: Individual element gain (dB)
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Array gain in dB
|
|
134
|
+
"""
|
|
135
|
+
if n_elements < 1:
|
|
136
|
+
raise ValueError("n_elements must be >= 1")
|
|
137
|
+
|
|
138
|
+
array_factor_db = 10 * math.log10(n_elements)
|
|
139
|
+
return element_gain_db + array_factor_db
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def compute_directivity_rectangular(
|
|
143
|
+
nx: int, ny: int, dx_lambda: float, dy_lambda: float
|
|
144
|
+
) -> float:
|
|
145
|
+
"""Estimate directivity for a rectangular array.
|
|
146
|
+
|
|
147
|
+
Uses the approximation: D = pi * (2*nx*dx) * (2*ny*dy) for large arrays.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
nx: Number of elements in x
|
|
151
|
+
ny: Number of elements in y
|
|
152
|
+
dx_lambda: Element spacing in x (wavelengths)
|
|
153
|
+
dy_lambda: Element spacing in y (wavelengths)
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Directivity in dB
|
|
157
|
+
"""
|
|
158
|
+
# Aperture dimensions in wavelengths
|
|
159
|
+
lx = nx * dx_lambda
|
|
160
|
+
ly = ny * dy_lambda
|
|
161
|
+
|
|
162
|
+
# Directivity approximation for uniform aperture
|
|
163
|
+
# D ≈ 4*pi*A/lambda^2 = 4*pi*Lx*Ly (when Lx, Ly in wavelengths)
|
|
164
|
+
directivity_linear = 4 * math.pi * lx * ly
|
|
165
|
+
|
|
166
|
+
return 10 * math.log10(directivity_linear)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Base model block protocol and utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from phased_array_systems.types import MetricsDict, Scenario
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class ModelBlock(Protocol):
|
|
10
|
+
"""Protocol for model blocks that evaluate architecture/scenario combinations.
|
|
11
|
+
|
|
12
|
+
All model blocks must implement evaluate() and return a flat metrics dictionary.
|
|
13
|
+
The metrics dictionary uses the canonical metric keys defined in the package
|
|
14
|
+
documentation.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
def evaluate(self, arch: Any, scenario: Scenario, context: dict[str, Any]) -> MetricsDict:
|
|
20
|
+
"""Evaluate the model and return metrics.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
arch: Architecture configuration object
|
|
24
|
+
scenario: Scenario configuration object
|
|
25
|
+
context: Additional context (e.g., results from other models)
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Dictionary of metric_name -> value pairs
|
|
29
|
+
"""
|
|
30
|
+
...
|