完成py_plan.md

This commit is contained in:
sinlatansen
2026-02-24 16:21:30 +08:00
parent 6c0526b2ef
commit 375febb4c0
21 changed files with 2041 additions and 4 deletions

3
sim/radio/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Radio module."""
__all__ = ["airtime", "propagation"]

170
sim/radio/airtime.py Normal file
View File

@@ -0,0 +1,170 @@
"""
LoRa Airtime Calculation.
Implements the real LoRa airtime formula for accurate simulation.
Reference: Semtech SX1276/77/78/79 Datasheet
"""
import math
from sim import config
def calculate_symbol_time(sf: int, bw: int) -> float:
"""
Calculate symbol time.
T_symbol = 2^SF / BW
Args:
sf: Spreading Factor (7-12)
bw: Bandwidth in Hz
Returns:
Symbol time in seconds
"""
return (2**sf) / bw
def calculate_payload_airtime(
payload_size: int,
sf: int,
bw: int,
cr: int,
use_header: bool = True,
low_data_rate_optimize: bool = None,
) -> float:
"""
Calculate payload airtime.
Args:
payload_size: Payload size in bytes
sf: Spreading Factor (7-12)
bw: Bandwidth in Hz
cr: Coding Rate (5-8, represents 4/5 to 4/8)
use_header: Whether packet header is present
low_data_rate_optimize: Low Data Rate Optimization flag
Set to True if SF >= 11 or BW <= 125kHz
Returns:
Payload airtime in seconds
"""
# Determine DE (Low Data Rate Optimization)
if low_data_rate_optimize is None:
# Auto-detect: DE = 1 if SF >= 11 or BW <= 125 kHz
de = 1 if (sf >= 11 or bw <= 125000) else 0
else:
de = 1 if low_data_rate_optimize else 0
# H = 0 if header is present, 1 if no header
h = 0 if use_header else 1
# Calculate number of payload symbols
# N_payload = 8 + max(ceil((8*PL - 4*SF + 28 - 16 - 20*H) / (4*(SF - 2*DE))) * (CR + 4), 0)
numerator = 8 * payload_size - 4 * sf + 28 - 16 - 20 * h
denominator = 4 * (sf - 2 * de)
if denominator <= 0:
# SF - 2*DE <= 0, use minimum
n_payload = 0
else:
n_payload = 8 + max(math.ceil(numerator / denominator) * (cr + 4), 0)
# Calculate time
symbol_time = calculate_symbol_time(sf, bw)
return n_payload * symbol_time
def calculate_preamble_airtime(sf: int, bw: int, preamble: int = None) -> float:
"""
Calculate preamble airtime.
T_preamble = (PREAMBLE + 4.25) * T_symbol
Args:
sf: Spreading Factor (7-12)
bw: Bandwidth in Hz
preamble: Number of preamble symbols (default from config)
Returns:
Preamble airtime in seconds
"""
if preamble is None:
preamble = config.PREAMBLE
symbol_time = calculate_symbol_time(sf, bw)
return (preamble + 4.25) * symbol_time
def calculate_packet_airtime(
payload_size: int,
sf: int = None,
bw: int = None,
cr: int = None,
preamble: int = None,
) -> float:
"""
Calculate total packet airtime.
T_packet = T_preamble + T_payload
Args:
payload_size: Payload size in bytes
sf: Spreading Factor (default from config)
bw: Bandwidth in Hz (default from config)
cr: Coding Rate (default from config)
preamble: Number of preamble symbols (default from config)
Returns:
Total packet airtime in seconds
"""
if sf is None:
sf = config.SF
if bw is None:
bw = config.BW
if cr is None:
cr = config.CR
preamble_time = calculate_preamble_airtime(sf, bw, preamble)
payload_time = calculate_payload_airtime(payload_size, sf, bw, cr)
return preamble_time + payload_time
def calculate_ack_time(ack_seq: int = 1) -> float:
"""
Calculate ACK packet airtime.
ACK packet structure:
- 1 byte for type
- 1 byte for seq
- 1 byte for dst
- Total: 3 bytes (minimal)
Args:
ack_seq: ACK sequence number (affects total size)
Returns:
ACK airtime in seconds
"""
# ACK = type(1) + seq(1) + dst(1) + reserved(1) = 4 bytes minimum
ack_size = 4
return calculate_packet_airtime(ack_size)
# Convenience function for quick calculations
def get_hello_airtime() -> float:
"""Get airtime for HELLO packet (minimal size)."""
# HELLO = type(1) + src(1) + cost(4) + seq(1) = ~7 bytes
return calculate_packet_airtime(7)
def get_data_airtime(payload_size: int = 16) -> float:
"""Get airtime for DATA packet."""
# DATA = type(1) + src(1) + dst(1) + seq(2) + hop(1) + payload(n)
base_size = 6
return calculate_packet_airtime(base_size + payload_size)
def get_ack_airtime() -> float:
"""Get airtime for ACK packet."""
return calculate_ack_time()

259
sim/radio/channel.py Normal file
View File

@@ -0,0 +1,259 @@
"""
Wireless Channel Model.
Implements:
- Broadcast propagation to all nodes in range
- Airtime occupation tracking
- Collision detection (time overlap + |RSSI1 - RSSI2| < 6 dB)
"""
import simpy
from typing import Dict, List, Optional, Callable
from dataclasses import dataclass, field
from sim.core.packet import Packet
from sim.radio import propagation, airtime as airtime_calc
@dataclass
class Transmission:
"""Represents an ongoing transmission on the channel."""
packet: Packet
sender_id: int
start_time: float
end_time: float
rssi: float
channel_busy_until: float
@dataclass
class ReceivedPacket:
"""Represents a packet received by a node."""
packet: Packet
sender_id: int
rssi: float
rx_time: float
collision: bool = False
class Channel:
"""
Wireless channel with collision detection.
Manages:
- Transmissions and their time slots
- Collision detection based on time overlap and RSSI difference
- Packet delivery to nodes within range
"""
COLLISION_RSSI_DIFF_DB = 6.0 # RSSI difference threshold for collision
def __init__(self, env: simpy.Environment):
"""
Initialize channel.
Args:
env: SimPy environment
"""
self.env = env
self.transmissions: List[Transmission] = []
self.collision_count = 0
# Callback for packet reception (set by node manager)
self.receive_callback: Optional[Callable[[int, ReceivedPacket], None]] = None
# Node positions {node_id: (x, y)}
self.node_positions: Dict[int, tuple] = {}
def register_node(self, node_id: int, x: float, y: float):
"""Register node position for propagation calculation."""
self.node_positions[node_id] = (x, y)
def transmit(self, packet: Packet, sender_id: int) -> float:
"""
Transmit a packet on the channel.
Args:
packet: Packet to transmit
sender_id: ID of sending node
Returns:
Airtime in seconds
"""
# Calculate packet size and airtime
if packet.is_hello:
pkt_airtime = airtime_calc.get_hello_airtime()
elif packet.is_ack:
pkt_airtime = airtime_calc.get_ack_airtime()
else: # DATA
payload_size = len(packet.payload) if packet.payload else 16
pkt_airtime = airtime_calc.get_data_airtime(payload_size)
start_time = self.env.now
end_time = start_time + pkt_airtime
# Get sender position and calculate RSSI for each potential receiver
sender_pos = self.node_positions.get(sender_id)
# Check for collisions with ongoing transmissions
colliding = self._check_collision(start_time, end_time)
# Create transmission record
if sender_pos:
# Calculate RSSI at sender's own position (not really used)
tx_power = packet.rssi if packet.rssi else 14.0
sender_rssi = tx_power # At transmitter, RSSI = TX power
else:
sender_rssi = 14.0
transmission = Transmission(
packet=packet,
sender_id=sender_id,
start_time=start_time,
end_time=end_time,
rssi=sender_rssi,
channel_busy_until=end_time,
)
if colliding:
self.collision_count += 1
# All packets involved in collision are dropped
# Don't deliver to receivers
else:
self.transmissions.append(transmission)
# Deliver to all nodes in range
self._deliver_packet(packet, sender_id, start_time, end_time)
# Clean up old transmissions
self._cleanup_transmissions()
return pkt_airtime
def _check_collision(self, start_time: float, end_time: float) -> bool:
"""
Check if transmission overlaps with any ongoing transmission.
Collision condition:
- Time overlap AND
- |RSSI1 - RSSI2| < 6 dB
Args:
start_time: Start time of new transmission
end_time: End time of new transmission
Returns:
True if collision detected
"""
for trans in self.transmissions:
# Check time overlap
if not (end_time <= trans.start_time or start_time >= trans.end_time):
# Time overlaps - check RSSI difference
# Since we're checking at the sender, we assume similar RSSI
# at nearby receivers (simplified model)
# In real scenario, would need per-receiver RSSI calculation
return True
return False
def _deliver_packet(
self, packet: Packet, sender_id: int, start_time: float, end_time: float
):
"""
Deliver packet to all nodes within communication range.
Args:
packet: Packet to deliver
sender_id: ID of sending node
start_time: Transmission start time
end_time: Transmission end time
"""
if not self.receive_callback:
return
sender_pos = self.node_positions.get(sender_id)
if not sender_pos:
return
for node_id, pos in self.node_positions.items():
if node_id == sender_id:
continue # Don't deliver to sender
# Calculate distance and RSSI
dist = propagation.calculate_distance(
sender_pos[0], sender_pos[1], pos[0], pos[1]
)
rssi = propagation.calculate_rssi(14.0, dist) # Use default TX power
# Check if packet can be received
if propagation.can_receive(rssi):
# Check if this reception is affected by collision
collision = self._is_reception_collided(
node_id, start_time, end_time, rssi
)
received = ReceivedPacket(
packet=packet,
sender_id=sender_id,
rssi=rssi,
rx_time=start_time,
collision=collision,
)
# Deliver to node
self.receive_callback(node_id, received)
def _is_reception_collided(
self, receiver_id: int, start_time: float, end_time: float, signal_rssi: float
) -> bool:
"""
Check if reception is affected by collision from other transmitters.
Args:
receiver_id: ID of receiving node
start_time: Packet start time
end_time: Packet end time
signal_rssi: RSSI of the signal we want to receive
Returns:
True if collision detected
"""
for trans in self.transmissions:
if trans.sender_id == receiver_id:
continue # Ignore self-transmissions in list
# Check time overlap
if not (end_time <= trans.start_time or start_time >= trans.end_time):
# Time overlaps - calculate RSSI of interfering signal
receiver_pos = self.node_positions.get(receiver_id)
sender_pos = self.node_positions.get(trans.sender_id)
if receiver_pos and sender_pos:
dist = propagation.calculate_distance(
sender_pos[0], sender_pos[1], receiver_pos[0], receiver_pos[1]
)
interference_rssi = propagation.calculate_rssi(14.0, dist)
# Check RSSI difference
rssi_diff = abs(signal_rssi - interference_rssi)
if rssi_diff < self.COLLISION_RSSI_DIFF_DB:
return True
return False
def _cleanup_transmissions(self):
"""Remove old transmissions that have ended."""
current_time = self.env.now
self.transmissions = [
t for t in self.transmissions if t.end_time > current_time
]
def get_channel_busy_until(self) -> float:
"""Get the time until which channel is busy."""
if not self.transmissions:
return self.env.now
return max(t.channel_busy_until for t in self.transmissions)
def reset(self):
"""Reset channel state."""
self.transmissions.clear()
self.collision_count = 0

61
sim/radio/propagation.py Normal file
View File

@@ -0,0 +1,61 @@
"""Radio propagation model."""
import math
import random
from sim import config
def calculate_distance(x1: float, y1: float, x2: float, y2: float) -> float:
"""Calculate Euclidean distance between two points."""
return math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2)
def calculate_rssi(tx_power: float, distance: float) -> float:
"""
Calculate RSSI using free-space path loss model.
RSSI = TX_POWER - 10*n*log10(d) + Gaussian_noise
Args:
tx_power: Transmit power in dBm
distance: Distance in meters
Returns:
RSSI in dBm
"""
if distance < 1.0:
distance = 1.0 # Avoid log(0)
# Path loss
path_loss = 10 * config.PATH_LOSS_EXPONENT * math.log10(distance)
# Gaussian noise
noise = random.gauss(0, config.NOISE_SIGMA)
rssi = tx_power - path_loss + noise
return rssi
def can_receive(rssi: float) -> bool:
"""Check if packet can be received based on RSSI threshold."""
return rssi >= config.RSSI_THRESHOLD
def calculate_link_penalty(rssi: float) -> float:
"""
Calculate link penalty for routing.
penalty = max(0, (RSSI_THRESHOLD - RSSI) / SCALE)
Args:
rssi: Received signal strength
Returns:
Link penalty (higher = worse link)
"""
return max(0.0, (config.RSSI_THRESHOLD - rssi) / config.LINK_PENALTY_SCALE)
def is_link_viable(rssi: float) -> bool:
"""Check if link is viable for communication."""
return can_receive(rssi)