pamoja.lora

Idiomatic LoRa link-budget facade.

LoRa buys kilometers of range on license-free bands at tiny power, and the price is time: a transmission occupies the channel for a duration the radio settings fix, and the regional rules cap how much of the time a node may transmit. This is the arithmetic that keeps a node inside that budget, with no radio involved.

It also works out how far a link reaches: the EIRP an antenna and cable leave, the free-space loss of a path, the first Fresnel zone, the sensitivity of a receiver, and the power 47 CFR 15.247 allows through an antenna, in decibels resolved to a hundredth.

  1"""Idiomatic LoRa link-budget facade.
  2
  3LoRa buys kilometers of range on license-free bands at tiny power, and the price
  4is time: a transmission occupies the channel for a duration the radio settings
  5fix, and the regional rules cap how much of the time a node may transmit. This is
  6the arithmetic that keeps a node inside that budget, with no radio involved.
  7
  8It also works out how far a link reaches: the EIRP an antenna and cable leave, the
  9free-space loss of a path, the first Fresnel zone, the sensitivity of a receiver,
 10and the power 47 CFR 15.247 allows through an antenna, in decibels resolved to a
 11hundredth.
 12"""
 13
 14from __future__ import annotations
 15
 16from pamoja._native import (
 17    ChannelPlan,
 18    ChannelPlanBuilder,
 19    LoraBeacon,
 20    LoraChannelBlock,
 21    LinkBudget,
 22    LoraDataRate,
 23    LoraJoinPlan,
 24    LoraJoinPlanPlace,
 25    LoraLink,
 26    LoraMaskControl,
 27    LoraMaxPayload,
 28    LoraPlanInfo,
 29    LoraPlanRules,
 30    LoraRelayChannel,
 31    LoraSubBand,
 32    lora_demodulator_snr_db,
 33    lora_fcc_max_conducted_dbm,
 34    lora_free_space_loss_db,
 35    lora_fresnel_radius_mm,
 36    lora_noise_floor_dbm,
 37)
 38
 39__all__ = [
 40    "CN470_PLANS",
 41    "ChannelPlan",
 42    "ChannelPlanBuilder",
 43    "GATEWAY_NOISE_FIGURE_DB",
 44    "LinkBudget",
 45    "LoraBeacon",
 46    "LoraChannelBlock",
 47    "LoraDataRate",
 48    "LoraJoinPlan",
 49    "LoraJoinPlanPlace",
 50    "LoraLink",
 51    "LoraMaskControl",
 52    "LoraMaxPayload",
 53    "LoraPlanInfo",
 54    "LoraPlanRules",
 55    "LoraRelayChannel",
 56    "LoraSubBand",
 57    "RADIO_NOISE_FIGURE_DB",
 58    "REGIONS",
 59    "demodulator_snr_db",
 60    "fcc_max_conducted_dbm",
 61    "free_space_loss_db",
 62    "fresnel_radius_mm",
 63    "link",
 64    "messages_per_hour",
 65    "messages_per_hour_at",
 66    "cn470_plan",
 67    "noise_floor_dbm",
 68    "plan_for",
 69]
 70
 71#: The bands with a published channel plan.
 72REGIONS = (
 73    "EU868",
 74    "US915",
 75    "EU433",
 76    "AU915",
 77    "CN470",
 78    "AS923",
 79    "KR920",
 80    "IN865",
 81    "RU864",
 82)
 83
 84#: The CN470-510 channel plans, as :func:`cn470_plan` takes them: the four
 85#: RP002-1.0.5 plans for 20 MHz and 26 MHz antennas, then the 96-channel plan of the
 86#: LoRaWAN 1.0.3 Regional Parameters revision A.
 87CN470_PLANS = (
 88    "antenna_20mhz_a",
 89    "antenna_20mhz_b",
 90    "antenna_26mhz_a",
 91    "antenna_26mhz_b",
 92    "channels_96",
 93)
 94
 95#: A typical noise figure for a Semtech sub-GHz LoRa radio, in dB. Semtech AN1200.22
 96#: takes it as the receiver behind the SX1272 and SX1276 datasheet sensitivities.
 97RADIO_NOISE_FIGURE_DB = 6.0
 98
 99#: A typical noise figure for a LoRaWAN gateway receiver, in dB, from Semtech TN1300.05.
100GATEWAY_NOISE_FIGURE_DB = 3.0
101
102
103def link(
104    spreading_factor: int,
105    bandwidth_hz: int,
106    coding_rate_denominator: int = 5,
107    preamble_symbols: int = 8,
108    explicit_header: bool = True,
109    crc: bool = True,
110) -> LoraLink:
111    """Describe a LoRa link, clamping every value to its LoRa range.
112
113    The defaults are coding rate 4/5, an eight-symbol preamble, an explicit
114    header, and CRC on, which is a typical uplink.
115
116    :param spreading_factor: The spreading factor, clamped to 5 (fastest) to 12
117        (longest range).
118    :param bandwidth_hz: The channel bandwidth in hertz, such as ``125_000``.
119    :param coding_rate_denominator: The coding-rate denominator, clamped to 5 to 8.
120    :param preamble_symbols: The preamble length in symbols.
121    :param explicit_header: Whether the frame carries an explicit header.
122    :param crc: Whether the frame carries a CRC.
123    :returns: The link, which answers for its own airtime and off time.
124    """
125    return LoraLink(
126        spreading_factor,
127        bandwidth_hz,
128        coding_rate_denominator,
129        preamble_symbols,
130        explicit_header,
131        crc,
132    )
133
134
135def messages_per_hour(
136    settings: LoraLink, payload_len: int, duty_cycle_permille: int
137) -> int:
138    """Return how many transmissions of a payload fit in an hour under a limit.
139
140    The airtime plus the silence it forces is what one transmission really costs,
141    so this is the message budget a deployment plans against.
142
143    :param settings: The link settings.
144    :param payload_len: The payload length in bytes.
145    :param duty_cycle_permille: The limit in parts per thousand, so ``10`` is 1%.
146    :returns: The number of whole transmissions per hour, or ``0`` when the limit
147        forbids transmitting.
148    """
149    return settings.messages_per_hour(payload_len, duty_cycle_permille)
150
151
152def plan_for(region: str) -> ChannelPlan:
153    """Return the published channel plan for a region.
154
155    A channel plan is what a regulator and the LoRa Alliance publish about one
156    band: which data rates exist, what each carries, how much of the time a node
157    may hold a frequency, and where it listens for a downlink. The plan reports
158    those facts and costs a transmission out against them; it never refuses one,
159    because a deployment may hold licensed spectrum or be working under emergency
160    provisions and only the operator knows which.
161
162    :param region: The band to describe, such as ``EU868``. See :data:`REGIONS`.
163    :returns: The plan, which answers every question about that band.
164    :raises ValueError: If no published region goes by that name.
165
166    >>> plan = plan_for("EU868")
167    >>> plan.name
168    'EU863-870'
169    >>> plan.link_settings(0).spreading_factor
170    12
171    >>> plan.duty_cycle_permille(868_100_000)
172    10
173    """
174    return ChannelPlan.for_region(region)
175
176
177def cn470_plan(plan: str) -> ChannelPlan:
178    """Return one of the CN470-510 channel plans.
179
180    A device joining over the air uses the twenty common join channels the four
181    RP002-1.0.5 plans share, and moves to the plan its join channel names.
182
183    :param plan: The plan, one of :data:`CN470_PLANS`.
184    :returns: The plan, which also carries the runs of join channels that select
185        each of the others.
186    :raises ValueError: If no CN470 plan goes by that name.
187
188    >>> plan = cn470_plan("antenna_26mhz_a")
189    >>> place = plan.join_plan_for_channel(9)
190    >>> plan.join_plans()[place.index].plan
191    'antenna_20mhz_b'
192    >>> place.accept_hz
193    499900000
194    """
195    return ChannelPlan.for_cn470(plan)
196
197
198def messages_per_hour_at(
199    plan: ChannelPlan, data_rate: int, payload_len: int, frequency_hz: int
200) -> int | None:
201    """Return how many transmissions fit in an hour at a data rate the plan defines.
202
203    This is the budget question a deployment actually asks: not what the radio
204    can do, but how often it may speak on this band at this setting. The duty
205    cycle of the frequency it transmits on decides the answer.
206
207    :param plan: The channel plan to read.
208    :param data_rate: The uplink data-rate number.
209    :param payload_len: The payload length in bytes.
210    :param frequency_hz: The frequency the node transmits on.
211    :returns: The number of whole transmissions per hour, or ``None`` when the
212        plan does not describe that data rate or frequency.
213
214    >>> plan = plan_for("EU868")
215    >>> messages_per_hour_at(plan, 5, 20, 868_100_000) > 0
216    True
217    """
218    settings = plan.link_settings(data_rate)
219    permille = plan.duty_cycle_permille(frequency_hz)
220    if settings is None or permille is None:
221        return None
222    return messages_per_hour(settings, payload_len, permille)
223
224
225def noise_floor_dbm(bandwidth_hz: int) -> float:
226    """Return the thermal noise power in a channel, in dBm.
227
228    This is -174 dBm/Hz plus ``10 log10`` of the bandwidth, as Semtech AN1200.22
229    derives it. LoRa demodulates below this floor by the processing gain its
230    spreading factor buys, and :func:`demodulator_snr_db` gives how far.
231
232    :param bandwidth_hz: The channel bandwidth in hertz; ``0`` counts as one hertz.
233    :returns: The noise power in dBm, to a hundredth of a decibel.
234
235    >>> noise_floor_dbm(125_000)
236    -123.03
237    """
238    return lora_noise_floor_dbm(bandwidth_hz)
239
240
241def demodulator_snr_db(spreading_factor: int) -> float:
242    """Return the signal-to-noise ratio the LoRa demodulator needs, in dB.
243
244    These are the typical figures of Table 6-1 in the Semtech SX1261/2 datasheet,
245    from -2.5 dB at SF5 to -20 dB at SF12. A negative ratio is a signal received
246    below the noise.
247
248    :param spreading_factor: The spreading factor, clamped to 5 to 12.
249    :returns: The required SNR in dB.
250
251    >>> demodulator_snr_db(12)
252    -20.0
253    """
254    return lora_demodulator_snr_db(spreading_factor)
255
256
257def free_space_loss_db(distance_m: int, frequency_hz: int) -> float:
258    """Return the free-space basic transmission loss between isotropic antennas, in dB.
259
260    This is Recommendation ITU-R P.525-5, equation (5): the loss with nothing in the
261    way, which grows by 6.02 dB each time the distance doubles. Terrain that enters
262    the first Fresnel ellipsoid adds to it; see :func:`fresnel_radius_mm`.
263
264    :param distance_m: The distance between the antennas in meters.
265    :param frequency_hz: The carrier frequency in hertz.
266    :returns: The loss in dB, to a hundredth of a decibel.
267
268    >>> free_space_loss_db(5_000, 868_100_000)
269    105.2
270    """
271    return lora_free_space_loss_db(distance_m, frequency_hz)
272
273
274def fresnel_radius_mm(near_m: int, far_m: int, frequency_hz: int) -> int:
275    """Return the radius of the first Fresnel ellipsoid at a point on a path, in millimeters.
276
277    This is Recommendation ITU-R P.526-16, equation (2). The radius is widest halfway
278    along the path, and the diffraction zone starts where the clearance falls to 60%
279    of it.
280
281    :param near_m: The distance from one antenna to the point, in meters.
282    :param far_m: The distance from the point to the other antenna, in meters.
283    :param frequency_hz: The carrier frequency in hertz.
284    :returns: The radius in millimeters, or ``0`` for a path of no length.
285
286    >>> fresnel_radius_mm(2_500, 2_500, 868_100_000)
287    20777
288    """
289    return lora_fresnel_radius_mm(near_m, far_m, frequency_hz)
290
291
292def fcc_max_conducted_dbm(
293    antenna_gain_dbi: float, hopping_channels: int | None = None
294) -> float | None:
295    """Return the most conducted power 47 CFR 15.247 allows through an antenna, in dBm.
296
297    The limit applies to a 902-928 MHz transmitter: 1 W for digital modulation and
298    for hopping on at least 50 channels, and 0.25 W for hopping on 25 to 49, less
299    every decibel the antenna gain exceeds 6 dBi.
300
301    :param antenna_gain_dbi: The directional gain of the transmitting antenna.
302    :param hopping_channels: The number of hopping channels, or ``None`` for a
303        system using digital modulation.
304    :returns: The limit in dBm, or ``None`` for hopping on fewer than 25 channels,
305        which paragraph (b)(2) sets no limit for.
306
307    >>> fcc_max_conducted_dbm(9.0)
308    27.0
309    >>> fcc_max_conducted_dbm(2.15, hopping_channels=24) is None
310    True
311    """
312    return lora_fcc_max_conducted_dbm(antenna_gain_dbi, hopping_channels)
CN470_PLANS = ('antenna_20mhz_a', 'antenna_20mhz_b', 'antenna_26mhz_a', 'antenna_26mhz_b', 'channels_96')
class ChannelPlan:

A regional channel plan, published or private.

def for_region(region):

Returns the published plan for a region.

Raises ValueError if no published region goes by that name.

def for_cn470(plan):

Returns one of the CN470-510 channel plans.

RP002-1.0.5 divides the band into four plans, for 20 MHz and 26 MHz antennas, each with a type A and B; channels_96 is the plan of the LoRaWAN 1.0.3 Regional Parameters revision A. Raises ValueError for any other name.

def cn470_plans():

Returns the name of every CN470-510 plan, as for_cn470 takes.

def regions():

Returns the short code of every published region, as for_region takes.

These are the codes a deployment is configured with. The band name the specification uses, such as EU863-870, is the plan's name.

def info(self, /):

Returns the scalar facts of the plan.

def data_rate(self, /, data_rate, direction='uplink'):

Returns the data rate a number selects, or None past the end of the plan's table.

A number the region reserves is a data rate of kind reserved, which is different from a number the plan never defines.

def max_payload(self, /, data_rate, table='uplink_direct'):

Returns what a data rate may carry in one frame, or None where the plan publishes no limit for it.

def duty_cycle_permille(self, /, frequency_hz):

Returns the share of time a transmitter may hold a frequency, in parts per thousand, or None if the frequency falls in no sub-band this plan describes.

This reports the limit; it does not impose it. Pair it with min_off_time_us to turn the limit into the silence a frame costs.

def max_eirp_dbm(self, /, frequency_hz):

Returns the power ceiling that applies at a frequency, in dBm EIRP, falling back to the plan's default where no sub-band says otherwise.

def tx_power_dbm(self, /, index, max_eirp_dbm):

Returns the radiated power a transmit-power index selects, in dBm, or None if the index is past the highest the plan defines.

def rx1_data_rate(self, /, uplink_data_rate, offset, dwell_limited=False):

Returns the downlink data rate the first receive window listens at, or None if the uplink data rate or offset is outside what the plan defines.

def rx2(self, /):

Returns where the second receive window listens, as a frequency in hertz and a data rate.

def next_backoff_data_rate(self, /, data_rate):

Returns the next lower data rate to fall back to during adaptive back-off, or None at the slowest rate the plan has.

A device that has lost the network steps down this chain, trading airtime for range until it is heard again.

def channel_frequency_hz(self, /, channel):

Returns the center frequency of one of the plan's default channels, or None past the last one the plan starts a device with.

def channel_blocks(self, /, which='default'):

Returns the plan's channel blocks: the join set, the default set, or the numbered downlink channels a fixed plan answers the first receive window on.

def rules(self, /):

Returns how the plan defines and uses its channels.

def mask_control(self, /, value):

Returns what a ChMaskCntl value does, or None past 7.

def rx1_frequency_hz(self, /, uplink_channel, uplink_hz):

Returns where the first receive window listens after an uplink on a channel.

On a plan with no numbered downlink channels that is the uplink's own frequency; otherwise it is the downlink channel the uplink channel maps to.

def join_plans(self, /):

Returns the runs of join channels that select a plan, which only the published CN470-510 plans carry.

def join_plan_for_channel(self, /, join_channel):

Returns the run of join channels a join channel belongs to, and where the accept and the second receive window fall for it, or None if no run holds it.

def relay_channels(self, /):

Returns the plan's default relay channels, by the index a relay configuration names: RP002-1.0.5 sections 3.4.9 to 3.13.9, and none where a region defines none.

def sub_bands(self, /):

Returns the plan's sub-bands and the transmit limits inside each.

name

The specification's name for the band.

class ChannelPlanBuilder:

A channel plan under construction.

Tables are indexed by position, so entries are added in data-rate order and a number the plan does not use is added as a reserved data rate. What a region would share between directions is filled in by build.

def data_rate(self, /, rate, direction='uplink'):

Adds the next data rate in a direction.

A plan that never adds a downlink rate uses its uplink table in both directions, which is what every region but the 900 MHz plans does.

def max_payload(self, /, payload=None, table='uplink_direct'):

Adds the next entry of one payload table.

Pass no payload for a data rate that carries nothing. A downlink table left empty mirrors the matching uplink one.

def channel_block(self, /, block, which='default'):

Adds a run of evenly spaced channels to the join, default, or downlink set.

def kind(self, /, kind, channel_list=None):

Sets whether the network creates channels (dynamic) or only switches numbered ones (fixed), and for a dynamic plan the numbering a type 1 channel list is read against, mhz800 or mhz900.

def tx_param_setup(self, /, answered):

Sets whether devices on the plan answer TXParamSetupReq.

def mask_controls(self, /, controls):

Sets what each ChMaskCntl value does, all eight in value order.

def join_sequence(self, /, sequence):

Sets the order a device tries the join channels in, random or octet_passes.

def power_reference(self, /, reference, gain_allowance_db=0):

Sets what the transmit power indexes count down from, eirp or conducted, and for a conducted ceiling the antenna gain it allows for.

def sub_band(self, /, band):

Adds a sub-band and the transmit limits inside it.

A deployment on licensed spectrum gives its sub-band a duty cycle of 1000, which reports as unrestricted.

def relay_channel(self, /, channel):

Adds the next default relay channel, whose position is the index a relay configuration names. A data rate that is not one of the plan's LoRa downlink rates is refused when the plan is built.

def rx1_row(self, /, offsets, dwell_limited=False):

Adds the RX1 downlink data rates for the next uplink data rate.

Every row must be as wide as the plan's highest RX1 offset allows.

def backoff(self, /, lower=None):

Adds the next entry of the adaptive back-off chain.

Pass no data rate at the slowest, which has nothing below it. A chain left empty steps down one data rate at a time.

def power(self, /, default_max_eirp_dbm, step_db=2, max_index=7):

Sets the transmit-power ladder.

def rx(self, /, rx2_frequency_hz, rx2_data_rate=0, max_rx1_offset=0):

Sets the receive windows.

max_rx1_offset fixes how wide every RX1 row must be.

def beacon(self, /, beacon, has_dwell_time_limit=False):

Sets the Class B beacon and whether the plan limits dwell time.

def build(self, /):

Finishes the plan.

Raises PamojaError if the plan would answer a question wrongly, for example because an RX1 row is narrower than the plan's offsets allow, or because the second receive window listens at a data rate the plan does not define.

GATEWAY_NOISE_FIGURE_DB = 3.0
class LinkBudget:

The gains and losses of a LoRa link, from the transmitting radio to the receiving one.

Every value is in decibels, resolved to the hundredth of a decibel the Rust crate holds.

def eirp_dbm(self, /):

The equivalent isotropically radiated power, in dBm.

The transmit power plus the transmitting antenna gain, less the transmitting cable loss. This is the figure regional power ceilings limit.

def received_dbm(self, /, path_loss_db):

The power that reaches the receiving radio across a path, in dBm.

def sensitivity_dbm(self, /, link):

The weakest signal the receiver can demodulate on a link, in dBm.

def max_path_loss_db(self, /, link):

The most path loss the link survives, in dB.

def margin_db(self, /, link, path_loss_db):

How far above the sensitivity a signal arrives across a path, in dB.

Negative where the path loses more than the link survives.

def max_transmit_power_dbm(self, /, eirp_ceiling_dbm):

The most transmit power that keeps the EIRP at or under a ceiling, in dBm.

A higher-gain antenna leaves less power for the radio.

receive_cable_loss_db

The loss in the cable and connectors between the receiving antenna and its radio, in dB.

noise_figure_db

The noise figure of the receiver, in dB.

transmit_antenna_gain_dbi

The gain of the transmitting antenna over an isotropic antenna, in dBi.

transmit_power_dbm

The power the transmitting radio delivers at its antenna port, in dBm.

transmit_cable_loss_db

The loss in the cable and connectors between the transmitting radio and its antenna, in dB.

receive_antenna_gain_dbi

The gain of the receiving antenna over an isotropic antenna, in dBi.

class LoraBeacon:

The Class B beacon settings of a plan.

ping_slot_frequency_hz

The default ping-slot frequency, in hertz.

frequency_hz

The frequency the beacon is broadcast on, in hertz.

data_rate

The data rate the beacon is broadcast at.

class LoraChannelBlock:

A run of evenly spaced channels.

min_data_rate

The slowest data rate the block allows.

start_hz

The first channel's center frequency in hertz.

count

How many channels the block holds.

max_data_rate

The fastest data rate the block allows.

step_hz

The spacing between channels in hertz.

class LoraDataRate:

One data rate: what a number on the wire means for the radio.

Only the attributes belonging to kind are set; the rest are None.

def lora(spreading_factor, bandwidth_hz, bitrate_bps):

Describes a rate carried by LoRa modulation.

def fsk(bitrate_bps):

Describes a rate carried by frequency-shift keying.

def lr_fhss( coding_rate_numerator, coding_rate_denominator, bandwidth_hz, bitrate_bps):

Describes a rate carried by long-range frequency-hopping spread spectrum.

def reserved():

Describes a data-rate number the region reserves, which carries nothing.

coding_rate_numerator

The coding-rate numerator, for an LR-FHSS rate.

coding_rate_denominator

The coding-rate denominator, for an LR-FHSS rate.

kind

How this rate is carried: lora, fsk, lr_fhss, or reserved.

spreading_factor

The spreading factor, for a LoRa rate.

bitrate_bps

The payload bitrate in bits per second.

bandwidth_hz

The channel bandwidth in hertz, for a LoRa or LR-FHSS rate.

class LoraJoinPlan:

A run of join channels that puts a device on a plan.

channels

The join channels and the data rates a request may use on them.

accept_start_hz

Where the accept answering the first channel arrives, in hertz.

accept_step_hz

How far the accept frequency moves for each next channel, in hertz.

rx2_start_hz

The second receive window's frequency after joining on the first channel, in hertz.

rx2_step_hz

How far that frequency moves for each next channel, in hertz.

plan

The CN470-510 plan a join on these channels selects, as ChannelPlan.for_cn470 takes it.

class LoraJoinPlanPlace:

The run of join channels one join channel belongs to.

rx2_hz

Where the second receive window listens once joined on it, in hertz.

index

The run's position in join_plans().

offset

The channel's place within the run.

accept_hz

Where the join accept for that channel arrives, in hertz.

class LoraMaskControl:

What one ChMaskCntl value of a LinkADRReq does.

Only the attributes belonging to kind are set; the rest are None.

def one_group(group):

The mask sets one group of sixteen channels.

def banks():

The ten low bits of the mask switch banks of eight channels.

def paired_banks():

The eight low bits switch banks of eight with their 500 kHz channel, and the ninth the 500 kHz channels past them.

def all_channels(on, then_group=None):

Every channel turns on or off, then the mask sets a group if one is given.

def reserved():

The value is reserved.

group

For group, the group of sixteen channels the mask sets.

then_group

For all, the group the mask then sets, if any.

kind

What the value does: group, banks, paired_banks, all, or reserved.

on

For all, whether every channel turns on.

class LoraMaxPayload:

What one data rate may carry in a single frame.

mac_payload

The largest MAC payload, frame options included, in bytes.

application

The largest application payload, in bytes.

class LoraPlanInfo:

The scalar facts of a plan, read in one call.

default_channel_block_count

How many default channel blocks the plan defines.

default_max_eirp_dbm

The power ceiling assumed when no sub-band says otherwise, in dBm.

join_channel_block_count

How many join channel blocks the plan defines.

tx_power_step_db

The step between transmit-power settings, in dB.

has_dwell_time_limit

Whether the plan limits how long one transmission may hold a channel.

has_dwell_limited_rx1

Whether the plan publishes a second RX1 mapping for a dwell-limited downlink.

max_tx_power_index

The highest transmit-power index the plan defines.

max_rx1_data_rate_offset

The highest RX1 data-rate offset the plan allows.

rx2_frequency_hz

The frequency the second receive window listens on, in hertz.

beacon

The Class B beacon settings.

has_dwell_limited_payloads

Whether the plan publishes a payload table for a dwell-limited device.

name

The specification's name for the band, such as EU863-870.

rx2_data_rate

The data rate the second receive window listens at.

default_channel_count

How many channels the plan starts a device with.

sub_band_count

How many sub-bands the plan defines.

class LoraPlanRules:

How a plan defines and uses its channels.

gain_allowance_db

For a conducted ceiling, the antenna gain it already allows for, in dB.

channel_list

For a dynamic plan, the numbering a type 1 channel list is read against: mhz800, mhz900, or None.

kind

dynamic if the network creates channels, fixed if it only switches numbered ones.

tx_param_setup

Whether devices on the plan answer TXParamSetupReq.

join_sequence

The order a device tries the join channels in: random or octet_passes.

join_plan_count

How many runs of join channels select a plan, which only the published CN470-510 plans carry.

power_reference

What the transmit power indexes count down from: eirp or conducted.

class LoraRelayChannel:

A default channel of a LoRaWAN relay, TS011-1.0.1.

data_rate

The data rate of both, numbered as the plan's downlink data rates.

ack_frequency_hz

Where the relay acknowledges it, in hertz.

wor_frequency_hz

Where an end device sends its wake-on-radio frame, in hertz.

class LoraSubBand:

A slice of a band with its own transmit limits.

start_hz

The first frequency in the sub-band, in hertz.

duty_cycle_permille

The share of time a transmitter may hold the channel, in parts per thousand, so 10 is one percent and 1000 is unrestricted.

max_eirp_dbm

The power ceiling in dBm EIRP.

end_hz

The last frequency in the sub-band, in hertz.

RADIO_NOISE_FIGURE_DB = 6.0
REGIONS = ('EU868', 'US915', 'EU433', 'AU915', 'CN470', 'AS923', 'KR920', 'IN865', 'RU864')
def demodulator_snr_db(spreading_factor: int) -> float:
242def demodulator_snr_db(spreading_factor: int) -> float:
243    """Return the signal-to-noise ratio the LoRa demodulator needs, in dB.
244
245    These are the typical figures of Table 6-1 in the Semtech SX1261/2 datasheet,
246    from -2.5 dB at SF5 to -20 dB at SF12. A negative ratio is a signal received
247    below the noise.
248
249    :param spreading_factor: The spreading factor, clamped to 5 to 12.
250    :returns: The required SNR in dB.
251
252    >>> demodulator_snr_db(12)
253    -20.0
254    """
255    return lora_demodulator_snr_db(spreading_factor)

Return the signal-to-noise ratio the LoRa demodulator needs, in dB.

These are the typical figures of Table 6-1 in the Semtech SX1261/2 datasheet, from -2.5 dB at SF5 to -20 dB at SF12. A negative ratio is a signal received below the noise.

Parameters
  • spreading_factor: The spreading factor, clamped to 5 to 12. :returns: The required SNR in dB.
>>> demodulator_snr_db(12)
-20.0
def fcc_max_conducted_dbm( antenna_gain_dbi: float, hopping_channels: int | None = None) -> float | None:
293def fcc_max_conducted_dbm(
294    antenna_gain_dbi: float, hopping_channels: int | None = None
295) -> float | None:
296    """Return the most conducted power 47 CFR 15.247 allows through an antenna, in dBm.
297
298    The limit applies to a 902-928 MHz transmitter: 1 W for digital modulation and
299    for hopping on at least 50 channels, and 0.25 W for hopping on 25 to 49, less
300    every decibel the antenna gain exceeds 6 dBi.
301
302    :param antenna_gain_dbi: The directional gain of the transmitting antenna.
303    :param hopping_channels: The number of hopping channels, or ``None`` for a
304        system using digital modulation.
305    :returns: The limit in dBm, or ``None`` for hopping on fewer than 25 channels,
306        which paragraph (b)(2) sets no limit for.
307
308    >>> fcc_max_conducted_dbm(9.0)
309    27.0
310    >>> fcc_max_conducted_dbm(2.15, hopping_channels=24) is None
311    True
312    """
313    return lora_fcc_max_conducted_dbm(antenna_gain_dbi, hopping_channels)

Return the most conducted power 47 CFR 15.247 allows through an antenna, in dBm.

The limit applies to a 902-928 MHz transmitter: 1 W for digital modulation and for hopping on at least 50 channels, and 0.25 W for hopping on 25 to 49, less every decibel the antenna gain exceeds 6 dBi.

Parameters
  • antenna_gain_dbi: The directional gain of the transmitting antenna.
  • hopping_channels: The number of hopping channels, or None for a system using digital modulation. :returns: The limit in dBm, or None for hopping on fewer than 25 channels, which paragraph (b)(2) sets no limit for.
>>> fcc_max_conducted_dbm(9.0)
27.0
>>> fcc_max_conducted_dbm(2.15, hopping_channels=24) is None
True
def free_space_loss_db(distance_m: int, frequency_hz: int) -> float:
258def free_space_loss_db(distance_m: int, frequency_hz: int) -> float:
259    """Return the free-space basic transmission loss between isotropic antennas, in dB.
260
261    This is Recommendation ITU-R P.525-5, equation (5): the loss with nothing in the
262    way, which grows by 6.02 dB each time the distance doubles. Terrain that enters
263    the first Fresnel ellipsoid adds to it; see :func:`fresnel_radius_mm`.
264
265    :param distance_m: The distance between the antennas in meters.
266    :param frequency_hz: The carrier frequency in hertz.
267    :returns: The loss in dB, to a hundredth of a decibel.
268
269    >>> free_space_loss_db(5_000, 868_100_000)
270    105.2
271    """
272    return lora_free_space_loss_db(distance_m, frequency_hz)

Return the free-space basic transmission loss between isotropic antennas, in dB.

This is Recommendation ITU-R P.525-5, equation (5): the loss with nothing in the way, which grows by 6.02 dB each time the distance doubles. Terrain that enters the first Fresnel ellipsoid adds to it; see fresnel_radius_mm().

Parameters
  • distance_m: The distance between the antennas in meters.
  • frequency_hz: The carrier frequency in hertz. :returns: The loss in dB, to a hundredth of a decibel.
>>> free_space_loss_db(5_000, 868_100_000)
105.2
def fresnel_radius_mm(near_m: int, far_m: int, frequency_hz: int) -> int:
275def fresnel_radius_mm(near_m: int, far_m: int, frequency_hz: int) -> int:
276    """Return the radius of the first Fresnel ellipsoid at a point on a path, in millimeters.
277
278    This is Recommendation ITU-R P.526-16, equation (2). The radius is widest halfway
279    along the path, and the diffraction zone starts where the clearance falls to 60%
280    of it.
281
282    :param near_m: The distance from one antenna to the point, in meters.
283    :param far_m: The distance from the point to the other antenna, in meters.
284    :param frequency_hz: The carrier frequency in hertz.
285    :returns: The radius in millimeters, or ``0`` for a path of no length.
286
287    >>> fresnel_radius_mm(2_500, 2_500, 868_100_000)
288    20777
289    """
290    return lora_fresnel_radius_mm(near_m, far_m, frequency_hz)

Return the radius of the first Fresnel ellipsoid at a point on a path, in millimeters.

This is Recommendation ITU-R P.526-16, equation (2). The radius is widest halfway along the path, and the diffraction zone starts where the clearance falls to 60% of it.

Parameters
  • near_m: The distance from one antenna to the point, in meters.
  • far_m: The distance from the point to the other antenna, in meters.
  • frequency_hz: The carrier frequency in hertz. :returns: The radius in millimeters, or 0 for a path of no length.
>>> fresnel_radius_mm(2_500, 2_500, 868_100_000)
20777
def messages_per_hour(settings: LoraLink, payload_len: int, duty_cycle_permille: int) -> int:
136def messages_per_hour(
137    settings: LoraLink, payload_len: int, duty_cycle_permille: int
138) -> int:
139    """Return how many transmissions of a payload fit in an hour under a limit.
140
141    The airtime plus the silence it forces is what one transmission really costs,
142    so this is the message budget a deployment plans against.
143
144    :param settings: The link settings.
145    :param payload_len: The payload length in bytes.
146    :param duty_cycle_permille: The limit in parts per thousand, so ``10`` is 1%.
147    :returns: The number of whole transmissions per hour, or ``0`` when the limit
148        forbids transmitting.
149    """
150    return settings.messages_per_hour(payload_len, duty_cycle_permille)

Return how many transmissions of a payload fit in an hour under a limit.

The airtime plus the silence it forces is what one transmission really costs, so this is the message budget a deployment plans against.

Parameters
  • settings: The link settings.
  • payload_len: The payload length in bytes.
  • duty_cycle_permille: The limit in parts per thousand, so 10 is 1%. :returns: The number of whole transmissions per hour, or 0 when the limit forbids transmitting.
def messages_per_hour_at( plan: ChannelPlan, data_rate: int, payload_len: int, frequency_hz: int) -> int | None:
199def messages_per_hour_at(
200    plan: ChannelPlan, data_rate: int, payload_len: int, frequency_hz: int
201) -> int | None:
202    """Return how many transmissions fit in an hour at a data rate the plan defines.
203
204    This is the budget question a deployment actually asks: not what the radio
205    can do, but how often it may speak on this band at this setting. The duty
206    cycle of the frequency it transmits on decides the answer.
207
208    :param plan: The channel plan to read.
209    :param data_rate: The uplink data-rate number.
210    :param payload_len: The payload length in bytes.
211    :param frequency_hz: The frequency the node transmits on.
212    :returns: The number of whole transmissions per hour, or ``None`` when the
213        plan does not describe that data rate or frequency.
214
215    >>> plan = plan_for("EU868")
216    >>> messages_per_hour_at(plan, 5, 20, 868_100_000) > 0
217    True
218    """
219    settings = plan.link_settings(data_rate)
220    permille = plan.duty_cycle_permille(frequency_hz)
221    if settings is None or permille is None:
222        return None
223    return messages_per_hour(settings, payload_len, permille)

Return how many transmissions fit in an hour at a data rate the plan defines.

This is the budget question a deployment actually asks: not what the radio can do, but how often it may speak on this band at this setting. The duty cycle of the frequency it transmits on decides the answer.

Parameters
  • plan: The channel plan to read.
  • data_rate: The uplink data-rate number.
  • payload_len: The payload length in bytes.
  • frequency_hz: The frequency the node transmits on. :returns: The number of whole transmissions per hour, or None when the plan does not describe that data rate or frequency.
>>> plan = plan_for("EU868")
>>> messages_per_hour_at(plan, 5, 20, 868_100_000) > 0
True
def cn470_plan(plan: str) -> ChannelPlan:
178def cn470_plan(plan: str) -> ChannelPlan:
179    """Return one of the CN470-510 channel plans.
180
181    A device joining over the air uses the twenty common join channels the four
182    RP002-1.0.5 plans share, and moves to the plan its join channel names.
183
184    :param plan: The plan, one of :data:`CN470_PLANS`.
185    :returns: The plan, which also carries the runs of join channels that select
186        each of the others.
187    :raises ValueError: If no CN470 plan goes by that name.
188
189    >>> plan = cn470_plan("antenna_26mhz_a")
190    >>> place = plan.join_plan_for_channel(9)
191    >>> plan.join_plans()[place.index].plan
192    'antenna_20mhz_b'
193    >>> place.accept_hz
194    499900000
195    """
196    return ChannelPlan.for_cn470(plan)

Return one of the CN470-510 channel plans.

A device joining over the air uses the twenty common join channels the four RP002-1.0.5 plans share, and moves to the plan its join channel names.

Parameters
  • plan: The plan, one of CN470_PLANS. :returns: The plan, which also carries the runs of join channels that select each of the others.
Raises
  • ValueError: If no CN470 plan goes by that name.
>>> plan = cn470_plan("antenna_26mhz_a")
>>> place = plan.join_plan_for_channel(9)
>>> plan.join_plans()[place.index].plan
'antenna_20mhz_b'
>>> place.accept_hz
499900000
def noise_floor_dbm(bandwidth_hz: int) -> float:
226def noise_floor_dbm(bandwidth_hz: int) -> float:
227    """Return the thermal noise power in a channel, in dBm.
228
229    This is -174 dBm/Hz plus ``10 log10`` of the bandwidth, as Semtech AN1200.22
230    derives it. LoRa demodulates below this floor by the processing gain its
231    spreading factor buys, and :func:`demodulator_snr_db` gives how far.
232
233    :param bandwidth_hz: The channel bandwidth in hertz; ``0`` counts as one hertz.
234    :returns: The noise power in dBm, to a hundredth of a decibel.
235
236    >>> noise_floor_dbm(125_000)
237    -123.03
238    """
239    return lora_noise_floor_dbm(bandwidth_hz)

Return the thermal noise power in a channel, in dBm.

This is -174 dBm/Hz plus 10 log10 of the bandwidth, as Semtech AN1200.22 derives it. LoRa demodulates below this floor by the processing gain its spreading factor buys, and demodulator_snr_db() gives how far.

Parameters
  • bandwidth_hz: The channel bandwidth in hertz; 0 counts as one hertz. :returns: The noise power in dBm, to a hundredth of a decibel.
>>> noise_floor_dbm(125_000)
-123.03
def plan_for(region: str) -> ChannelPlan:
153def plan_for(region: str) -> ChannelPlan:
154    """Return the published channel plan for a region.
155
156    A channel plan is what a regulator and the LoRa Alliance publish about one
157    band: which data rates exist, what each carries, how much of the time a node
158    may hold a frequency, and where it listens for a downlink. The plan reports
159    those facts and costs a transmission out against them; it never refuses one,
160    because a deployment may hold licensed spectrum or be working under emergency
161    provisions and only the operator knows which.
162
163    :param region: The band to describe, such as ``EU868``. See :data:`REGIONS`.
164    :returns: The plan, which answers every question about that band.
165    :raises ValueError: If no published region goes by that name.
166
167    >>> plan = plan_for("EU868")
168    >>> plan.name
169    'EU863-870'
170    >>> plan.link_settings(0).spreading_factor
171    12
172    >>> plan.duty_cycle_permille(868_100_000)
173    10
174    """
175    return ChannelPlan.for_region(region)

Return the published channel plan for a region.

A channel plan is what a regulator and the LoRa Alliance publish about one band: which data rates exist, what each carries, how much of the time a node may hold a frequency, and where it listens for a downlink. The plan reports those facts and costs a transmission out against them; it never refuses one, because a deployment may hold licensed spectrum or be working under emergency provisions and only the operator knows which.

Parameters
  • region: The band to describe, such as EU868. See REGIONS. :returns: The plan, which answers every question about that band.
Raises
  • ValueError: If no published region goes by that name.
>>> plan = plan_for("EU868")
>>> plan.name
'EU863-870'
>>> plan.link_settings(0).spreading_factor
12
>>> plan.duty_cycle_permille(868_100_000)
10