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 ADDED
@@ -0,0 +1,3 @@
1
+ """PhysBound — Physical Layer Linter for AI hallucination detection."""
2
+
3
+ __version__ = "0.1.0"
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()
@@ -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: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
37
+ [![Python 3.12+](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/)
38
+ [![Tests](https://img.shields.io/badge/tests-107%20passed-brightgreen.svg)]()
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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ physbound = physbound.server:main
@@ -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.