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,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
phased-array-systems: Phased array antenna system design, optimization, and visualization.
|
|
3
|
+
|
|
4
|
+
This package implements an MBSE/MDAO workflow for phased array system design:
|
|
5
|
+
requirements -> architecture -> analytical models -> trade studies -> Pareto selection -> reporting.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from phased_array_systems.__about__ import __version__
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Architecture configuration subsystem."""
|
|
2
|
+
|
|
3
|
+
from phased_array_systems.architecture.config import (
|
|
4
|
+
Architecture,
|
|
5
|
+
ArrayConfig,
|
|
6
|
+
CostConfig,
|
|
7
|
+
RFChainConfig,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Architecture",
|
|
12
|
+
"ArrayConfig",
|
|
13
|
+
"CostConfig",
|
|
14
|
+
"RFChainConfig",
|
|
15
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Architecture configuration models using Pydantic."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, field_validator
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ArrayConfig(BaseModel):
|
|
9
|
+
"""Configuration for the antenna array geometry.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
geometry: Array geometry type
|
|
13
|
+
nx: Number of elements in x-direction
|
|
14
|
+
ny: Number of elements in y-direction
|
|
15
|
+
dx_lambda: Element spacing in x-direction (wavelengths)
|
|
16
|
+
dy_lambda: Element spacing in y-direction (wavelengths)
|
|
17
|
+
scan_limit_deg: Maximum scan angle from boresight (degrees)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
geometry: Literal["rectangular", "circular", "triangular"] = "rectangular"
|
|
21
|
+
nx: int = Field(ge=1, description="Number of elements in x-direction")
|
|
22
|
+
ny: int = Field(ge=1, description="Number of elements in y-direction")
|
|
23
|
+
dx_lambda: float = Field(default=0.5, gt=0, description="Element spacing in x (wavelengths)")
|
|
24
|
+
dy_lambda: float = Field(default=0.5, gt=0, description="Element spacing in y (wavelengths)")
|
|
25
|
+
scan_limit_deg: float = Field(
|
|
26
|
+
default=60.0, ge=0, le=90, description="Maximum scan angle (degrees)"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def n_elements(self) -> int:
|
|
31
|
+
"""Total number of elements in the array."""
|
|
32
|
+
return self.nx * self.ny
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RFChainConfig(BaseModel):
|
|
36
|
+
"""Configuration for the RF chain.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
tx_power_w_per_elem: Transmit power per element (Watts)
|
|
40
|
+
pa_efficiency: Power amplifier efficiency (0-1)
|
|
41
|
+
noise_figure_db: Receiver noise figure (dB)
|
|
42
|
+
n_tx_beams: Number of simultaneous transmit beams
|
|
43
|
+
feed_loss_db: Feed network loss (dB)
|
|
44
|
+
system_loss_db: Additional system losses (dB)
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
tx_power_w_per_elem: float = Field(gt=0, description="TX power per element (W)")
|
|
48
|
+
pa_efficiency: float = Field(
|
|
49
|
+
default=0.3, gt=0, le=1, description="PA efficiency (0-1)"
|
|
50
|
+
)
|
|
51
|
+
noise_figure_db: float = Field(
|
|
52
|
+
default=3.0, ge=0, description="Noise figure (dB)"
|
|
53
|
+
)
|
|
54
|
+
n_tx_beams: int = Field(default=1, ge=1, description="Number of TX beams")
|
|
55
|
+
feed_loss_db: float = Field(default=1.0, ge=0, description="Feed network loss (dB)")
|
|
56
|
+
system_loss_db: float = Field(default=0.0, ge=0, description="Additional system losses (dB)")
|
|
57
|
+
|
|
58
|
+
@field_validator("pa_efficiency")
|
|
59
|
+
@classmethod
|
|
60
|
+
def validate_efficiency(cls, v: float) -> float:
|
|
61
|
+
if not 0 < v <= 1:
|
|
62
|
+
raise ValueError("PA efficiency must be between 0 and 1")
|
|
63
|
+
return v
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CostConfig(BaseModel):
|
|
67
|
+
"""Configuration for cost modeling.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
cost_per_elem_usd: Recurring cost per element (USD)
|
|
71
|
+
nre_usd: Non-recurring engineering cost (USD)
|
|
72
|
+
integration_cost_usd: System integration cost (USD)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
cost_per_elem_usd: float = Field(default=100.0, ge=0, description="Cost per element (USD)")
|
|
76
|
+
nre_usd: float = Field(default=0.0, ge=0, description="NRE cost (USD)")
|
|
77
|
+
integration_cost_usd: float = Field(
|
|
78
|
+
default=0.0, ge=0, description="Integration cost (USD)"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Architecture(BaseModel):
|
|
83
|
+
"""Complete system architecture configuration.
|
|
84
|
+
|
|
85
|
+
This is the top-level configuration object that contains all
|
|
86
|
+
subsystem configurations.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
array: Antenna array configuration
|
|
90
|
+
rf: RF chain configuration
|
|
91
|
+
cost: Cost model configuration
|
|
92
|
+
name: Optional name for this architecture
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
array: ArrayConfig
|
|
96
|
+
rf: RFChainConfig
|
|
97
|
+
cost: CostConfig = Field(default_factory=CostConfig)
|
|
98
|
+
name: str | None = Field(default=None, description="Architecture name")
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def n_elements(self) -> int:
|
|
102
|
+
"""Total number of elements (convenience property)."""
|
|
103
|
+
return self.array.n_elements
|
|
104
|
+
|
|
105
|
+
def model_dump_flat(self) -> dict:
|
|
106
|
+
"""Return a flattened dictionary of all configuration values.
|
|
107
|
+
|
|
108
|
+
Useful for DOE case generation where we need flat parameter names.
|
|
109
|
+
"""
|
|
110
|
+
flat = {}
|
|
111
|
+
for prefix, config in [
|
|
112
|
+
("array", self.array),
|
|
113
|
+
("rf", self.rf),
|
|
114
|
+
("cost", self.cost),
|
|
115
|
+
]:
|
|
116
|
+
for key, value in config.model_dump().items():
|
|
117
|
+
flat[f"{prefix}.{key}"] = value
|
|
118
|
+
if self.name:
|
|
119
|
+
flat["name"] = self.name
|
|
120
|
+
return flat
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_flat(cls, flat_dict: dict) -> "Architecture":
|
|
124
|
+
"""Create an Architecture from a flattened dictionary.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
flat_dict: Dictionary with keys like "array.nx", "rf.tx_power_w_per_elem"
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Architecture instance
|
|
131
|
+
"""
|
|
132
|
+
array_dict = {}
|
|
133
|
+
rf_dict = {}
|
|
134
|
+
cost_dict = {}
|
|
135
|
+
name = None
|
|
136
|
+
|
|
137
|
+
for key, value in flat_dict.items():
|
|
138
|
+
if key == "name":
|
|
139
|
+
name = value
|
|
140
|
+
elif key.startswith("array."):
|
|
141
|
+
array_dict[key.replace("array.", "")] = value
|
|
142
|
+
elif key.startswith("rf."):
|
|
143
|
+
rf_dict[key.replace("rf.", "")] = value
|
|
144
|
+
elif key.startswith("cost."):
|
|
145
|
+
cost_dict[key.replace("cost.", "")] = value
|
|
146
|
+
|
|
147
|
+
return cls(
|
|
148
|
+
array=ArrayConfig(**array_dict),
|
|
149
|
+
rf=RFChainConfig(**rf_dict),
|
|
150
|
+
cost=CostConfig(**cost_dict) if cost_dict else CostConfig(),
|
|
151
|
+
name=name,
|
|
152
|
+
)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Command-line interface for phased-array-systems.
|
|
2
|
+
|
|
3
|
+
Planned for Phase 4 implementation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
"""Entry point for the pasys CLI."""
|
|
11
|
+
print("phased-array-systems CLI")
|
|
12
|
+
print("=" * 40)
|
|
13
|
+
print("CLI implementation planned for Phase 4.")
|
|
14
|
+
print()
|
|
15
|
+
print("For now, use the Python API directly:")
|
|
16
|
+
print(" - Single case: examples/01_comms_single_case.py")
|
|
17
|
+
print(" - DOE study: examples/02_comms_doe_trade.py")
|
|
18
|
+
print()
|
|
19
|
+
print("See documentation at:")
|
|
20
|
+
print(" https://github.com/phased-array-systems/phased-array-systems")
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
if __name__ == "__main__":
|
|
25
|
+
sys.exit(main())
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Physical constants used throughout the package."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
# Speed of light in vacuum [m/s]
|
|
6
|
+
C = 299_792_458.0
|
|
7
|
+
|
|
8
|
+
# Boltzmann constant [J/K]
|
|
9
|
+
K_B = 1.380649e-23
|
|
10
|
+
|
|
11
|
+
# Standard reference temperature [K]
|
|
12
|
+
T_REF = 290.0
|
|
13
|
+
|
|
14
|
+
# Pi (for convenience)
|
|
15
|
+
PI = math.pi
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# Conversion functions
|
|
19
|
+
def db_to_linear(db: float) -> float:
|
|
20
|
+
"""Convert dB to linear scale (power)."""
|
|
21
|
+
return 10 ** (db / 10)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def linear_to_db(linear: float) -> float:
|
|
25
|
+
"""Convert linear scale to dB (power)."""
|
|
26
|
+
return 10 * math.log10(linear) if linear > 0 else float("-inf")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def dbw_to_w(dbw: float) -> float:
|
|
30
|
+
"""Convert dBW to Watts."""
|
|
31
|
+
return 10 ** (dbw / 10)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def w_to_dbw(w: float) -> float:
|
|
35
|
+
"""Convert Watts to dBW."""
|
|
36
|
+
return 10 * math.log10(w) if w > 0 else float("-inf")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def db_to_linear_voltage(db: float) -> float:
|
|
40
|
+
"""Convert dB to linear scale (voltage/amplitude)."""
|
|
41
|
+
return 10 ** (db / 20)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def linear_to_db_voltage(linear: float) -> float:
|
|
45
|
+
"""Convert linear scale to dB (voltage/amplitude)."""
|
|
46
|
+
return 20 * math.log10(linear) if linear > 0 else float("-inf")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Backward-compatible aliases (uppercase)
|
|
50
|
+
DB_TO_LINEAR = db_to_linear
|
|
51
|
+
LINEAR_TO_DB = linear_to_db
|
|
52
|
+
DBW_TO_W = dbw_to_w
|
|
53
|
+
W_TO_DBW = w_to_dbw
|
|
54
|
+
DB_TO_LINEAR_VOLTAGE = db_to_linear_voltage
|
|
55
|
+
LINEAR_TO_DB_VOLTAGE = linear_to_db_voltage
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Single-case evaluation orchestrator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from phased_array_systems.architecture import Architecture
|
|
9
|
+
from phased_array_systems.models.antenna import PhasedArrayAdapter
|
|
10
|
+
from phased_array_systems.models.comms import CommsLinkModel
|
|
11
|
+
from phased_array_systems.models.swapc import CostModel, PowerModel
|
|
12
|
+
from phased_array_systems.requirements import RequirementSet, VerificationReport
|
|
13
|
+
from phased_array_systems.scenarios import CommsLinkScenario, RadarDetectionScenario
|
|
14
|
+
from phased_array_systems.types import MetricsDict, Scenario
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from phased_array_systems.io.schema import StudyConfig
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def evaluate_case(
|
|
21
|
+
arch: Architecture,
|
|
22
|
+
scenario: Scenario,
|
|
23
|
+
requirements: RequirementSet | None = None,
|
|
24
|
+
case_id: str | None = None,
|
|
25
|
+
) -> MetricsDict:
|
|
26
|
+
"""Evaluate a single architecture/scenario case.
|
|
27
|
+
|
|
28
|
+
Runs all applicable models and returns merged metrics dictionary.
|
|
29
|
+
Optionally verifies against requirements and includes verification results.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
arch: Architecture configuration
|
|
33
|
+
scenario: Scenario configuration (CommsLinkScenario or RadarDetectionScenario)
|
|
34
|
+
requirements: Optional requirement set for verification
|
|
35
|
+
case_id: Optional case identifier for tracking
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Dictionary containing all computed metrics plus metadata:
|
|
39
|
+
- All antenna metrics (g_peak_db, beamwidth_*, sll_db, etc.)
|
|
40
|
+
- All link/radar metrics (eirp_dbw, snr_*, margin_*, etc.)
|
|
41
|
+
- All SWaP-C metrics (power_*, cost_*)
|
|
42
|
+
- Verification results if requirements provided
|
|
43
|
+
- Metadata (case_id, runtime_s)
|
|
44
|
+
"""
|
|
45
|
+
start_time = time.perf_counter()
|
|
46
|
+
metrics: MetricsDict = {}
|
|
47
|
+
|
|
48
|
+
# Add case ID if provided
|
|
49
|
+
if case_id is not None:
|
|
50
|
+
metrics["meta.case_id"] = case_id
|
|
51
|
+
|
|
52
|
+
# Initialize models
|
|
53
|
+
antenna_model = PhasedArrayAdapter(use_analytical_fallback=True)
|
|
54
|
+
power_model = PowerModel()
|
|
55
|
+
cost_model = CostModel()
|
|
56
|
+
|
|
57
|
+
# Evaluate antenna model first (provides gain for link budget)
|
|
58
|
+
antenna_metrics = antenna_model.evaluate(arch, scenario, {})
|
|
59
|
+
metrics.update(antenna_metrics)
|
|
60
|
+
|
|
61
|
+
# Create context with antenna results for downstream models
|
|
62
|
+
context: dict[str, Any] = dict(antenna_metrics)
|
|
63
|
+
|
|
64
|
+
# Evaluate SWaP-C models
|
|
65
|
+
power_metrics = power_model.evaluate(arch, scenario, context)
|
|
66
|
+
metrics.update(power_metrics)
|
|
67
|
+
|
|
68
|
+
cost_metrics = cost_model.evaluate(arch, scenario, context)
|
|
69
|
+
metrics.update(cost_metrics)
|
|
70
|
+
|
|
71
|
+
# Evaluate scenario-specific models
|
|
72
|
+
if isinstance(scenario, CommsLinkScenario):
|
|
73
|
+
comms_model = CommsLinkModel()
|
|
74
|
+
comms_metrics = comms_model.evaluate(arch, scenario, context)
|
|
75
|
+
metrics.update(comms_metrics)
|
|
76
|
+
elif isinstance(scenario, RadarDetectionScenario):
|
|
77
|
+
# Radar model placeholder (Phase 3)
|
|
78
|
+
metrics["meta.warning"] = "Radar model not yet implemented"
|
|
79
|
+
|
|
80
|
+
# Verify requirements if provided
|
|
81
|
+
if requirements is not None and len(requirements) > 0:
|
|
82
|
+
report = requirements.verify(metrics)
|
|
83
|
+
metrics["verification.passes"] = 1.0 if report.passes else 0.0
|
|
84
|
+
metrics["verification.must_pass_count"] = float(report.must_pass_count)
|
|
85
|
+
metrics["verification.must_total_count"] = float(report.must_total_count)
|
|
86
|
+
metrics["verification.failed_ids"] = ",".join(report.failed_ids) if report.failed_ids else ""
|
|
87
|
+
|
|
88
|
+
# Add timing metadata
|
|
89
|
+
elapsed = time.perf_counter() - start_time
|
|
90
|
+
metrics["meta.runtime_s"] = elapsed
|
|
91
|
+
|
|
92
|
+
return metrics
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def evaluate_case_with_report(
|
|
96
|
+
arch: Architecture,
|
|
97
|
+
scenario: Scenario,
|
|
98
|
+
requirements: RequirementSet,
|
|
99
|
+
case_id: str | None = None,
|
|
100
|
+
) -> tuple[MetricsDict, VerificationReport]:
|
|
101
|
+
"""Evaluate a case and return both metrics and full verification report.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
arch: Architecture configuration
|
|
105
|
+
scenario: Scenario configuration
|
|
106
|
+
requirements: Requirement set for verification
|
|
107
|
+
case_id: Optional case identifier
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Tuple of (metrics dict, VerificationReport)
|
|
111
|
+
"""
|
|
112
|
+
metrics = evaluate_case(arch, scenario, requirements, case_id)
|
|
113
|
+
report = requirements.verify(metrics)
|
|
114
|
+
return metrics, report
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def evaluate_config(config: StudyConfig) -> MetricsDict:
|
|
118
|
+
"""Evaluate a case from a StudyConfig object.
|
|
119
|
+
|
|
120
|
+
Convenience function for config-driven evaluation.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
config: StudyConfig object with architecture, scenario, requirements
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Metrics dictionary
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
arch = config.get_architecture()
|
|
130
|
+
scenario = config.get_scenario()
|
|
131
|
+
requirements = config.get_requirement_set()
|
|
132
|
+
|
|
133
|
+
if scenario is None:
|
|
134
|
+
raise ValueError("StudyConfig must have a scenario defined")
|
|
135
|
+
|
|
136
|
+
return evaluate_case(arch, scenario, requirements, case_id=config.name)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Configuration I/O and data export utilities."""
|
|
2
|
+
|
|
3
|
+
from phased_array_systems.io.config_loader import load_config
|
|
4
|
+
from phased_array_systems.io.exporters import export_results, get_export_metadata, load_results
|
|
5
|
+
from phased_array_systems.io.schema import StudyConfig
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"StudyConfig",
|
|
9
|
+
"load_config",
|
|
10
|
+
"export_results",
|
|
11
|
+
"load_results",
|
|
12
|
+
"get_export_metadata",
|
|
13
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Configuration file loading utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from phased_array_systems.io.schema import StudyConfig
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def load_config(path: str | Path) -> StudyConfig:
|
|
12
|
+
"""Load a study configuration from a YAML or JSON file.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
path: Path to configuration file (.yaml, .yml, or .json)
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Validated StudyConfig object
|
|
19
|
+
|
|
20
|
+
Raises:
|
|
21
|
+
FileNotFoundError: If config file doesn't exist
|
|
22
|
+
ValueError: If file format is not supported
|
|
23
|
+
ValidationError: If config validation fails
|
|
24
|
+
"""
|
|
25
|
+
path = Path(path)
|
|
26
|
+
|
|
27
|
+
if not path.exists():
|
|
28
|
+
raise FileNotFoundError(f"Configuration file not found: {path}")
|
|
29
|
+
|
|
30
|
+
suffix = path.suffix.lower()
|
|
31
|
+
|
|
32
|
+
if suffix in (".yaml", ".yml"):
|
|
33
|
+
with open(path, encoding="utf-8") as f:
|
|
34
|
+
data = yaml.safe_load(f)
|
|
35
|
+
elif suffix == ".json":
|
|
36
|
+
with open(path, encoding="utf-8") as f:
|
|
37
|
+
data = json.load(f)
|
|
38
|
+
else:
|
|
39
|
+
raise ValueError(f"Unsupported config format: {suffix}. Use .yaml, .yml, or .json")
|
|
40
|
+
|
|
41
|
+
return StudyConfig.model_validate(data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_config_from_string(content: str, format: str = "yaml") -> StudyConfig:
|
|
45
|
+
"""Load a study configuration from a string.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
content: Configuration content as string
|
|
49
|
+
format: Format of the content ("yaml" or "json")
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Validated StudyConfig object
|
|
53
|
+
"""
|
|
54
|
+
if format.lower() in ("yaml", "yml"):
|
|
55
|
+
data = yaml.safe_load(content)
|
|
56
|
+
elif format.lower() == "json":
|
|
57
|
+
data = json.loads(content)
|
|
58
|
+
else:
|
|
59
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
60
|
+
|
|
61
|
+
return StudyConfig.model_validate(data)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def save_config(config: StudyConfig, path: str | Path, format: str | None = None) -> None:
|
|
65
|
+
"""Save a study configuration to a file.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
config: StudyConfig object to save
|
|
69
|
+
path: Output file path
|
|
70
|
+
format: Output format (auto-detected from extension if None)
|
|
71
|
+
"""
|
|
72
|
+
path = Path(path)
|
|
73
|
+
|
|
74
|
+
if format is None:
|
|
75
|
+
format = path.suffix.lower().lstrip(".")
|
|
76
|
+
|
|
77
|
+
data = config.model_dump(exclude_none=True)
|
|
78
|
+
|
|
79
|
+
if format in ("yaml", "yml"):
|
|
80
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
81
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
82
|
+
elif format == "json":
|
|
83
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
84
|
+
json.dump(data, f, indent=2)
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError(f"Unsupported format: {format}")
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Results export utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def export_results(
|
|
11
|
+
results: pd.DataFrame,
|
|
12
|
+
path: str | Path,
|
|
13
|
+
format: Literal["parquet", "csv", "json"] | None = None,
|
|
14
|
+
include_metadata: bool = True,
|
|
15
|
+
) -> Path:
|
|
16
|
+
"""Export evaluation results to file.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
results: DataFrame with evaluation results
|
|
20
|
+
path: Output file path
|
|
21
|
+
format: Output format (auto-detected from extension if None)
|
|
22
|
+
include_metadata: Include export metadata (timestamp, version)
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Path to exported file
|
|
26
|
+
"""
|
|
27
|
+
path = Path(path)
|
|
28
|
+
|
|
29
|
+
if format is None:
|
|
30
|
+
suffix = path.suffix.lower()
|
|
31
|
+
if suffix == ".parquet":
|
|
32
|
+
format = "parquet"
|
|
33
|
+
elif suffix == ".csv":
|
|
34
|
+
format = "csv"
|
|
35
|
+
elif suffix == ".json":
|
|
36
|
+
format = "json"
|
|
37
|
+
else:
|
|
38
|
+
format = "parquet" # Default
|
|
39
|
+
|
|
40
|
+
# Ensure parent directory exists
|
|
41
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
if format == "parquet":
|
|
44
|
+
_export_parquet(results, path, include_metadata)
|
|
45
|
+
elif format == "csv":
|
|
46
|
+
_export_csv(results, path)
|
|
47
|
+
elif format == "json":
|
|
48
|
+
_export_json(results, path, include_metadata)
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"Unknown format: {format}")
|
|
51
|
+
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _export_parquet(
|
|
56
|
+
results: pd.DataFrame,
|
|
57
|
+
path: Path,
|
|
58
|
+
include_metadata: bool,
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Export to Parquet format with optional metadata."""
|
|
61
|
+
import pyarrow as pa
|
|
62
|
+
import pyarrow.parquet as pq
|
|
63
|
+
|
|
64
|
+
table = pa.Table.from_pandas(results)
|
|
65
|
+
|
|
66
|
+
if include_metadata:
|
|
67
|
+
from datetime import datetime
|
|
68
|
+
|
|
69
|
+
from phased_array_systems import __version__
|
|
70
|
+
|
|
71
|
+
custom_meta = {
|
|
72
|
+
"export_timestamp": datetime.now().isoformat(),
|
|
73
|
+
"package_version": __version__,
|
|
74
|
+
"n_cases": str(len(results)),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
existing_meta = table.schema.metadata or {}
|
|
78
|
+
merged_meta = {**existing_meta, **{k.encode(): v.encode() for k, v in custom_meta.items()}}
|
|
79
|
+
table = table.replace_schema_metadata(merged_meta)
|
|
80
|
+
|
|
81
|
+
pq.write_table(table, path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _export_csv(results: pd.DataFrame, path: Path) -> None:
|
|
85
|
+
"""Export to CSV format."""
|
|
86
|
+
results.to_csv(path, index=False)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _export_json(
|
|
90
|
+
results: pd.DataFrame,
|
|
91
|
+
path: Path,
|
|
92
|
+
include_metadata: bool,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Export to JSON format."""
|
|
95
|
+
data = results.to_dict(orient="records")
|
|
96
|
+
|
|
97
|
+
if include_metadata:
|
|
98
|
+
from datetime import datetime
|
|
99
|
+
|
|
100
|
+
from phased_array_systems import __version__
|
|
101
|
+
|
|
102
|
+
output = {
|
|
103
|
+
"metadata": {
|
|
104
|
+
"export_timestamp": datetime.now().isoformat(),
|
|
105
|
+
"package_version": __version__,
|
|
106
|
+
"n_cases": len(results),
|
|
107
|
+
},
|
|
108
|
+
"results": data,
|
|
109
|
+
}
|
|
110
|
+
else:
|
|
111
|
+
output = {"results": data}
|
|
112
|
+
|
|
113
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
114
|
+
json.dump(output, f, indent=2, default=str)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load_results(path: str | Path) -> pd.DataFrame:
|
|
118
|
+
"""Load previously exported results.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
path: Path to results file
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
DataFrame with results
|
|
125
|
+
"""
|
|
126
|
+
path = Path(path)
|
|
127
|
+
suffix = path.suffix.lower()
|
|
128
|
+
|
|
129
|
+
if suffix == ".parquet":
|
|
130
|
+
return pd.read_parquet(path)
|
|
131
|
+
elif suffix == ".csv":
|
|
132
|
+
return pd.read_csv(path)
|
|
133
|
+
elif suffix == ".json":
|
|
134
|
+
with open(path, encoding="utf-8") as f:
|
|
135
|
+
data = json.load(f)
|
|
136
|
+
if "results" in data:
|
|
137
|
+
return pd.DataFrame(data["results"])
|
|
138
|
+
else:
|
|
139
|
+
return pd.DataFrame(data)
|
|
140
|
+
else:
|
|
141
|
+
raise ValueError(f"Unknown format: {suffix}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_export_metadata(path: str | Path) -> dict | None:
|
|
145
|
+
"""Get metadata from an exported results file.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
path: Path to results file
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Metadata dictionary or None if no metadata
|
|
152
|
+
"""
|
|
153
|
+
path = Path(path)
|
|
154
|
+
suffix = path.suffix.lower()
|
|
155
|
+
|
|
156
|
+
if suffix == ".parquet":
|
|
157
|
+
import pyarrow.parquet as pq
|
|
158
|
+
|
|
159
|
+
meta = pq.read_metadata(path)
|
|
160
|
+
if meta.schema.metadata:
|
|
161
|
+
return {k.decode(): v.decode() for k, v in meta.schema.metadata.items()
|
|
162
|
+
if k.decode().startswith(("export_", "package_", "n_"))}
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
elif suffix == ".json":
|
|
166
|
+
with open(path, encoding="utf-8") as f:
|
|
167
|
+
data = json.load(f)
|
|
168
|
+
return data.get("metadata")
|
|
169
|
+
|
|
170
|
+
else:
|
|
171
|
+
return None
|