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.
Files changed (43) hide show
  1. phased_array_systems/__about__.py +4 -0
  2. phased_array_systems/__init__.py +10 -0
  3. phased_array_systems/architecture/__init__.py +15 -0
  4. phased_array_systems/architecture/config.py +152 -0
  5. phased_array_systems/cli.py +25 -0
  6. phased_array_systems/constants.py +55 -0
  7. phased_array_systems/evaluate.py +136 -0
  8. phased_array_systems/io/__init__.py +13 -0
  9. phased_array_systems/io/config_loader.py +86 -0
  10. phased_array_systems/io/exporters.py +171 -0
  11. phased_array_systems/io/schema.py +145 -0
  12. phased_array_systems/models/__init__.py +5 -0
  13. phased_array_systems/models/antenna/__init__.py +15 -0
  14. phased_array_systems/models/antenna/adapter.py +190 -0
  15. phased_array_systems/models/antenna/metrics.py +166 -0
  16. phased_array_systems/models/base.py +30 -0
  17. phased_array_systems/models/comms/__init__.py +9 -0
  18. phased_array_systems/models/comms/link_budget.py +171 -0
  19. phased_array_systems/models/comms/propagation.py +84 -0
  20. phased_array_systems/models/swapc/__init__.py +9 -0
  21. phased_array_systems/models/swapc/cost.py +98 -0
  22. phased_array_systems/models/swapc/power.py +102 -0
  23. phased_array_systems/requirements/__init__.py +15 -0
  24. phased_array_systems/requirements/core.py +244 -0
  25. phased_array_systems/scenarios/__init__.py +11 -0
  26. phased_array_systems/scenarios/base.py +30 -0
  27. phased_array_systems/scenarios/comms.py +56 -0
  28. phased_array_systems/scenarios/radar.py +42 -0
  29. phased_array_systems/trades/__init__.py +16 -0
  30. phased_array_systems/trades/design_space.py +241 -0
  31. phased_array_systems/trades/doe.py +146 -0
  32. phased_array_systems/trades/pareto.py +266 -0
  33. phased_array_systems/trades/runner.py +245 -0
  34. phased_array_systems/types.py +54 -0
  35. phased_array_systems/utils/__init__.py +8 -0
  36. phased_array_systems/utils/hashing.py +70 -0
  37. phased_array_systems/viz/__init__.py +9 -0
  38. phased_array_systems/viz/plots.py +324 -0
  39. phased_array_systems-0.1.0.dist-info/METADATA +174 -0
  40. phased_array_systems-0.1.0.dist-info/RECORD +43 -0
  41. phased_array_systems-0.1.0.dist-info/WHEEL +4 -0
  42. phased_array_systems-0.1.0.dist-info/entry_points.txt +2 -0
  43. phased_array_systems-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,4 @@
1
+ """Version and metadata for phased-array-systems."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "phased-array-systems contributors"
@@ -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