pamoja.lora

Idiomatic LoRa link-budget facade.

LoRa buys kilometres 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.

  1"""Idiomatic LoRa link-budget facade.
  2
  3LoRa buys kilometres 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"""
  8
  9from __future__ import annotations
 10
 11from pamoja._native import (
 12    ChannelPlan,
 13    ChannelPlanBuilder,
 14    LoraBeacon,
 15    LoraChannelBlock,
 16    LoraDataRate,
 17    LoraLink,
 18    LoraMaxPayload,
 19    LoraPlanInfo,
 20    LoraSubBand,
 21)
 22
 23__all__ = [
 24    "ChannelPlan",
 25    "ChannelPlanBuilder",
 26    "LoraBeacon",
 27    "LoraChannelBlock",
 28    "LoraDataRate",
 29    "LoraLink",
 30    "LoraMaxPayload",
 31    "LoraPlanInfo",
 32    "LoraSubBand",
 33    "REGIONS",
 34    "link",
 35    "messages_per_hour",
 36    "messages_per_hour_at",
 37    "plan_for",
 38]
 39
 40#: The bands with a published channel plan.
 41REGIONS = (
 42    "EU868",
 43    "US915",
 44    "EU433",
 45    "AU915",
 46    "CN470",
 47    "AS923",
 48    "KR920",
 49    "IN865",
 50    "RU864",
 51)
 52
 53
 54def link(
 55    spreading_factor: int,
 56    bandwidth_hz: int,
 57    coding_rate_denominator: int = 5,
 58    preamble_symbols: int = 8,
 59    explicit_header: bool = True,
 60    crc: bool = True,
 61) -> LoraLink:
 62    """Describe a LoRa link, clamping every value to its LoRa range.
 63
 64    The defaults are coding rate 4/5, an eight-symbol preamble, an explicit
 65    header, and CRC on, which is a typical uplink.
 66
 67    :param spreading_factor: The spreading factor, clamped to 5 (fastest) to 12
 68        (longest range).
 69    :param bandwidth_hz: The channel bandwidth in hertz, such as ``125_000``.
 70    :param coding_rate_denominator: The coding-rate denominator, clamped to 5 to 8.
 71    :param preamble_symbols: The preamble length in symbols.
 72    :param explicit_header: Whether the frame carries an explicit header.
 73    :param crc: Whether the frame carries a CRC.
 74    :returns: The link, which answers for its own airtime and off time.
 75    """
 76    return LoraLink(
 77        spreading_factor,
 78        bandwidth_hz,
 79        coding_rate_denominator,
 80        preamble_symbols,
 81        explicit_header,
 82        crc,
 83    )
 84
 85
 86def messages_per_hour(
 87    settings: LoraLink, payload_len: int, duty_cycle_permille: int
 88) -> int:
 89    """Return how many transmissions of a payload fit in an hour under a limit.
 90
 91    The airtime plus the silence it forces is what one transmission really costs,
 92    so this is the message budget a deployment plans against.
 93
 94    :param settings: The link settings.
 95    :param payload_len: The payload length in bytes.
 96    :param duty_cycle_permille: The limit in parts per thousand, so ``10`` is 1%.
 97    :returns: The number of whole transmissions per hour, or ``0`` when the limit
 98        forbids transmitting.
 99    """
100    off_time = settings.min_off_time_us(payload_len, duty_cycle_permille)
101    if off_time is None:
102        return 0
103    return 3_600_000_000 // (settings.airtime_us(payload_len) + off_time)
104
105
106def plan_for(region: str) -> ChannelPlan:
107    """Return the published channel plan for a region.
108
109    A channel plan is what a regulator and the LoRa Alliance publish about one
110    band: which data rates exist, what each carries, how much of the time a node
111    may hold a frequency, and where it listens for a downlink. The plan reports
112    those facts and costs a transmission out against them; it never refuses one,
113    because a deployment may hold licensed spectrum or be working under emergency
114    provisions and only the operator knows which.
115
116    :param region: The band to describe, such as ``EU868``. See :data:`REGIONS`.
117    :returns: The plan, which answers every question about that band.
118    :raises ValueError: If no published region goes by that name.
119
120    >>> plan = plan_for("EU868")
121    >>> plan.name
122    'EU863-870'
123    >>> plan.link_settings(0).spreading_factor
124    12
125    >>> plan.duty_cycle_permille(868_100_000)
126    10
127    """
128    return ChannelPlan.for_region(region)
129
130
131def messages_per_hour_at(
132    plan: ChannelPlan, data_rate: int, payload_len: int, frequency_hz: int
133) -> int | None:
134    """Return how many transmissions fit in an hour at a data rate the plan defines.
135
136    This is the budget question a deployment actually asks: not what the radio
137    can do, but how often it may speak on this band at this setting. The duty
138    cycle of the frequency it transmits on decides the answer.
139
140    :param plan: The channel plan to read.
141    :param data_rate: The uplink data-rate number.
142    :param payload_len: The payload length in bytes.
143    :param frequency_hz: The frequency the node transmits on.
144    :returns: The number of whole transmissions per hour, or ``None`` when the
145        plan does not describe that data rate or frequency.
146
147    >>> plan = plan_for("EU868")
148    >>> messages_per_hour_at(plan, 5, 20, 868_100_000) > 0
149    True
150    """
151    settings = plan.link_settings(data_rate)
152    permille = plan.duty_cycle_permille(frequency_hz)
153    if settings is None or permille is None:
154        return None
155    return messages_per_hour(settings, payload_len, permille)
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 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 centre 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, either the join set or the default set.

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.

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 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 ValueError 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.

class LoraBeacon:

The Class B beacon settings of a plan.

frequency_hz

The frequency the beacon is broadcast on, in hertz.

data_rate

The data rate the beacon is broadcast at.

ping_slot_frequency_hz

The default ping-slot frequency, in hertz.

class LoraChannelBlock:

A run of evenly spaced channels.

max_data_rate

The fastest data rate the block allows.

count

How many channels the block holds.

start_hz

The first channel's centre frequency in hertz.

step_hz

The spacing between channels in hertz.

min_data_rate

The slowest data rate the block allows.

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_denominator

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

bandwidth_hz

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

bitrate_bps

The payload bitrate in bits per second.

kind

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

spreading_factor

The spreading factor, for a LoRa rate.

coding_rate_numerator

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

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.

beacon

The Class B beacon settings.

tx_power_step_db

The step between transmit-power settings, in dB.

max_tx_power_index

The highest transmit-power index the plan defines.

name

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

has_dwell_time_limit

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

default_max_eirp_dbm

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

rx2_data_rate

The data rate the second receive window listens at.

has_dwell_limited_payloads

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

default_channel_block_count

How many default channel blocks the plan defines.

default_channel_count

How many channels the plan starts a device with.

rx2_frequency_hz

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

max_rx1_data_rate_offset

The highest RX1 data-rate offset the plan allows.

has_dwell_limited_rx1

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

sub_band_count

How many sub-bands the plan defines.

join_channel_block_count

How many join channel blocks the plan defines.

class LoraSubBand:

A slice of a band with its own transmit limits.

max_eirp_dbm

The power ceiling in dBm EIRP.

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.

end_hz

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

REGIONS = ('EU868', 'US915', 'EU433', 'AU915', 'CN470', 'AS923', 'KR920', 'IN865', 'RU864')
def messages_per_hour(settings: LoraLink, payload_len: int, duty_cycle_permille: int) -> int:
 87def messages_per_hour(
 88    settings: LoraLink, payload_len: int, duty_cycle_permille: int
 89) -> int:
 90    """Return how many transmissions of a payload fit in an hour under a limit.
 91
 92    The airtime plus the silence it forces is what one transmission really costs,
 93    so this is the message budget a deployment plans against.
 94
 95    :param settings: The link settings.
 96    :param payload_len: The payload length in bytes.
 97    :param duty_cycle_permille: The limit in parts per thousand, so ``10`` is 1%.
 98    :returns: The number of whole transmissions per hour, or ``0`` when the limit
 99        forbids transmitting.
100    """
101    off_time = settings.min_off_time_us(payload_len, duty_cycle_permille)
102    if off_time is None:
103        return 0
104    return 3_600_000_000 // (settings.airtime_us(payload_len) + off_time)

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:
132def messages_per_hour_at(
133    plan: ChannelPlan, data_rate: int, payload_len: int, frequency_hz: int
134) -> int | None:
135    """Return how many transmissions fit in an hour at a data rate the plan defines.
136
137    This is the budget question a deployment actually asks: not what the radio
138    can do, but how often it may speak on this band at this setting. The duty
139    cycle of the frequency it transmits on decides the answer.
140
141    :param plan: The channel plan to read.
142    :param data_rate: The uplink data-rate number.
143    :param payload_len: The payload length in bytes.
144    :param frequency_hz: The frequency the node transmits on.
145    :returns: The number of whole transmissions per hour, or ``None`` when the
146        plan does not describe that data rate or frequency.
147
148    >>> plan = plan_for("EU868")
149    >>> messages_per_hour_at(plan, 5, 20, 868_100_000) > 0
150    True
151    """
152    settings = plan.link_settings(data_rate)
153    permille = plan.duty_cycle_permille(frequency_hz)
154    if settings is None or permille is None:
155        return None
156    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 plan_for(region: str) -> ChannelPlan:
107def plan_for(region: str) -> ChannelPlan:
108    """Return the published channel plan for a region.
109
110    A channel plan is what a regulator and the LoRa Alliance publish about one
111    band: which data rates exist, what each carries, how much of the time a node
112    may hold a frequency, and where it listens for a downlink. The plan reports
113    those facts and costs a transmission out against them; it never refuses one,
114    because a deployment may hold licensed spectrum or be working under emergency
115    provisions and only the operator knows which.
116
117    :param region: The band to describe, such as ``EU868``. See :data:`REGIONS`.
118    :returns: The plan, which answers every question about that band.
119    :raises ValueError: If no published region goes by that name.
120
121    >>> plan = plan_for("EU868")
122    >>> plan.name
123    'EU863-870'
124    >>> plan.link_settings(0).spreading_factor
125    12
126    >>> plan.duty_cycle_permille(868_100_000)
127    10
128    """
129    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