physbound 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.
- physbound/__init__.py +3 -0
- physbound/engines/__init__.py +0 -0
- physbound/engines/constants.py +25 -0
- physbound/engines/link_budget.py +217 -0
- physbound/engines/noise.py +125 -0
- physbound/engines/shannon.py +106 -0
- physbound/engines/units.py +103 -0
- physbound/errors.py +34 -0
- physbound/models/__init__.py +0 -0
- physbound/models/common.py +11 -0
- physbound/models/link_budget.py +33 -0
- physbound/models/noise.py +37 -0
- physbound/models/shannon.py +40 -0
- physbound/server.py +279 -0
- physbound/validators.py +70 -0
- physbound-0.1.0.dist-info/METADATA +147 -0
- physbound-0.1.0.dist-info/RECORD +20 -0
- physbound-0.1.0.dist-info/WHEEL +4 -0
- physbound-0.1.0.dist-info/entry_points.txt +2 -0
- physbound-0.1.0.dist-info/licenses/LICENSE +21 -0
physbound/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Physical constants as Pint quantities wrapping SciPy CODATA values.
|
|
2
|
+
|
|
3
|
+
All downstream modules import the shared UnitRegistry and constants from here.
|
|
4
|
+
No raw floats should leak into engine code — everything carries units.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pint
|
|
8
|
+
from scipy import constants as sc
|
|
9
|
+
|
|
10
|
+
# Single shared registry — import this everywhere
|
|
11
|
+
ureg = pint.UnitRegistry()
|
|
12
|
+
Q_ = ureg.Quantity
|
|
13
|
+
|
|
14
|
+
# Exact physical constants as Pint quantities (CODATA 2018 exact values)
|
|
15
|
+
SPEED_OF_LIGHT = Q_(sc.speed_of_light, "m/s") # 299_792_458 m/s (exact)
|
|
16
|
+
BOLTZMANN = Q_(sc.Boltzmann, "J/K") # 1.380649e-23 J/K (exact)
|
|
17
|
+
PLANCK = Q_(sc.Planck, "J*s") # 6.62607015e-34 J·s (exact)
|
|
18
|
+
|
|
19
|
+
# IEEE standard reference temperature
|
|
20
|
+
T_REF = Q_(290, "K")
|
|
21
|
+
|
|
22
|
+
# Derived: thermal noise floor at T_REF, 1 Hz bandwidth
|
|
23
|
+
# N = k_B * T = 1.380649e-23 * 290 = 4.00388e-21 W/Hz
|
|
24
|
+
# In dBm/Hz: 10 * log10(4.00388e-21 / 1e-3) = -173.977 ≈ -174 dBm/Hz
|
|
25
|
+
THERMAL_NOISE_FLOOR_DBM_PER_HZ = -174.0
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""RF Link Budget calculator using Friis transmission equation.
|
|
2
|
+
|
|
3
|
+
Formulas:
|
|
4
|
+
FSPL = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c) (free-space path loss)
|
|
5
|
+
P_rx = P_tx + G_tx + G_rx - FSPL - L_tx - L_rx (Friis transmission)
|
|
6
|
+
G_max = eta * (pi*D/lambda)^2 (aperture limit)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from physbound.engines.constants import SPEED_OF_LIGHT
|
|
12
|
+
from physbound.engines.units import linear_to_db
|
|
13
|
+
from physbound.errors import PhysicalViolationError
|
|
14
|
+
from physbound.validators import validate_positive_distance, validate_positive_frequency
|
|
15
|
+
|
|
16
|
+
# Default aperture efficiency for parabolic dish antennas
|
|
17
|
+
DEFAULT_APERTURE_EFFICIENCY = 0.55
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def free_space_path_loss_db(frequency_hz: float, distance_m: float) -> float:
|
|
21
|
+
"""Compute free-space path loss in dB.
|
|
22
|
+
|
|
23
|
+
FSPL(dB) = 20*log10(d) + 20*log10(f) + 20*log10(4*pi/c)
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
frequency_hz: Carrier frequency in Hz.
|
|
27
|
+
distance_m: Link distance in meters.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
FSPL in dB (positive value; a loss).
|
|
31
|
+
"""
|
|
32
|
+
validate_positive_frequency(frequency_hz)
|
|
33
|
+
validate_positive_distance(distance_m)
|
|
34
|
+
|
|
35
|
+
c = SPEED_OF_LIGHT.magnitude # m/s
|
|
36
|
+
fspl = (
|
|
37
|
+
20.0 * math.log10(distance_m)
|
|
38
|
+
+ 20.0 * math.log10(frequency_hz)
|
|
39
|
+
+ 20.0 * math.log10(4.0 * math.pi / c)
|
|
40
|
+
)
|
|
41
|
+
return fspl
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def max_aperture_gain_dbi(
|
|
45
|
+
diameter_m: float,
|
|
46
|
+
frequency_hz: float,
|
|
47
|
+
efficiency: float = DEFAULT_APERTURE_EFFICIENCY,
|
|
48
|
+
) -> float:
|
|
49
|
+
"""Compute maximum antenna gain for a circular aperture.
|
|
50
|
+
|
|
51
|
+
G_max = eta * (pi * D / lambda)^2
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
diameter_m: Antenna diameter in meters.
|
|
55
|
+
frequency_hz: Operating frequency in Hz.
|
|
56
|
+
efficiency: Aperture efficiency (default: 0.55 for parabolic dish).
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Maximum gain in dBi.
|
|
60
|
+
"""
|
|
61
|
+
validate_positive_frequency(frequency_hz)
|
|
62
|
+
if diameter_m <= 0:
|
|
63
|
+
raise PhysicalViolationError(
|
|
64
|
+
message=f"Antenna diameter must be positive, got {diameter_m} m",
|
|
65
|
+
law_violated="Antenna Theory",
|
|
66
|
+
latex_explanation=r"$D > 0$ required for a physical antenna aperture",
|
|
67
|
+
claimed_value=diameter_m,
|
|
68
|
+
unit="m",
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
c = SPEED_OF_LIGHT.magnitude
|
|
72
|
+
wavelength = c / frequency_hz
|
|
73
|
+
g_linear = efficiency * (math.pi * diameter_m / wavelength) ** 2
|
|
74
|
+
return linear_to_db(g_linear)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_antenna_gain(
|
|
78
|
+
claimed_gain_dbi: float,
|
|
79
|
+
diameter_m: float,
|
|
80
|
+
frequency_hz: float,
|
|
81
|
+
label: str = "antenna",
|
|
82
|
+
efficiency: float = DEFAULT_APERTURE_EFFICIENCY,
|
|
83
|
+
) -> float:
|
|
84
|
+
"""Validate that claimed antenna gain doesn't exceed the aperture limit.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
claimed_gain_dbi: Claimed antenna gain in dBi.
|
|
88
|
+
diameter_m: Antenna diameter in meters.
|
|
89
|
+
frequency_hz: Operating frequency in Hz.
|
|
90
|
+
label: Human label for error messages (e.g., "TX antenna").
|
|
91
|
+
efficiency: Aperture efficiency (default: 0.55).
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The computed G_max in dBi.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
PhysicalViolationError: If claimed gain exceeds G_max.
|
|
98
|
+
"""
|
|
99
|
+
g_max = max_aperture_gain_dbi(diameter_m, frequency_hz, efficiency)
|
|
100
|
+
|
|
101
|
+
if claimed_gain_dbi > g_max:
|
|
102
|
+
c = SPEED_OF_LIGHT.magnitude
|
|
103
|
+
wavelength = c / frequency_hz
|
|
104
|
+
raise PhysicalViolationError(
|
|
105
|
+
message=(
|
|
106
|
+
f"{label} claimed gain {claimed_gain_dbi:.1f} dBi exceeds "
|
|
107
|
+
f"aperture limit {g_max:.1f} dBi for {diameter_m} m dish at "
|
|
108
|
+
f"{frequency_hz / 1e9:.3f} GHz"
|
|
109
|
+
),
|
|
110
|
+
law_violated="Antenna Aperture Limit",
|
|
111
|
+
latex_explanation=(
|
|
112
|
+
rf"$G_{{\max}} = \eta \left(\frac{{\pi D}}{{\lambda}}\right)^2 = "
|
|
113
|
+
rf"{efficiency} \times \left(\frac{{\pi \times {diameter_m}}}"
|
|
114
|
+
rf"{{{wavelength:.4f}}}\right)^2 = {g_max:.1f}\,\text{{dBi}}$. "
|
|
115
|
+
rf"Claimed ${claimed_gain_dbi:.1f}\,\text{{dBi}}$ exceeds this limit."
|
|
116
|
+
),
|
|
117
|
+
computed_limit=g_max,
|
|
118
|
+
claimed_value=claimed_gain_dbi,
|
|
119
|
+
unit="dBi",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return g_max
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def compute_link_budget(
|
|
126
|
+
tx_power_dbm: float,
|
|
127
|
+
tx_antenna_gain_dbi: float,
|
|
128
|
+
rx_antenna_gain_dbi: float,
|
|
129
|
+
frequency_hz: float,
|
|
130
|
+
distance_m: float,
|
|
131
|
+
tx_losses_db: float = 0.0,
|
|
132
|
+
rx_losses_db: float = 0.0,
|
|
133
|
+
tx_antenna_diameter_m: float | None = None,
|
|
134
|
+
rx_antenna_diameter_m: float | None = None,
|
|
135
|
+
) -> dict:
|
|
136
|
+
"""Compute a full RF link budget using the Friis transmission equation.
|
|
137
|
+
|
|
138
|
+
P_rx = P_tx + G_tx + G_rx - FSPL - L_tx - L_rx
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
tx_power_dbm: Transmit power in dBm.
|
|
142
|
+
tx_antenna_gain_dbi: Transmit antenna gain in dBi.
|
|
143
|
+
rx_antenna_gain_dbi: Receive antenna gain in dBi.
|
|
144
|
+
frequency_hz: Carrier frequency in Hz.
|
|
145
|
+
distance_m: Link distance in meters.
|
|
146
|
+
tx_losses_db: TX-side miscellaneous losses in dB.
|
|
147
|
+
rx_losses_db: RX-side miscellaneous losses in dB.
|
|
148
|
+
tx_antenna_diameter_m: Optional TX antenna diameter for aperture check.
|
|
149
|
+
rx_antenna_diameter_m: Optional RX antenna diameter for aperture check.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Dict with FSPL, received power, warnings, human-readable, and LaTeX.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
PhysicalViolationError: If antenna gains exceed aperture limits.
|
|
156
|
+
"""
|
|
157
|
+
warnings = []
|
|
158
|
+
tx_aperture_limit_dbi = None
|
|
159
|
+
rx_aperture_limit_dbi = None
|
|
160
|
+
|
|
161
|
+
# Validate antenna gains against aperture limits if diameters provided
|
|
162
|
+
if tx_antenna_diameter_m is not None:
|
|
163
|
+
tx_aperture_limit_dbi = validate_antenna_gain(
|
|
164
|
+
tx_antenna_gain_dbi, tx_antenna_diameter_m, frequency_hz, "TX antenna"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
if rx_antenna_diameter_m is not None:
|
|
168
|
+
rx_aperture_limit_dbi = validate_antenna_gain(
|
|
169
|
+
rx_antenna_gain_dbi, rx_antenna_diameter_m, frequency_hz, "RX antenna"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# Friis model applicability warning above 300 GHz
|
|
173
|
+
if frequency_hz > 3e11:
|
|
174
|
+
warnings.append(
|
|
175
|
+
f"Frequency {frequency_hz / 1e9:.1f} GHz exceeds 300 GHz; "
|
|
176
|
+
"Friis free-space model may not be accurate due to atmospheric absorption"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Compute FSPL and received power
|
|
180
|
+
fspl = free_space_path_loss_db(frequency_hz, distance_m)
|
|
181
|
+
received_power_dbm = (
|
|
182
|
+
tx_power_dbm + tx_antenna_gain_dbi + rx_antenna_gain_dbi
|
|
183
|
+
- fspl - tx_losses_db - rx_losses_db
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
c = SPEED_OF_LIGHT.magnitude
|
|
187
|
+
wavelength = c / frequency_hz
|
|
188
|
+
|
|
189
|
+
human_readable = (
|
|
190
|
+
f"Link Budget at {frequency_hz / 1e9:.3f} GHz, {distance_m:.1f} m:\n"
|
|
191
|
+
f" TX Power: {tx_power_dbm:.1f} dBm\n"
|
|
192
|
+
f" TX Gain: {tx_antenna_gain_dbi:.1f} dBi\n"
|
|
193
|
+
f" RX Gain: {rx_antenna_gain_dbi:.1f} dBi\n"
|
|
194
|
+
f" FSPL: {fspl:.2f} dB\n"
|
|
195
|
+
f" TX Losses: {tx_losses_db:.1f} dB\n"
|
|
196
|
+
f" RX Losses: {rx_losses_db:.1f} dB\n"
|
|
197
|
+
f" Received Power: {received_power_dbm:.2f} dBm"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
latex = (
|
|
201
|
+
rf"$P_{{\text{{rx}}}} = P_{{\text{{tx}}}} + G_{{\text{{tx}}}} + G_{{\text{{rx}}}} "
|
|
202
|
+
rf"- \text{{FSPL}} - L_{{\text{{tx}}}} - L_{{\text{{rx}}}} = "
|
|
203
|
+
rf"{tx_power_dbm:.1f} + {tx_antenna_gain_dbi:.1f} + {rx_antenna_gain_dbi:.1f} "
|
|
204
|
+
rf"- {fspl:.2f} - {tx_losses_db:.1f} - {rx_losses_db:.1f} = "
|
|
205
|
+
rf"{received_power_dbm:.2f}\,\text{{dBm}}$"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
"fspl_db": fspl,
|
|
210
|
+
"received_power_dbm": received_power_dbm,
|
|
211
|
+
"wavelength_m": wavelength,
|
|
212
|
+
"tx_aperture_limit_dbi": tx_aperture_limit_dbi,
|
|
213
|
+
"rx_aperture_limit_dbi": rx_aperture_limit_dbi,
|
|
214
|
+
"warnings": warnings,
|
|
215
|
+
"human_readable": human_readable,
|
|
216
|
+
"latex": latex,
|
|
217
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Thermal noise floor, Friis noise figure cascading, and receiver sensitivity.
|
|
2
|
+
|
|
3
|
+
Formulas:
|
|
4
|
+
N = k_B * T * B (thermal noise power)
|
|
5
|
+
F_total = F_1 + (F_2-1)/G_1 + ... (Friis noise cascade)
|
|
6
|
+
S_min = N_floor + NF + SNR_req (receiver sensitivity)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from physbound.engines.constants import BOLTZMANN, T_REF
|
|
12
|
+
from physbound.engines.units import db_to_linear, linear_to_db
|
|
13
|
+
from physbound.errors import PhysicalViolationError
|
|
14
|
+
from physbound.validators import validate_positive_bandwidth, validate_temperature
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def thermal_noise_power_dbm(bandwidth_hz: float, temperature_k: float = 290.0) -> float:
|
|
18
|
+
"""Compute thermal noise power N = k_B * T * B in dBm.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
bandwidth_hz: Receiver bandwidth in Hz.
|
|
22
|
+
temperature_k: System noise temperature in Kelvin (default: 290K).
|
|
23
|
+
|
|
24
|
+
Returns:
|
|
25
|
+
Thermal noise power in dBm.
|
|
26
|
+
"""
|
|
27
|
+
validate_positive_bandwidth(bandwidth_hz)
|
|
28
|
+
validate_temperature(temperature_k)
|
|
29
|
+
|
|
30
|
+
if temperature_k == 0:
|
|
31
|
+
return float("-inf")
|
|
32
|
+
|
|
33
|
+
k_b = BOLTZMANN.magnitude # J/K
|
|
34
|
+
n_watts = k_b * temperature_k * bandwidth_hz
|
|
35
|
+
n_dbm = 10.0 * math.log10(n_watts / 1e-3)
|
|
36
|
+
return n_dbm
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def thermal_noise_power_watts(bandwidth_hz: float, temperature_k: float = 290.0) -> float:
|
|
40
|
+
"""Compute thermal noise power N = k_B * T * B in watts.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
bandwidth_hz: Receiver bandwidth in Hz.
|
|
44
|
+
temperature_k: System noise temperature in Kelvin (default: 290K).
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Thermal noise power in watts.
|
|
48
|
+
"""
|
|
49
|
+
validate_positive_bandwidth(bandwidth_hz)
|
|
50
|
+
validate_temperature(temperature_k)
|
|
51
|
+
|
|
52
|
+
k_b = BOLTZMANN.magnitude
|
|
53
|
+
return k_b * temperature_k * bandwidth_hz
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def friis_noise_cascade(
|
|
57
|
+
stages: list[tuple[float, float]],
|
|
58
|
+
) -> float:
|
|
59
|
+
"""Compute cascaded noise figure using the Friis formula.
|
|
60
|
+
|
|
61
|
+
F_total = F_1 + (F_2 - 1)/G_1 + (F_3 - 1)/(G_1*G_2) + ...
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
stages: List of (gain_db, noise_figure_db) tuples for each stage.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
Total cascaded noise figure in dB.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
PhysicalViolationError: If noise figure is negative (below quantum limit).
|
|
71
|
+
"""
|
|
72
|
+
if not stages:
|
|
73
|
+
raise PhysicalViolationError(
|
|
74
|
+
message="At least one stage is required for noise cascade",
|
|
75
|
+
law_violated="Friis Noise Formula",
|
|
76
|
+
latex_explanation=r"$F_\text{total}$ requires at least one stage",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
for i, (gain_db, nf_db) in enumerate(stages):
|
|
80
|
+
if nf_db < 0:
|
|
81
|
+
raise PhysicalViolationError(
|
|
82
|
+
message=f"Stage {i + 1} noise figure is {nf_db} dB (negative); "
|
|
83
|
+
"this implies a noiseless amplifier below the quantum limit",
|
|
84
|
+
law_violated="Quantum Noise Limit",
|
|
85
|
+
latex_explanation=(
|
|
86
|
+
rf"$NF_{{{i + 1}}} = {nf_db}\,\text{{dB}} < 0$; "
|
|
87
|
+
r"violates the quantum noise limit $NF \geq 0\,\text{dB}$"
|
|
88
|
+
),
|
|
89
|
+
claimed_value=nf_db,
|
|
90
|
+
unit="dB",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Convert to linear
|
|
94
|
+
gains_linear = [db_to_linear(g) for g, _ in stages]
|
|
95
|
+
nf_linear = [db_to_linear(nf) for _, nf in stages]
|
|
96
|
+
|
|
97
|
+
# Friis formula
|
|
98
|
+
f_total = nf_linear[0]
|
|
99
|
+
cumulative_gain = 1.0
|
|
100
|
+
for i in range(1, len(stages)):
|
|
101
|
+
cumulative_gain *= gains_linear[i - 1]
|
|
102
|
+
f_total += (nf_linear[i] - 1.0) / cumulative_gain
|
|
103
|
+
|
|
104
|
+
return linear_to_db(f_total)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def receiver_sensitivity_dbm(
|
|
108
|
+
bandwidth_hz: float,
|
|
109
|
+
noise_figure_db: float,
|
|
110
|
+
required_snr_db: float,
|
|
111
|
+
temperature_k: float = 290.0,
|
|
112
|
+
) -> float:
|
|
113
|
+
"""Compute minimum receiver sensitivity: S_min = N_floor + NF + SNR_req.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
bandwidth_hz: Receiver bandwidth in Hz.
|
|
117
|
+
noise_figure_db: System noise figure in dB.
|
|
118
|
+
required_snr_db: Required SNR at the detector in dB.
|
|
119
|
+
temperature_k: Reference temperature in Kelvin (default: 290K).
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
Minimum detectable signal power in dBm.
|
|
123
|
+
"""
|
|
124
|
+
n_floor = thermal_noise_power_dbm(bandwidth_hz, temperature_k)
|
|
125
|
+
return n_floor + noise_figure_db + required_snr_db
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Shannon-Hartley channel capacity theorem implementation.
|
|
2
|
+
|
|
3
|
+
Formulas:
|
|
4
|
+
C = B * log2(1 + SNR) (channel capacity in bps)
|
|
5
|
+
eta = C / B = log2(1 + SNR) (spectral efficiency in bps/Hz)
|
|
6
|
+
SNR_linear = 10^(SNR_dB / 10) (dB to linear conversion)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
|
|
11
|
+
from physbound.engines.units import db_to_linear, linear_to_db
|
|
12
|
+
from physbound.errors import PhysicalViolationError
|
|
13
|
+
from physbound.validators import validate_positive_bandwidth, validate_positive_snr
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def channel_capacity_bps(bandwidth_hz: float, snr_linear: float) -> float:
|
|
17
|
+
"""Compute Shannon-Hartley channel capacity: C = B * log2(1 + SNR).
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
bandwidth_hz: Channel bandwidth in Hz.
|
|
21
|
+
snr_linear: Signal-to-noise ratio (linear, not dB).
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
Maximum channel capacity in bits per second.
|
|
25
|
+
"""
|
|
26
|
+
validate_positive_bandwidth(bandwidth_hz)
|
|
27
|
+
validate_positive_snr(snr_linear)
|
|
28
|
+
return bandwidth_hz * math.log2(1.0 + snr_linear)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def spectral_efficiency(snr_linear: float) -> float:
|
|
32
|
+
"""Compute spectral efficiency: eta = log2(1 + SNR) in bps/Hz.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
snr_linear: Signal-to-noise ratio (linear, not dB).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Spectral efficiency in bits/sec/Hz.
|
|
39
|
+
"""
|
|
40
|
+
validate_positive_snr(snr_linear)
|
|
41
|
+
return math.log2(1.0 + snr_linear)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def snr_db_to_linear(snr_db: float) -> float:
|
|
45
|
+
"""Convert SNR from dB to linear: SNR_linear = 10^(SNR_dB / 10)."""
|
|
46
|
+
return db_to_linear(snr_db)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def validate_throughput_claim(
|
|
50
|
+
bandwidth_hz: float,
|
|
51
|
+
snr_linear: float,
|
|
52
|
+
claimed_throughput_bps: float,
|
|
53
|
+
) -> dict:
|
|
54
|
+
"""Validate a throughput claim against the Shannon-Hartley limit.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
bandwidth_hz: Channel bandwidth in Hz.
|
|
58
|
+
snr_linear: Signal-to-noise ratio (linear, not dB).
|
|
59
|
+
claimed_throughput_bps: Claimed throughput to validate in bps.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dict with capacity, claim validity, and excess percentage.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
PhysicalViolationError: If claimed throughput exceeds Shannon limit.
|
|
66
|
+
"""
|
|
67
|
+
capacity = channel_capacity_bps(bandwidth_hz, snr_linear)
|
|
68
|
+
eta = spectral_efficiency(snr_linear)
|
|
69
|
+
|
|
70
|
+
if claimed_throughput_bps > capacity:
|
|
71
|
+
excess_pct = ((claimed_throughput_bps - capacity) / capacity) * 100.0
|
|
72
|
+
snr_db = linear_to_db(snr_linear)
|
|
73
|
+
raise PhysicalViolationError(
|
|
74
|
+
message=(
|
|
75
|
+
f"Claimed throughput {claimed_throughput_bps:.1f} bps exceeds "
|
|
76
|
+
f"Shannon limit of {capacity:.1f} bps by {excess_pct:.1f}%"
|
|
77
|
+
),
|
|
78
|
+
law_violated="Shannon-Hartley Theorem",
|
|
79
|
+
latex_explanation=(
|
|
80
|
+
rf"$C = B \log_2(1 + \text{{SNR}}) = "
|
|
81
|
+
rf"{bandwidth_hz:.0f} \times \log_2(1 + {snr_linear:.2f}) = "
|
|
82
|
+
rf"{capacity:.1f}\,\text{{bps}}$. "
|
|
83
|
+
rf"Claimed ${claimed_throughput_bps:.1f}\,\text{{bps}}$ exceeds "
|
|
84
|
+
rf"the Shannon limit by ${excess_pct:.1f}\%$."
|
|
85
|
+
),
|
|
86
|
+
computed_limit=capacity,
|
|
87
|
+
claimed_value=claimed_throughput_bps,
|
|
88
|
+
unit="bps",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Warn if spectral efficiency is unusually high (> 20 bps/Hz)
|
|
92
|
+
warnings = []
|
|
93
|
+
claimed_eta = claimed_throughput_bps / bandwidth_hz
|
|
94
|
+
if claimed_eta > 20:
|
|
95
|
+
warnings.append(
|
|
96
|
+
f"Claimed spectral efficiency {claimed_eta:.1f} bps/Hz exceeds "
|
|
97
|
+
"20 bps/Hz; possible but unusual in practical systems"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
"capacity_bps": capacity,
|
|
102
|
+
"spectral_efficiency_bps_hz": eta,
|
|
103
|
+
"claim_is_valid": True,
|
|
104
|
+
"excess_percentage": 0.0,
|
|
105
|
+
"warnings": warnings,
|
|
106
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Unit conversion helpers for RF engineering calculations.
|
|
2
|
+
|
|
3
|
+
All functions operate on Pint quantities and enforce dimensional correctness.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
|
|
8
|
+
import pint
|
|
9
|
+
|
|
10
|
+
from physbound.engines.constants import Q_, SPEED_OF_LIGHT, ureg
|
|
11
|
+
from physbound.errors import PhysicalViolationError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_dimensions(quantity: pint.Quantity, expected: str, name: str = "value") -> None:
|
|
15
|
+
"""Raise PhysicalViolationError if quantity dimensions don't match expected.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
quantity: The Pint quantity to check.
|
|
19
|
+
expected: Pint dimensionality string, e.g. '[length]', '[power]'.
|
|
20
|
+
name: Human-readable name for error messages.
|
|
21
|
+
"""
|
|
22
|
+
if not quantity.check(expected):
|
|
23
|
+
raise PhysicalViolationError(
|
|
24
|
+
message=f"{name} has dimensions {quantity.dimensionality}, expected {expected}",
|
|
25
|
+
law_violated="Dimensional Analysis",
|
|
26
|
+
latex_explanation=(
|
|
27
|
+
rf"$\text{{{name}}}$ has dimensions "
|
|
28
|
+
rf"$\left[{quantity.dimensionality}\right]$, "
|
|
29
|
+
rf"expected $\left[{expected}\right]$"
|
|
30
|
+
),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def watts_to_dbm(power: pint.Quantity) -> float:
|
|
35
|
+
"""Convert a Pint power quantity to dBm (dimensionless float).
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
power: Power as a Pint quantity with dimensions of [power].
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Power in dBm as a plain float.
|
|
42
|
+
"""
|
|
43
|
+
validate_dimensions(power, "[length] ** 2 * [mass] / [time] ** 3", "power")
|
|
44
|
+
power_watts = power.to(ureg.watt).magnitude
|
|
45
|
+
if power_watts <= 0:
|
|
46
|
+
raise PhysicalViolationError(
|
|
47
|
+
message=f"Power must be positive, got {power_watts} W",
|
|
48
|
+
law_violated="Conservation of Energy",
|
|
49
|
+
latex_explanation=r"$P > 0$ required; negative power is non-physical",
|
|
50
|
+
claimed_value=power_watts,
|
|
51
|
+
unit="W",
|
|
52
|
+
)
|
|
53
|
+
return 10.0 * math.log10(power_watts / 1e-3)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dbm_to_watts(power_dbm: float) -> pint.Quantity:
|
|
57
|
+
"""Convert dBm float to Pint watts quantity.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
power_dbm: Power in dBm.
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Power as a Pint Quantity in watts.
|
|
64
|
+
"""
|
|
65
|
+
return Q_(1e-3 * 10.0 ** (power_dbm / 10.0), "watt")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def frequency_to_wavelength(freq: pint.Quantity) -> pint.Quantity:
|
|
69
|
+
"""Compute wavelength from frequency: lambda = c / f.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
freq: Frequency as a Pint quantity with dimensions of [time]^-1.
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
Wavelength as a Pint Quantity in meters.
|
|
76
|
+
"""
|
|
77
|
+
validate_dimensions(freq, "1 / [time]", "frequency")
|
|
78
|
+
if freq.magnitude <= 0:
|
|
79
|
+
raise PhysicalViolationError(
|
|
80
|
+
message=f"Frequency must be positive, got {freq}",
|
|
81
|
+
law_violated="Electromagnetic Theory",
|
|
82
|
+
latex_explanation=r"$f > 0$ required for physical electromagnetic radiation",
|
|
83
|
+
claimed_value=freq.magnitude,
|
|
84
|
+
unit=str(freq.units),
|
|
85
|
+
)
|
|
86
|
+
return (SPEED_OF_LIGHT / freq).to(ureg.meter)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def db_to_linear(db: float) -> float:
|
|
90
|
+
"""Convert a dB value to linear ratio: 10^(dB/10)."""
|
|
91
|
+
return 10.0 ** (db / 10.0)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def linear_to_db(linear: float) -> float:
|
|
95
|
+
"""Convert a linear ratio to dB: 10*log10(linear)."""
|
|
96
|
+
if linear <= 0:
|
|
97
|
+
raise PhysicalViolationError(
|
|
98
|
+
message=f"Linear value must be positive for dB conversion, got {linear}",
|
|
99
|
+
law_violated="Mathematical Constraint",
|
|
100
|
+
latex_explanation=r"$\text{dB} = 10 \log_{10}(x)$ requires $x > 0$",
|
|
101
|
+
claimed_value=linear,
|
|
102
|
+
)
|
|
103
|
+
return 10.0 * math.log10(linear)
|
physbound/errors.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""PhysBound error types with LaTeX-formatted physics violation explanations."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class PhysicalViolationError(Exception):
|
|
8
|
+
"""Raised when an input or claim violates a physical law or hard limit.
|
|
9
|
+
|
|
10
|
+
Always carries a LaTeX explanation suitable for rendering in technical documents.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
message: str
|
|
14
|
+
law_violated: str
|
|
15
|
+
latex_explanation: str
|
|
16
|
+
computed_limit: float | None = None
|
|
17
|
+
claimed_value: float | None = None
|
|
18
|
+
unit: str = ""
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
return f"[{self.law_violated}] {self.message}"
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
"""Serialize for JSON MCP response."""
|
|
25
|
+
return {
|
|
26
|
+
"error": True,
|
|
27
|
+
"violation_type": "PhysicalViolationError",
|
|
28
|
+
"law_violated": self.law_violated,
|
|
29
|
+
"message": self.message,
|
|
30
|
+
"latex": self.latex_explanation,
|
|
31
|
+
"computed_limit": self.computed_limit,
|
|
32
|
+
"claimed_value": self.claimed_value,
|
|
33
|
+
"unit": self.unit,
|
|
34
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared Pydantic models for PhysBound MCP tool responses."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PhysBoundResult(BaseModel):
|
|
7
|
+
"""Base output envelope for all PhysBound tool responses."""
|
|
8
|
+
|
|
9
|
+
human_readable: str
|
|
10
|
+
latex: str
|
|
11
|
+
warnings: list[str] = []
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Pydantic models for the RF Link Budget tool."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from physbound.models.common import PhysBoundResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LinkBudgetInput(BaseModel):
|
|
9
|
+
"""Input parameters for RF link budget calculation using Friis transmission equation."""
|
|
10
|
+
|
|
11
|
+
tx_power_dbm: float = Field(description="Transmit power in dBm")
|
|
12
|
+
tx_antenna_gain_dbi: float = Field(description="Transmit antenna gain in dBi")
|
|
13
|
+
rx_antenna_gain_dbi: float = Field(description="Receive antenna gain in dBi")
|
|
14
|
+
frequency_hz: float = Field(gt=0, description="Carrier frequency in Hz")
|
|
15
|
+
distance_m: float = Field(gt=0, description="Link distance in meters")
|
|
16
|
+
tx_losses_db: float = Field(default=0.0, description="TX-side miscellaneous losses in dB")
|
|
17
|
+
rx_losses_db: float = Field(default=0.0, description="RX-side miscellaneous losses in dB")
|
|
18
|
+
tx_antenna_diameter_m: float | None = Field(
|
|
19
|
+
default=None, description="TX antenna diameter in meters (enables aperture limit check)"
|
|
20
|
+
)
|
|
21
|
+
rx_antenna_diameter_m: float | None = Field(
|
|
22
|
+
default=None, description="RX antenna diameter in meters (enables aperture limit check)"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LinkBudgetOutput(PhysBoundResult):
|
|
27
|
+
"""Output of the RF link budget calculation."""
|
|
28
|
+
|
|
29
|
+
fspl_db: float
|
|
30
|
+
received_power_dbm: float
|
|
31
|
+
wavelength_m: float
|
|
32
|
+
tx_aperture_limit_dbi: float | None = None
|
|
33
|
+
rx_aperture_limit_dbi: float | None = None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pydantic models for the Thermal Noise Floor tool."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
from physbound.models.common import PhysBoundResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NoiseStage(BaseModel):
|
|
9
|
+
"""A single amplifier/component stage for Friis noise figure cascading."""
|
|
10
|
+
|
|
11
|
+
gain_db: float = Field(description="Stage gain in dB")
|
|
12
|
+
noise_figure_db: float = Field(ge=0, description="Stage noise figure in dB")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NoiseFloorInput(BaseModel):
|
|
16
|
+
"""Input parameters for thermal noise floor and receiver sensitivity calculation."""
|
|
17
|
+
|
|
18
|
+
bandwidth_hz: float = Field(gt=0, description="Receiver bandwidth in Hz")
|
|
19
|
+
temperature_k: float = Field(
|
|
20
|
+
default=290.0, ge=0, description="System noise temperature in Kelvin (default: 290K)"
|
|
21
|
+
)
|
|
22
|
+
stages: list[NoiseStage] | None = Field(
|
|
23
|
+
default=None, description="Optional cascade of amplifier stages for Friis noise formula"
|
|
24
|
+
)
|
|
25
|
+
required_snr_db: float | None = Field(
|
|
26
|
+
default=None, description="Required SNR in dB for receiver sensitivity calculation"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NoiseFloorOutput(PhysBoundResult):
|
|
31
|
+
"""Output of thermal noise floor and sensitivity calculation."""
|
|
32
|
+
|
|
33
|
+
thermal_noise_dbm: float
|
|
34
|
+
thermal_noise_watts: float
|
|
35
|
+
cascaded_noise_figure_db: float | None = None
|
|
36
|
+
system_noise_temp_k: float | None = None
|
|
37
|
+
receiver_sensitivity_dbm: float | None = None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Pydantic models for the Shannon-Hartley Channel Capacity tool."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field, model_validator
|
|
4
|
+
|
|
5
|
+
from physbound.models.common import PhysBoundResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ShannonInput(BaseModel):
|
|
9
|
+
"""Input parameters for Shannon-Hartley channel capacity validation."""
|
|
10
|
+
|
|
11
|
+
bandwidth_hz: float = Field(gt=0, description="Channel bandwidth in Hz")
|
|
12
|
+
snr_linear: float | None = Field(
|
|
13
|
+
default=None, description="Signal-to-noise ratio (linear, not dB)"
|
|
14
|
+
)
|
|
15
|
+
snr_db: float | None = Field(
|
|
16
|
+
default=None, description="Signal-to-noise ratio in dB (alternative to snr_linear)"
|
|
17
|
+
)
|
|
18
|
+
claimed_throughput_bps: float | None = Field(
|
|
19
|
+
default=None, description="Throughput claim to validate in bits per second"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
@model_validator(mode="after")
|
|
23
|
+
def exactly_one_snr(self):
|
|
24
|
+
if self.snr_linear is None and self.snr_db is None:
|
|
25
|
+
raise ValueError("Exactly one of snr_linear or snr_db must be provided")
|
|
26
|
+
if self.snr_linear is not None and self.snr_db is not None:
|
|
27
|
+
raise ValueError("Provide only one of snr_linear or snr_db, not both")
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ShannonOutput(PhysBoundResult):
|
|
32
|
+
"""Output of Shannon-Hartley channel capacity calculation."""
|
|
33
|
+
|
|
34
|
+
capacity_bps: float
|
|
35
|
+
spectral_efficiency_bps_hz: float
|
|
36
|
+
snr_db: float
|
|
37
|
+
snr_linear: float
|
|
38
|
+
claimed_throughput_bps: float | None = None
|
|
39
|
+
claim_is_valid: bool | None = None
|
|
40
|
+
excess_percentage: float | None = None
|
physbound/server.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""PhysBound MCP Server — Physical Layer Linter for AI hallucination detection.
|
|
2
|
+
|
|
3
|
+
Exposes three RF validation tools via the Model Context Protocol (MCP):
|
|
4
|
+
1. rf_link_budget — Friis transmission link budget with aperture limit checks
|
|
5
|
+
2. shannon_hartley — Shannon-Hartley channel capacity and throughput validation
|
|
6
|
+
3. noise_floor — Thermal noise, Friis noise cascade, and receiver sensitivity
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
|
|
11
|
+
from physbound.engines import link_budget as lb_engine
|
|
12
|
+
from physbound.engines import noise as nz_engine
|
|
13
|
+
from physbound.engines import shannon as sh_engine
|
|
14
|
+
from physbound.engines.units import db_to_linear, linear_to_db
|
|
15
|
+
from physbound.errors import PhysicalViolationError
|
|
16
|
+
from physbound.models.link_budget import LinkBudgetInput, LinkBudgetOutput
|
|
17
|
+
from physbound.models.noise import NoiseFloorInput, NoiseFloorOutput
|
|
18
|
+
from physbound.models.shannon import ShannonInput, ShannonOutput
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP(
|
|
21
|
+
name="PhysBound",
|
|
22
|
+
instructions=(
|
|
23
|
+
"Physics validation MCP server. Validates RF link budgets, "
|
|
24
|
+
"Shannon-Hartley channel capacity claims, and thermal noise calculations "
|
|
25
|
+
"against hard physical limits. Catches AI hallucinations in physics."
|
|
26
|
+
),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool
|
|
31
|
+
def rf_link_budget(
|
|
32
|
+
tx_power_dbm: float,
|
|
33
|
+
tx_antenna_gain_dbi: float,
|
|
34
|
+
rx_antenna_gain_dbi: float,
|
|
35
|
+
frequency_hz: float,
|
|
36
|
+
distance_m: float,
|
|
37
|
+
tx_losses_db: float = 0.0,
|
|
38
|
+
rx_losses_db: float = 0.0,
|
|
39
|
+
tx_antenna_diameter_m: float | None = None,
|
|
40
|
+
rx_antenna_diameter_m: float | None = None,
|
|
41
|
+
) -> dict:
|
|
42
|
+
"""Calculate a complete RF link budget using the Friis transmission equation.
|
|
43
|
+
|
|
44
|
+
Computes free-space path loss (FSPL), received power, and validates antenna
|
|
45
|
+
gains against aperture limits (G_max = eta * (pi*D/lambda)^2). Rejects any
|
|
46
|
+
configuration that implies physically impossible antenna performance.
|
|
47
|
+
|
|
48
|
+
Use this tool when you need to:
|
|
49
|
+
- Estimate received signal strength for a wireless link
|
|
50
|
+
- Validate whether a claimed link budget is physically achievable
|
|
51
|
+
- Check if antenna gain claims are consistent with antenna dimensions
|
|
52
|
+
- Compute free-space path loss at a given frequency and distance
|
|
53
|
+
|
|
54
|
+
Returns both human-readable summary and machine-readable JSON with all
|
|
55
|
+
intermediate values. Returns a PhysicalViolationError dict if any input
|
|
56
|
+
violates physics.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
tx_power_dbm: Transmit power in dBm
|
|
60
|
+
tx_antenna_gain_dbi: Transmit antenna gain in dBi
|
|
61
|
+
rx_antenna_gain_dbi: Receive antenna gain in dBi
|
|
62
|
+
frequency_hz: Carrier frequency in Hz (must be > 0)
|
|
63
|
+
distance_m: Link distance in meters (must be > 0)
|
|
64
|
+
tx_losses_db: TX-side miscellaneous losses in dB (default: 0)
|
|
65
|
+
rx_losses_db: RX-side miscellaneous losses in dB (default: 0)
|
|
66
|
+
tx_antenna_diameter_m: TX antenna diameter in meters (enables aperture check)
|
|
67
|
+
rx_antenna_diameter_m: RX antenna diameter in meters (enables aperture check)
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
result = lb_engine.compute_link_budget(
|
|
71
|
+
tx_power_dbm=tx_power_dbm,
|
|
72
|
+
tx_antenna_gain_dbi=tx_antenna_gain_dbi,
|
|
73
|
+
rx_antenna_gain_dbi=rx_antenna_gain_dbi,
|
|
74
|
+
frequency_hz=frequency_hz,
|
|
75
|
+
distance_m=distance_m,
|
|
76
|
+
tx_losses_db=tx_losses_db,
|
|
77
|
+
rx_losses_db=rx_losses_db,
|
|
78
|
+
tx_antenna_diameter_m=tx_antenna_diameter_m,
|
|
79
|
+
rx_antenna_diameter_m=rx_antenna_diameter_m,
|
|
80
|
+
)
|
|
81
|
+
return LinkBudgetOutput(**result).model_dump()
|
|
82
|
+
except PhysicalViolationError as e:
|
|
83
|
+
return e.to_dict()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@mcp.tool
|
|
87
|
+
def shannon_hartley(
|
|
88
|
+
bandwidth_hz: float,
|
|
89
|
+
snr_linear: float | None = None,
|
|
90
|
+
snr_db: float | None = None,
|
|
91
|
+
claimed_throughput_bps: float | None = None,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""Calculate Shannon-Hartley channel capacity and validate throughput claims.
|
|
94
|
+
|
|
95
|
+
Computes the theoretical maximum data rate C = B * log2(1 + SNR) for an AWGN
|
|
96
|
+
channel. If a claimed throughput is provided, validates it against this limit.
|
|
97
|
+
Any claim exceeding the Shannon limit is a physical impossibility.
|
|
98
|
+
|
|
99
|
+
Use this tool when you need to:
|
|
100
|
+
- Calculate maximum achievable throughput for a given bandwidth and SNR
|
|
101
|
+
- Validate whether a throughput claim is physically possible
|
|
102
|
+
- Determine spectral efficiency limits
|
|
103
|
+
- Check if a modulation/coding scheme claim is realistic
|
|
104
|
+
|
|
105
|
+
Returns a PhysicalViolationError dict when a claim exceeds the Shannon limit.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
bandwidth_hz: Channel bandwidth in Hz (must be > 0)
|
|
109
|
+
snr_linear: Signal-to-noise ratio (linear, not dB). Provide this OR snr_db.
|
|
110
|
+
snr_db: Signal-to-noise ratio in dB. Provide this OR snr_linear.
|
|
111
|
+
claimed_throughput_bps: Optional throughput claim to validate in bits/sec
|
|
112
|
+
"""
|
|
113
|
+
try:
|
|
114
|
+
# Validate and resolve SNR
|
|
115
|
+
params = ShannonInput(
|
|
116
|
+
bandwidth_hz=bandwidth_hz,
|
|
117
|
+
snr_linear=snr_linear,
|
|
118
|
+
snr_db=snr_db,
|
|
119
|
+
claimed_throughput_bps=claimed_throughput_bps,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Resolve SNR to both representations
|
|
123
|
+
if params.snr_db is not None:
|
|
124
|
+
resolved_snr_linear = db_to_linear(params.snr_db)
|
|
125
|
+
resolved_snr_db = params.snr_db
|
|
126
|
+
else:
|
|
127
|
+
resolved_snr_linear = params.snr_linear
|
|
128
|
+
resolved_snr_db = linear_to_db(params.snr_linear)
|
|
129
|
+
|
|
130
|
+
capacity = sh_engine.channel_capacity_bps(params.bandwidth_hz, resolved_snr_linear)
|
|
131
|
+
eta = sh_engine.spectral_efficiency(resolved_snr_linear)
|
|
132
|
+
|
|
133
|
+
# If throughput claim provided, validate it
|
|
134
|
+
claim_is_valid = None
|
|
135
|
+
excess_percentage = None
|
|
136
|
+
warnings = []
|
|
137
|
+
|
|
138
|
+
if params.claimed_throughput_bps is not None:
|
|
139
|
+
result = sh_engine.validate_throughput_claim(
|
|
140
|
+
params.bandwidth_hz, resolved_snr_linear, params.claimed_throughput_bps
|
|
141
|
+
)
|
|
142
|
+
claim_is_valid = result["claim_is_valid"]
|
|
143
|
+
excess_percentage = result["excess_percentage"]
|
|
144
|
+
warnings = result["warnings"]
|
|
145
|
+
|
|
146
|
+
human_readable = (
|
|
147
|
+
f"Shannon-Hartley Capacity:\n"
|
|
148
|
+
f" Bandwidth: {params.bandwidth_hz / 1e6:.3f} MHz\n"
|
|
149
|
+
f" SNR: {resolved_snr_db:.1f} dB ({resolved_snr_linear:.2f} linear)\n"
|
|
150
|
+
f" Capacity: {capacity:.1f} bps ({capacity / 1e6:.3f} Mbps)\n"
|
|
151
|
+
f" Spectral Efficiency: {eta:.3f} bps/Hz"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
latex = (
|
|
155
|
+
rf"$C = B \log_2(1 + \text{{SNR}}) = "
|
|
156
|
+
rf"{params.bandwidth_hz:.0f} \times \log_2(1 + {resolved_snr_linear:.2f}) = "
|
|
157
|
+
rf"{capacity:.1f}\,\text{{bps}}$"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return ShannonOutput(
|
|
161
|
+
capacity_bps=capacity,
|
|
162
|
+
spectral_efficiency_bps_hz=eta,
|
|
163
|
+
snr_db=resolved_snr_db,
|
|
164
|
+
snr_linear=resolved_snr_linear,
|
|
165
|
+
claimed_throughput_bps=params.claimed_throughput_bps,
|
|
166
|
+
claim_is_valid=claim_is_valid,
|
|
167
|
+
excess_percentage=excess_percentage,
|
|
168
|
+
human_readable=human_readable,
|
|
169
|
+
latex=latex,
|
|
170
|
+
warnings=warnings,
|
|
171
|
+
).model_dump()
|
|
172
|
+
|
|
173
|
+
except PhysicalViolationError as e:
|
|
174
|
+
return e.to_dict()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@mcp.tool
|
|
178
|
+
def noise_floor(
|
|
179
|
+
bandwidth_hz: float,
|
|
180
|
+
temperature_k: float = 290.0,
|
|
181
|
+
stages: list[dict] | None = None,
|
|
182
|
+
required_snr_db: float | None = None,
|
|
183
|
+
) -> dict:
|
|
184
|
+
"""Calculate thermal noise power (kTB), cascaded noise figure, and receiver sensitivity.
|
|
185
|
+
|
|
186
|
+
Computes the fundamental thermal noise floor N = k_B * T * B, which is
|
|
187
|
+
-174 dBm/Hz at the IEEE standard temperature of 290K. Optionally cascades
|
|
188
|
+
multiple amplifier/filter stages using the Friis noise figure formula
|
|
189
|
+
F_total = F_1 + (F_2-1)/G_1 + (F_3-1)/(G_1*G_2) + ... and computes
|
|
190
|
+
receiver sensitivity as S_min = N_floor + NF + SNR_required.
|
|
191
|
+
|
|
192
|
+
Use this tool when you need to:
|
|
193
|
+
- Determine the thermal noise floor for a receiver bandwidth
|
|
194
|
+
- Cascade noise figures through a multi-stage receiver chain
|
|
195
|
+
- Calculate minimum detectable signal / receiver sensitivity
|
|
196
|
+
- Validate that a claimed noise figure is physically plausible
|
|
197
|
+
|
|
198
|
+
Returns a PhysicalViolationError dict if inputs violate thermodynamic limits.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
bandwidth_hz: Receiver bandwidth in Hz (must be > 0)
|
|
202
|
+
temperature_k: System noise temperature in Kelvin (default: 290K, must be >= 0)
|
|
203
|
+
stages: Optional list of stages, each with 'gain_db' and 'noise_figure_db' keys
|
|
204
|
+
required_snr_db: Required SNR in dB for sensitivity calculation
|
|
205
|
+
"""
|
|
206
|
+
try:
|
|
207
|
+
params = NoiseFloorInput(
|
|
208
|
+
bandwidth_hz=bandwidth_hz,
|
|
209
|
+
temperature_k=temperature_k,
|
|
210
|
+
stages=[{"gain_db": s["gain_db"], "noise_figure_db": s["noise_figure_db"]}
|
|
211
|
+
for s in stages] if stages else None,
|
|
212
|
+
required_snr_db=required_snr_db,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
n_dbm = nz_engine.thermal_noise_power_dbm(params.bandwidth_hz, params.temperature_k)
|
|
216
|
+
n_watts = nz_engine.thermal_noise_power_watts(params.bandwidth_hz, params.temperature_k)
|
|
217
|
+
|
|
218
|
+
warnings = []
|
|
219
|
+
cascaded_nf_db = None
|
|
220
|
+
system_noise_temp_k = None
|
|
221
|
+
sensitivity_dbm = None
|
|
222
|
+
|
|
223
|
+
# Friis noise cascade if stages provided
|
|
224
|
+
if params.stages:
|
|
225
|
+
stage_tuples = [(s.gain_db, s.noise_figure_db) for s in params.stages]
|
|
226
|
+
cascaded_nf_db = nz_engine.friis_noise_cascade(stage_tuples)
|
|
227
|
+
# System noise temperature: T_sys = T_ref * (F - 1)
|
|
228
|
+
f_linear = db_to_linear(cascaded_nf_db)
|
|
229
|
+
system_noise_temp_k = params.temperature_k * (f_linear - 1)
|
|
230
|
+
|
|
231
|
+
# Receiver sensitivity
|
|
232
|
+
if params.required_snr_db is not None:
|
|
233
|
+
nf = cascaded_nf_db if cascaded_nf_db is not None else 0.0
|
|
234
|
+
sensitivity_dbm = nz_engine.receiver_sensitivity_dbm(
|
|
235
|
+
params.bandwidth_hz, nf, params.required_snr_db, params.temperature_k
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
human_readable = (
|
|
239
|
+
f"Thermal Noise Floor:\n"
|
|
240
|
+
f" Temperature: {params.temperature_k:.1f} K\n"
|
|
241
|
+
f" Bandwidth: {params.bandwidth_hz / 1e6:.3f} MHz\n"
|
|
242
|
+
f" Noise Power: {n_dbm:.2f} dBm ({n_watts:.3e} W)"
|
|
243
|
+
)
|
|
244
|
+
if cascaded_nf_db is not None:
|
|
245
|
+
human_readable += f"\n Cascaded NF: {cascaded_nf_db:.2f} dB"
|
|
246
|
+
if sensitivity_dbm is not None:
|
|
247
|
+
human_readable += f"\n Sensitivity: {sensitivity_dbm:.2f} dBm"
|
|
248
|
+
|
|
249
|
+
latex = (
|
|
250
|
+
rf"$N = k_B T B = {BOLTZMANN_VAL:.4e} \times {params.temperature_k:.1f} "
|
|
251
|
+
rf"\times {params.bandwidth_hz:.0f} = {n_dbm:.2f}\,\text{{dBm}}$"
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return NoiseFloorOutput(
|
|
255
|
+
thermal_noise_dbm=n_dbm,
|
|
256
|
+
thermal_noise_watts=n_watts,
|
|
257
|
+
cascaded_noise_figure_db=cascaded_nf_db,
|
|
258
|
+
system_noise_temp_k=system_noise_temp_k,
|
|
259
|
+
receiver_sensitivity_dbm=sensitivity_dbm,
|
|
260
|
+
human_readable=human_readable,
|
|
261
|
+
latex=latex,
|
|
262
|
+
warnings=warnings,
|
|
263
|
+
).model_dump()
|
|
264
|
+
|
|
265
|
+
except PhysicalViolationError as e:
|
|
266
|
+
return e.to_dict()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# Constant for LaTeX rendering
|
|
270
|
+
BOLTZMANN_VAL = 1.380649e-23
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main():
|
|
274
|
+
"""Entry point for `physbound` console script and stdio MCP."""
|
|
275
|
+
mcp.run(transport="stdio")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
if __name__ == "__main__":
|
|
279
|
+
main()
|
physbound/validators.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Hard-limit guard functions enforcing physical laws.
|
|
2
|
+
|
|
3
|
+
These validators are called by engine modules before computation to reject
|
|
4
|
+
inputs that would violate fundamental physics. Each raises PhysicalViolationError
|
|
5
|
+
with a LaTeX explanation on failure.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from physbound.errors import PhysicalViolationError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def validate_positive_frequency(frequency_hz: float) -> None:
|
|
12
|
+
"""Reject non-positive frequencies."""
|
|
13
|
+
if frequency_hz <= 0:
|
|
14
|
+
raise PhysicalViolationError(
|
|
15
|
+
message=f"Frequency must be positive, got {frequency_hz} Hz",
|
|
16
|
+
law_violated="Electromagnetic Theory",
|
|
17
|
+
latex_explanation=r"$f > 0$ required; negative or zero frequency is non-physical",
|
|
18
|
+
claimed_value=frequency_hz,
|
|
19
|
+
unit="Hz",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def validate_positive_distance(distance_m: float) -> None:
|
|
24
|
+
"""Reject non-positive distances."""
|
|
25
|
+
if distance_m <= 0:
|
|
26
|
+
raise PhysicalViolationError(
|
|
27
|
+
message=f"Distance must be positive, got {distance_m} m",
|
|
28
|
+
law_violated="Euclidean Geometry",
|
|
29
|
+
latex_explanation=r"$d > 0$ required for physical separation between TX and RX",
|
|
30
|
+
claimed_value=distance_m,
|
|
31
|
+
unit="m",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def validate_positive_bandwidth(bandwidth_hz: float) -> None:
|
|
36
|
+
"""Reject non-positive bandwidths."""
|
|
37
|
+
if bandwidth_hz <= 0:
|
|
38
|
+
raise PhysicalViolationError(
|
|
39
|
+
message=f"Bandwidth must be positive, got {bandwidth_hz} Hz",
|
|
40
|
+
law_violated="Signal Processing",
|
|
41
|
+
latex_explanation=r"$B > 0$ required; zero or negative bandwidth is non-physical",
|
|
42
|
+
claimed_value=bandwidth_hz,
|
|
43
|
+
unit="Hz",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_temperature(temperature_k: float) -> None:
|
|
48
|
+
"""Reject negative absolute temperatures."""
|
|
49
|
+
if temperature_k < 0:
|
|
50
|
+
raise PhysicalViolationError(
|
|
51
|
+
message=f"Temperature must be >= 0 K, got {temperature_k} K",
|
|
52
|
+
law_violated="Third Law of Thermodynamics",
|
|
53
|
+
latex_explanation=(
|
|
54
|
+
r"$T \geq 0\,\text{K}$ required by the third law of thermodynamics; "
|
|
55
|
+
r"negative absolute temperature is non-physical in this context"
|
|
56
|
+
),
|
|
57
|
+
claimed_value=temperature_k,
|
|
58
|
+
unit="K",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def validate_positive_snr(snr_linear: float) -> None:
|
|
63
|
+
"""Reject non-positive linear SNR values."""
|
|
64
|
+
if snr_linear <= 0:
|
|
65
|
+
raise PhysicalViolationError(
|
|
66
|
+
message=f"SNR (linear) must be positive, got {snr_linear}",
|
|
67
|
+
law_violated="Information Theory",
|
|
68
|
+
latex_explanation=r"$\text{SNR} > 0$ required; signal power cannot be non-positive",
|
|
69
|
+
claimed_value=snr_linear,
|
|
70
|
+
)
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: physbound
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Physical Layer Linter — validates RF and physics calculations against hard physical limits
|
|
5
|
+
Project-URL: Homepage, https://github.com/JonesRobM/physbound
|
|
6
|
+
Project-URL: Repository, https://github.com/JonesRobM/physbound
|
|
7
|
+
Project-URL: Issues, https://github.com/JonesRobM/physbound/issues
|
|
8
|
+
Author-email: Robert Jones <jonesrobm@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Intended Audience :: Telecommunications Industry
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
17
|
+
Requires-Python: >=3.12
|
|
18
|
+
Requires-Dist: fastmcp==2.14.5
|
|
19
|
+
Requires-Dist: numpy==2.2.3
|
|
20
|
+
Requires-Dist: pint==0.25.2
|
|
21
|
+
Requires-Dist: pydantic<3,>=2.10
|
|
22
|
+
Requires-Dist: scipy==1.15.2
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: mypy==1.15.0; extra == 'dev'
|
|
25
|
+
Requires-Dist: pre-commit==4.1.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov==6.0.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest==8.3.5; extra == 'dev'
|
|
28
|
+
Requires-Dist: python-dotenv>=1.1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff==0.9.6; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# PhysBound
|
|
33
|
+
|
|
34
|
+
**Physical Layer Linter** — An MCP server that validates RF and physics calculations against hard physical limits. Catches AI hallucinations in engineering workflows.
|
|
35
|
+
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://www.python.org/downloads/)
|
|
38
|
+
[]()
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## What LLMs Get Wrong
|
|
43
|
+
|
|
44
|
+
LLMs routinely hallucinate physics. PhysBound catches it:
|
|
45
|
+
|
|
46
|
+
| # | Category | LLM Hallucination | PhysBound Truth | Verdict |
|
|
47
|
+
|---|----------|-------------------|-----------------|---------|
|
|
48
|
+
| 1 | Shannon-Hartley | "20 MHz 802.11n at 15 dB SNR achieves 500 Mbps" | Shannon limit: **100.6 Mbps** | CAUGHT |
|
|
49
|
+
| 2 | Shannon-Hartley | "100 MHz 5G channel at 20 dB SNR delivers 2 Gbps" | Shannon limit: **665.8 Mbps** | CAUGHT |
|
|
50
|
+
| 3 | Antenna Aperture | "30 cm dish at 1 GHz provides 45 dBi gain" | Aperture limit: **7.4 dBi** | CAUGHT |
|
|
51
|
+
| 4 | Thermal Noise | "Noise floor of -180 dBm/Hz at room temperature" | Actual: **-174.0 dBm/Hz** at 290K | CAUGHT |
|
|
52
|
+
| 5 | Link Budget | "Wi-Fi at 2.4 GHz reaches 10 km at -40 dBm" | Actual RX power: **-94.1 dBm** | CAUGHT |
|
|
53
|
+
| 6 | Link Budget | "1W to GEO with 0 dBi antennas at -80 dBm" | Actual RX power: **-175.1 dBm** | CAUGHT |
|
|
54
|
+
|
|
55
|
+
*Generated automatically by `pytest tests/test_marketing.py -s`*
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### Install
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
pip install physbound
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Use with Claude Desktop
|
|
68
|
+
|
|
69
|
+
Add to your `claude_desktop_config.json`:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"physbound": {
|
|
75
|
+
"command": "uv",
|
|
76
|
+
"args": ["run", "--from", "physbound", "physbound"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
That's it. Claude now has access to physics-validated RF calculations.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Tools
|
|
87
|
+
|
|
88
|
+
### `rf_link_budget`
|
|
89
|
+
|
|
90
|
+
Computes a full RF link budget using the Friis transmission equation. Validates antenna gains against aperture limits.
|
|
91
|
+
|
|
92
|
+
**Example:** *"What's the received power for a 2.4 GHz link at 100 m with 20 dBm TX, 10 dBi TX gain, 3 dBi RX gain?"*
|
|
93
|
+
|
|
94
|
+
Returns: FSPL, received power, wavelength, and optional aperture limit checks. Rejects antenna gains that violate `G_max = eta * (pi * D / lambda)^2`.
|
|
95
|
+
|
|
96
|
+
### `shannon_hartley`
|
|
97
|
+
|
|
98
|
+
Computes Shannon-Hartley channel capacity `C = B * log2(1 + SNR)` and validates throughput claims.
|
|
99
|
+
|
|
100
|
+
**Example:** *"Can a 20 MHz channel with 15 dB SNR support 500 Mbps?"*
|
|
101
|
+
|
|
102
|
+
Returns: Theoretical capacity, spectral efficiency, and whether the claim is physically possible. Flags violations with the exact percentage by which the claim exceeds the Shannon limit.
|
|
103
|
+
|
|
104
|
+
### `noise_floor`
|
|
105
|
+
|
|
106
|
+
Computes thermal noise power `N = k_B * T * B`, cascades noise figures through multi-stage receivers using the Friis noise formula, and calculates receiver sensitivity.
|
|
107
|
+
|
|
108
|
+
**Example:** *"What's the noise floor for a 1 MHz receiver at 290K with a two-stage LNA chain?"*
|
|
109
|
+
|
|
110
|
+
Returns: Thermal noise in dBm and watts, cascaded noise figure, system noise temperature, and receiver sensitivity.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Physics Guarantees
|
|
115
|
+
|
|
116
|
+
Every calculation is validated against hard physical limits:
|
|
117
|
+
|
|
118
|
+
- **Speed of light:** `c = 299,792,458 m/s` — no exceptions
|
|
119
|
+
- **Thermal noise floor:** `N = -174 dBm/Hz` at 290K — the IEEE standard reference
|
|
120
|
+
- **Shannon limit:** `C = B * log2(1 + SNR)` — no throughput claim exceeds this
|
|
121
|
+
- **Aperture limit:** `G_max = eta * (pi * D / lambda)^2` — antenna gain is bounded by physics
|
|
122
|
+
|
|
123
|
+
Violations return structured `PhysicalViolationError` responses with LaTeX explanations, not silent failures.
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Development
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# Clone and install
|
|
131
|
+
git clone https://github.com/JonesRobM/physbound.git
|
|
132
|
+
cd physbound
|
|
133
|
+
uv sync --all-extras
|
|
134
|
+
|
|
135
|
+
# Run tests
|
|
136
|
+
uv run pytest tests/ -v
|
|
137
|
+
|
|
138
|
+
# Print hallucination delta table
|
|
139
|
+
uv run pytest tests/test_marketing.py -s
|
|
140
|
+
|
|
141
|
+
# Start MCP server locally
|
|
142
|
+
uv run physbound
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT License. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
physbound/__init__.py,sha256=95SB7Cb_K3ZjQDu6wp0UE8ofrvCaW59zh1SOq_GIg0s,97
|
|
2
|
+
physbound/errors.py,sha256=bnWVIJAY-KrypBS0X3gUt6LInCPsj_pHn-sUgoTZvYo,1041
|
|
3
|
+
physbound/server.py,sha256=Q9MdPPK0xxMcYAlnOc5E4OoCkXF2ejq9ixituW44a80,11187
|
|
4
|
+
physbound/validators.py,sha256=q5JJutOxznBlSo4vW4fAHNrWWVsZHn0doqCZ9qv-Tg4,2692
|
|
5
|
+
physbound/engines/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
physbound/engines/constants.py,sha256=YFjFdCZ_D3aPuYmjToT_Oxb35fVZjkTEbGz4jXXIkmM,928
|
|
7
|
+
physbound/engines/link_budget.py,sha256=FczE30W0FV6nxVsbkgDUfYBbKF15CqJUUsA9VMesCf4,7599
|
|
8
|
+
physbound/engines/noise.py,sha256=Da27RZrxpSpBWnC-n0NJHqCMX9jGzK69tl-QHXfbfYc,4088
|
|
9
|
+
physbound/engines/shannon.py,sha256=CZoTN19JcDbR5jFHpo772R4OfxT_N4nrHVqznX4od28,3620
|
|
10
|
+
physbound/engines/units.py,sha256=ddAZLCFS1qHSsRHhJSaoMFQWuXixkDP7-Dx1dyHILKA,3478
|
|
11
|
+
physbound/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
physbound/models/common.py,sha256=66VWbzqSM1b0XxVIiIMa-0ojD8lrR9uiM1vpCMnybuA,265
|
|
13
|
+
physbound/models/link_budget.py,sha256=3cBiWAWtoZVvCLVhHGycaqK08Le_DSkHZ8YC3I7EMEQ,1416
|
|
14
|
+
physbound/models/noise.py,sha256=PZ4clSzFsJ5omA2ZbgxiY1l3tBLahgSs3DRKTv3f_9Q,1344
|
|
15
|
+
physbound/models/shannon.py,sha256=ZVC7O35LKOrohiyTwlt-Neo39XGFw2l326Xlc4yaS2M,1478
|
|
16
|
+
physbound-0.1.0.dist-info/METADATA,sha256=68U98NW3_kUEhB_PTwF4oDfHTHesOWQYwd81Pjhbxhw,5124
|
|
17
|
+
physbound-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
18
|
+
physbound-0.1.0.dist-info/entry_points.txt,sha256=nQ1ILpnBm3CqMU0Ygzmk9yt866Ki0EooQmN7DcwIp5M,52
|
|
19
|
+
physbound-0.1.0.dist-info/licenses/LICENSE,sha256=t7gUixl245pipySIQOuZ0P-TCdxmtgKQfaRmryGa7iU,1069
|
|
20
|
+
physbound-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Jones
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|