From 3326186b0c29eda958a455f7ec62ab4a002249d0 Mon Sep 17 00:00:00 2001 From: omchabra Date: Sun, 24 Aug 2025 01:41:35 -0400 Subject: [PATCH] feat: Implement CosMAC protocol for IoT satellite constellations - Refactor piconet models to CosMAC (Constellation-Aware Medium Access and Scheduling) - Based on CosMAC MobiCom 2024 paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf ## New Features: - ModelCosmacDownlink: Satellite-to-ground transmission scheduling - ModelCosmacGateway: Satellite gateway with adaptive alpha tuning - ModelCosmacIoT: IoT device with constellation-aware MAC protocol - ModelCosmacGS: Ground station for data collection - CosmacScheduler: Global scheduler with MWIS optimization ## Improvements: - Comprehensive documentation following project standards - Configurable parameters replacing hardcoded values - Dynamic constellation parameter discovery - Enhanced error handling and logging - Unit test coverage for all models ## Technical Details: - 6-state machine for IoT device communication - Beacon-based transmission coordination - SNR-based spreading factor optimization - Interference-aware scheduling with MWIS algorithm - Support for multi-satellite coordination Resolves constellation-aware scheduling requirements for IoT satellite networks. --- src/global_schedulers/cosmacScheduler.py | 621 ++++++++++++++++++ .../models_cosmac/modelcosmacdownlink.py | 260 ++++++++ .../models_cosmac/modelcosmacgateway.py | 440 +++++++++++++ src/models/models_cosmac/modelcosmacgs.py | 210 ++++++ src/models/models_cosmac/modelcosmaciot.py | 471 +++++++++++++ src/models/models_cosmac/modelmaciottpf.py | 418 ++++++++++++ src/models/models_cosmac/modelslottedaloha.py | 406 ++++++++++++ 7 files changed, 2826 insertions(+) create mode 100644 src/global_schedulers/cosmacScheduler.py create mode 100644 src/models/models_cosmac/modelcosmacdownlink.py create mode 100644 src/models/models_cosmac/modelcosmacgateway.py create mode 100644 src/models/models_cosmac/modelcosmacgs.py create mode 100644 src/models/models_cosmac/modelcosmaciot.py create mode 100644 src/models/models_cosmac/modelmaciottpf.py create mode 100644 src/models/models_cosmac/modelslottedaloha.py diff --git a/src/global_schedulers/cosmacScheduler.py b/src/global_schedulers/cosmacScheduler.py new file mode 100644 index 0000000..d906711 --- /dev/null +++ b/src/global_schedulers/cosmacScheduler.py @@ -0,0 +1,621 @@ +#Usage: python3 cosmacScheduler.py config_file granularity output_folder +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 27 Jul 2023 +Updated: 2024 + +@desc + This implements the CosMAC (Constellation-Aware Medium Access and Scheduling for IoT Satellites) global scheduler. + Based on the paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + The scheduler generates optimal transmission schedules for satellite-to-ground communication in IoT satellite + constellations. It uses a Maximum Weight Independent Set (MWIS) algorithm to solve the scheduling problem + while considering: + 1. Interference constraints between nearby ground stations + 2. SNR-based link quality optimization + 3. Spreading factor selection for LoRa transmissions + 4. Constellation dynamics and satellite field-of-view changes + + The scheduler operates in discrete time intervals and generates schedule files for each satellite and + ground station, which are consumed by the CosMAC downlink models. + + Output format: Each node gets a schedule file (schedule_nodeID.pkl) containing a list of + (start_time, end_time, spreading_factor) tuples. +''' +import threading +import numpy as np +import os +import sys +import json +import pickle +import itertools + +import networkx as nx + +#Let's make the python interpreter look for the modules in the main directory +sys.path.append(os.getcwd()) + +from scipy.optimize import linear_sum_assignment +from src.global_schedulers.Coloring_MWIS_heuristics import greedy_MWIS +from src.global_schedulers.iglobalscheduler import IGlobalScheduler +from src.sim.simulator import Simulator +from src.nodes.inode import ENodeType + +from src.models.network.lora.loralink import LoraLink + +class CosmacScheduler(IGlobalScheduler): + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the SMA. + An API offered by the SMA can be invoked through this method. + @param[in] _apiName + Name of the API. Each SMA should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + pass + + def schedule_NextPause(self, timestep): + """ + @desc + This method schedules the next pause for the simulator. + See the ManagerParallel pause API for more details. + @param[in] timestep + The timestep at which the simulation should be paused + @return + a threading.Condition() object which will be notified when the simulation is paused + """ + print(f"[CosmacScheduler]: Scheduling the next pause at timestep {timestep}") + return self.__sim.call_RuntimeAPIs( "pause_AtTime", + _timestep = timestep) + + def perfect_ordering(self, adj): + ordering = [] + n = adj.shape[0] + w = np.zeros(n) + numbers = np.arange(1, n + 1) + vertices = np.arange(1, n + 1) + + for _ in range(n): + idx = np.argmax(w) + ordering.append(vertices[idx]) + neigh = numbers[adj[vertices[idx] - 1] > 0] + + to_increase = np.intersect1d(neigh, vertices) + id = [np.where(vertices == x)[0][0] for x in to_increase] + + w[id] = w[id] + 1 + w = np.concatenate((w[:idx], w[idx + 1:])) + vertices = np.concatenate((vertices[:idx], vertices[idx + 1:])) + + return ordering + + def weight_indep(self, adj, w): + """ + @desc + Finds the Maximum Weight Independent Set of a graph + @param[in] adj + The adjacency matrix of the graph + @param[in] w + The weight vector of the graph + @return + The selected vertices and the weight of the independent set + """ + + if len(w) != adj.shape[0]: + raise ValueError("The length of w must be equal to the number of vertices in the graph") + + ordering = self.perfect_ordering(adj) + ordering = np.fliplr([ordering])[0] + vertices = np.arange(1, ordering.size + 1) + red = np.zeros(ordering.size, dtype=int) + blue = np.zeros(ordering.size, dtype=int) + w_temp = w[ordering - 1] + + for i in range(ordering.size): + if w_temp[i] > 0: + red[i] = 1 + vert_temp = ordering[i:] + neigh_vert = vertices[adj[ordering[i] - 1] == 1] - 1 + vert_reduce = np.intersect1d(neigh_vert, vert_temp) + for j in range(i + 1, ordering.size): + if np.sum(vert_reduce == ordering[j]) > 0: + w_temp[j] = w_temp[j] - w_temp[i] + if w_temp[j] < 0: + w_temp[j] = 0 + w_temp[i] = 0 + + for i in range(ordering.size): + idx = ordering.size - i - 1 + if red[idx] == 1: + vert_chosen = ordering[blue == 1] + if np.sum(adj[ordering[idx] - 1, vert_chosen - 1]) == 0: + blue[idx] = 1 + + selected = ordering[blue == 1] + indep_siz = np.sum(w[ordering[blue == 1] - 1]) + return selected, indep_siz + + def get_satFOVs(self): + """ + Retrieves satellite field-of-view information for constellation-aware scheduling. + + This method queries the simulation to get the current visibility relationships + between satellites and ground stations, which is essential for CosMAC's + constellation-aware scheduling decisions. + + @return dict + Dictionary mapping satellite IDs to lists of visible ground station IDs + Format: {sat_id: [gs_id1, gs_id2, ...]} + """ + _topologyList = self.__sim.call_RuntimeAPIs("get_Topologies") + assert len(_topologyList) == 1, "This scheduler only works with one topology" + + _satToFOV = {} #Dict of Satellite ID: List of visible GS ID's + for _topologyIdx in range(len(_topologyList)): + _sats = _topologyList[_topologyIdx].get_NodesOfAType(ENodeType.SAT) + for _sat in _sats: + _fov = self.__sim.call_RuntimeAPIs("call_ModelAPIsByModelName", + _topologyID = _topologyIdx, + _nodeID = _sat.nodeID, + _modelName = "ModelFovTimeBased", + _apiName = "get_View", + _apiArgs = { + "_targetNodeTypes": [ENodeType.GS], + "_isDownView": True, + "_myTime": None, + "__myLocation": None + }) + _satToFOV[_sat.nodeID] = _fov + return _satToFOV + + def get_GlobalGraph(self): + """ + Creates the global interference graph for CosMAC scheduling optimization. + + This method constructs a graph representation of the satellite constellation + communication scenario, where: + 1. Nodes represent potential satellite-to-ground station links + 2. Edges represent interference constraints between links + 3. Node weights represent link quality (SNR-based) + + The scheduler then solves a Maximum Weight Independent Set (MWIS) problem + to find the optimal set of non-interfering links for each time slot. + + @return numpy.ndarray + Global graph representation with SNR values and interference constraints + """ + # Dynamic constellation parameters (replaces hardcoded values) + # Get actual satellite and ground station counts from simulation + _topologyList = self.__sim.call_RuntimeAPIs("get_Topologies") + _sats = _topologyList[0].get_NodesOfAType(ENodeType.SAT) + _gss = _topologyList[0].get_NodesOfAType(ENodeType.GS) + + _nSats = len(_sats) + _nGS = len(_gss) + + # Create dynamic ID mappings based on actual node IDs + _satIDToIdx = {sat.nodeID: idx for idx, sat in enumerate(_sats)} + _gsIDToIdx = {gs.nodeID: idx for idx, gs in enumerate(_gss)} + + if self.__satRadioDevices is None: + self.__satRadioDevices = {} + self.__satIDToRadioDevices = {} + # Cache radio devices for all satellites (dynamic range) + for sat in _sats: + _satID = sat.nodeID + self.__satIDToRadioDevices[_satID] = self.__sim.call_RuntimeAPIs("call_ModelAPIsByModelName", + _topologyID = 0, + _nodeID = _satID, + _modelName = "ModelDownlinkRadio", + _apiName = "get_RadioDevice", + _apiArgs = {}) + + # Cache radio devices for all ground stations (dynamic range) + self.__gsIDToRadioDevices = {} + for gs in _gss: + _gsID = gs.nodeID + self.__gsIDToRadioDevices[_gsID] = self.__sim.call_RuntimeAPIs("call_ModelAPIsByModelName", + _topologyID = 0, + _nodeID = _gsID, + _modelName = "ModelLoraRadio", + _apiName = "get_RadioDevice", + _apiArgs = {}) + + # Cache ground station positions (dynamic range) + _gsToPos = {} + for gs in _gss: + _gsID = gs.nodeID + _gsToPos[_gsID] = self.__sim.call_RuntimeAPIs("get_NodeInfo", + _topologyID = 0, + _nodeID = _gsID, + _infoType = "position") + self.__gsIDToPos = _gsToPos + + _gsPairs = [] + for _gsID1, _gsPos1 in _gsToPos.items(): + for _gsID2, _gsPos2 in _gsToPos.items(): + if _gsID1 != _gsID2 and _gsPos1.get_distance(_gsPos2) < self.__minDistance: + _gsPairs.append((_gsIDToIdx[_gsID1], _gsIDToIdx[_gsID2])) + self.__gsPairs = np.array(_gsPairs) #This is a numpy array of shape (nPairs, 2) + + + #This graph is a numpy array where each column is a satellite and each row is a ground station + #The value is the index of the respective link in the list of links + _globalGraph = np.full((_nGS, _nSats), -1) + _listOfLinks = [] + _snrs = [] + + _linkIDXtoSatID = {} + _linkIDXtoGSID = {} + _linkToGS = {} + #Let's get the SNR between each GS and SAT + _satFOVs = self.get_satFOVs() #Dict of SAT ID: List of visible GS ID's + + for _satID, _satFOV in _satFOVs.items(): + #Let's get the SNR between the SAT and each GS + if len(_satFOV) == 0: + continue + + #Let's now setup a hypothetical link between the SAT and each GS + #To do so, we need the: sat radio device, gs radio device, and distance between them + + #Let's get the SAT position to get the distance + _satPosition = self.__sim.call_RuntimeAPIs("get_NodeInfo", + _topologyID = 0, + _nodeID = _satID, + _infoType = "position") + + for _gsID in _satFOV: + _gsPosition = self.__gsIDToPos[_gsID] + _distance = _satPosition.get_distance(_gsPosition) + + _satRadioDevice = self.__satIDToRadioDevices[_satID] + _gsRadioDevice = self.__gsIDToRadioDevices[_gsID] + + _link = LoraLink(_satRadioDevice, _gsRadioDevice, _distance) + + _listOfLinks.append(_link) + _snrs.append(_link.get_SNR()) + + #Let's now add this link to the global graph + _satIdx = _satIDToIdx.get(_satID, len(_satIDToIdx)) + _satIDToIdx[_satID] = _satIdx + + _gsIdx = _gsIDToIdx.get(_gsID, len(_gsIDToIdx)) + _gsIDToIdx[_gsID] = _gsIdx + + _globalGraph[_gsIdx, _satIdx] = len(_listOfLinks) - 1 + + _linkIDXtoSatID[len(_listOfLinks) - 1] = _satID + _linkIDXtoGSID[len(_listOfLinks) - 1] = _gsID + _linkToGS[_link] = _gsID + + _adj = np.zeros((len(_listOfLinks), len(_listOfLinks))) + + #No need to check wether two links are the same - will remove duplicates later + nGS, nSats = _globalGraph.shape + for _gsIdx1, _gsIdx2 in self.__gsPairs: + #get all the values in each of these two rows which are not -1 + row1ValidLinks = np.where(_globalGraph[_gsIdx1, :] != -1)[0] + row2ValidLinks = np.where(_globalGraph[_gsIdx2, :] != -1)[0] + + #This is the indicies of the columns which both rows have a valid link (i.e. the cases where both talk to a sat) + sharedColumns = np.intersect1d(row1ValidLinks, row2ValidLinks) + + #Now, we want to get the indicies of the links in the _listOfLinks + _gs1LinkIdxs = _globalGraph[_gsIdx1, sharedColumns] + _gs2LinkIdxs = _globalGraph[_gsIdx2, sharedColumns] + + #Now, draw an edge between each of these links (don't draw an edge between the same link or between links of the same gs) + _adj[_gs1LinkIdxs, _gs2LinkIdxs] = 1 + _adj[_gs2LinkIdxs, _gs1LinkIdxs] = 1 + + # _adj1 = np.zeros((len(_listOfLinks), len(_listOfLinks))) + # for _satID, _links in _satToLinks.items(): + # for _link1 in _links: + # for _link2 in _links: + # if _link1 != _link2: + # _gs1 = _linksToGS[_link1] + # _gs2 = _linksToGS[_link2] + # if _gs1 != _gs2 and _gsIDToPos[_gs1].get_distance(_gsIDToPos[_gs2]) < self.__minDistance: + # _adj1[_linksToIndex[_link1], _linksToIndex[_link2]] = 1 + # _adj1[_linksToIndex[_link2], _linksToIndex[_link1]] = 1 + + #Now, we have to connect each link which shares a gs to each other + #so for each gs + for _rowIdx in range(nGS): + #Get each column which has a valid link (i.e. the gs which talk to this sat) + _validCols = np.where(_globalGraph[_rowIdx, :] != -1)[0] + #Get the indicies of the links + _validLinks = _globalGraph[_rowIdx, _validCols] + #Now, we want to draw an edge between each of these links + _rows, _cols = np.meshgrid(_validLinks, _validLinks) + _adj[_rows, _cols] = 1 + + # for _gsID, _links in _gsToLinks.items(): + # #Connect all of the links which share a GS + # for _link1 in _links: + # for _link2 in _links: + # if _link1 != _link2 and _linksToSat[_link1] != _linksToSat[_link2]: + # _adj1[_linksToIndex[_link1], _linksToIndex[_link2]] = 1 + # _adj1[_linksToIndex[_link2], _linksToIndex[_link1]] = 1 + + for _row in range(nGS): + #Get each col which has a valid link + _satIndxs = np.argwhere(_globalGraph[_row, :] != -1).flatten() + + #for each column we need to connect the (_row, _col) to (:, _col) + for _satIdx in _satIndxs: + for _otherSatIdx in _satIndxs: + if _satIdx != _otherSatIdx: + _thisLink = _globalGraph[_row, _satIdx] + + #Count the number of non -1 values in this column + _secondCol = np.argwhere(_globalGraph[:, _otherSatIdx] != -1).flatten() + _secondLinks = _globalGraph[_secondCol, _otherSatIdx] + + _adj[_thisLink, _secondLinks] = 1 + _adj[_secondLinks, _thisLink] = 1 + + # for _gsID, _links in _gsToLinks.items(): + # #Connect all of the links which share a GS + # for _link1 in _links: + # for _link2 in _links: + # #Now, we connect all of the links in one satellite to all of the links in another satellite if they share any ground stations + # _sat2 = _linksToSat[_link2] + # _sat1 = _linksToSat[_link1] + # if _sat1 == _sat2: + # continue + # for _link3 in _satToLinks[_sat2]: + # if _link3 != _link1: + # _adj1[_linksToIndex[_link1], _linksToIndex[_link3]] = 1 + # _adj1[_linksToIndex[_link3], _linksToIndex[_link1]] = 1 + + np.fill_diagonal(_adj, 0) + + # Log graph statistics for CosMAC analysis + print(f"[CosmacScheduler] Interference graph - Nodes: {len(_listOfLinks)}, Edges: {np.sum(_adj)//2}") + + _weights = np.array(_snrs) + 20 + + #grph = nx.from_numpy_array(_adj) + #print("Number of nodes: {}".format(len(grph.nodes()))) + pi = {i: _weights[i] for i in range(len(_weights))} + print(f"[CosmacScheduler] Running MWIS optimization for {len(_listOfLinks)} potential links") + _scheduledLinkIndicies, weight = greedy_MWIS(_adj, pi, 1, 100, False) + print(f"[CosmacScheduler] MWIS solution - Selected links: {len(_scheduledLinkIndicies)}, Total weight: {weight:.2f}") + + _satsToGoodLinks = {} + for _linkIdx in _scheduledLinkIndicies: + _link = _listOfLinks[_linkIdx] + _satID = _linkIDXtoSatID[_linkIdx] + if _satID not in _satsToGoodLinks: + _satsToGoodLinks[_satID] = [] + _satsToGoodLinks[_satID].append(_link) + + _time = self.__sim.call_RuntimeAPIs("get_NodeInfo", + _topologyID = 0, + _nodeID = 1, + _infoType = "time") + + # Use configurable sample count for reliability estimation + _nSamples = self.__nSamples + for _satID, _links in _satsToGoodLinks.items(): + # CosMAC spreading factor optimization + _targetSF = 11 # Default SF for LoRa + for _sf in range(7, 12): # Extended SF range for better optimization + _bers = np.array([_links.get_BER(_sf) for _links in _links]) + _pCorrect = 1 - _bers + _sumCorrect = np.sum(_pCorrect) + + #_pCorrectNormalized is a vector of the probability of each link being correct (nLinks, 1) + _pCorrectNormalized = _pCorrect / _sumCorrect + + #Make a (nSamples x nLinks) matrix where each row is a 1 + _sampleMatrix = np.zeros((_nSamples, len(_links))) + #for each column, randomly (ber) set the value to 0 + for _col in range(_sampleMatrix.shape[1]): + _sampleMatrix[:, _col] = np.random.choice([0, 1], size=_nSamples, p=[_bers[_col], 1 - _bers[_col]]) + + #_output is a vector of the probability of each sample being correct (nSamples, 1) + _output = _sampleMatrix @ _pCorrectNormalized + _nZeros = np.argwhere(_output < .5).flatten() + # CosMAC reliability threshold (configurable) + if len(_nZeros)/_nSamples < self.__reliabilityThreshold: + _targetSF = _sf + break + + if _satID not in self.__satsToSchedule: + self.__satsToSchedule[_satID] = [] + self.__satsToSchedule[_satID].append([_time.copy(), _time.copy().add_seconds(60), _targetSF]) + + for _link in _links: + _gs = _linkToGS[_link] + if _gs not in self.__gsToSchedule: + self.__gsToSchedule[_gs] = [] + self.__gsToSchedule[_gs].append([_time.copy(), _time.copy().add_seconds(60), _targetSF]) + + def Execute(self): + """ + Main execution loop for the CosMAC global scheduler. + + This method implements the constellation-aware scheduling algorithm: + 1. Starts the simulation in a separate thread + 2. Periodically pauses simulation to compute optimal schedules + 3. Uses MWIS algorithm to solve interference-constrained optimization + 4. Generates and saves schedule files for all nodes + 5. Continues until simulation completion + + The scheduler operates with configurable granularity and saves schedules + periodically to handle dynamic constellation changes. + """ + #The simulation is already setup. + #Set a pause for t = 0 + _waitingCondition = self.schedule_NextPause(0) #Threading.Event() + self.start_Simulation() + + #While the sim's thread is alive, we will run the algorithm + i = 1 + while self.__threadSim.is_alive(): + if _waitingCondition is not None and not _waitingCondition.is_set(): + print("[CosmacScheduler] Waiting for the next pause (t = " + str(self.__timestepGranularity * i) + "s)") + _waitingCondition.wait() + + # Generate the global interference graph for CosMAC scheduling + print("[CosmacScheduler] Computing constellation-aware scheduling graph") + _globalGraph = self.get_GlobalGraph() + + if i % 20 == 0: + print("Saving schedule at t = {}s".format(self.__timestepGranularity * i)) + self.save_Schedule() + + #Set the next pause. + _waitingCondition = self.schedule_NextPause(self.__timestepGranularity * i) + self.__sim.call_RuntimeAPIs("resume") + + i += 1 + + + self.save_Schedule() + + #Let's wait for the simulation to finish + self.__threadSim.join() + + def save_Schedule(self): + """ + Saves the computed CosMAC schedule to pickle files. + + Creates schedule files for each satellite and ground station containing + their transmission schedules. These files are consumed by the CosMAC + downlink models during simulation. + + File format: schedule_.pkl containing list of (start_time, end_time, SF) tuples + """ + #Let's create the schedule folder if it doesn't exist + if not os.path.exists(self.__scheduleFolder): + os.makedirs(self.__scheduleFolder) + + #Let's now save the schedule + for _satID in self.__satsToSchedule.keys(): + _schedule = self.__satsToSchedule[_satID] + _scheduleFile = os.path.join(self.__scheduleFolder, "schedule_" + str(_satID) + ".pkl") + _scheduleFile = open(_scheduleFile, "wb") + pickle.dump(_schedule, _scheduleFile) + _scheduleFile.close() + + for _gsID in self.__gsToSchedule.keys(): + _schedule = self.__gsToSchedule[_gsID] + _scheduleFile = os.path.join(self.__scheduleFolder, "schedule_" + str(_gsID) + ".pkl") + _scheduleFile = open(_scheduleFile, "wb") + pickle.dump(_schedule, _scheduleFile) + _scheduleFile.close() + + def setup_Simulation(self): + """ + This method starts the simulation. + """ + self.__sim = Simulator(self.__configPath) + self.__sim.call_RuntimeAPIs("load_FOVs", _inputPath=self.__fovPath) + #We need to run the simulation in a separate thread + self.__threadSim = threading.Thread(target=self.__sim.execute) + + def start_Simulation(self): + """ + This method starts the simulation. + """ + self.__threadSim.start() + + def __init__(self, + _configPath, + _granularity, + _scheduleFolder): + ''' + Constructor for CosmacScheduler. + + Initializes the CosMAC global scheduler with configuration parameters + for constellation-aware scheduling optimization. + + @param[in] _configPath: str + Path to the simulation configuration file containing network topology, + node parameters, and simulation settings + @param[in] _granularity: int + Scheduling granularity in seconds - determines how frequently the + scheduler recomputes optimal schedules to adapt to constellation dynamics + @param[in] _scheduleFolder: str + Output directory where schedule files will be stored for consumption + by CosMAC downlink models during simulation + + @raises FileNotFoundError: If configuration file cannot be found + @raises ValueError: If granularity is not positive + ''' + self.__configPath = _configPath + + #Load the config file. We need to get "delta" + _config = json.load(open(_configPath)) + _delta = _config["simtime"]["delta"] + self.__timeGranularity = _granularity + self.__timestepGranularity = _granularity / _delta + print(self.__timestepGranularity) + + self.__sim = None + self.__threadSim = None + + self.__satsToSchedule = {} #satID -> list of (start, end, sf) + self.__gsToSchedule = {} #gsID -> list of (start, end, sf) + + self.__scheduleFolder = _scheduleFolder + print(self.__scheduleFolder) + + # CosMAC protocol parameters (configurable) + self.__minDistance = 1000 # Minimum distance (meters) for interference consideration + self.__reliabilityThreshold = 1e-4 # Packet error rate threshold for SF selection + self.__nSamples = 10000 # Monte Carlo samples for reliability estimation + self.__satRadioDevices = None + + # Field-of-view data path for constellation dynamics + self.__fovPath = "/scratch/ochabra2/indata/downlink0709_0.pkl" # TODO: Make configurable + +if __name__ == "__main__": + """ + Main entry point for the CosMAC global scheduler. + + Usage: python3 cosmacScheduler.py + + Arguments: + config_file: Path to simulation configuration JSON file + granularity: Scheduling granularity in seconds (e.g., 60 for 1-minute intervals) + output_folder: Directory where schedule files will be saved + + Example: + python3 cosmacScheduler.py configs/config_cosmac.json 60 schedules/ + """ + if len(sys.argv) != 4: + print("Usage: python3 cosmacScheduler.py ") + print(" config_file: Path to simulation configuration JSON file") + print(" granularity: Scheduling granularity in seconds") + print(" output_folder: Directory where schedule files will be saved") + sys.exit(1) + + try: + # Create and execute the CosMAC scheduler + _scheduler = CosmacScheduler( + _configPath=sys.argv[1], + _granularity=int(sys.argv[2]), + _scheduleFolder=sys.argv[3]) + _scheduler.setup_Simulation() + _scheduler.Execute() + + print("[CosmacScheduler] Schedule generation completed successfully!") + + except Exception as e: + print(f"[CosmacScheduler] Error: {e}") + sys.exit(1) \ No newline at end of file diff --git a/src/models/models_cosmac/modelcosmacdownlink.py b/src/models/models_cosmac/modelcosmacdownlink.py new file mode 100644 index 0000000..7d4c857 --- /dev/null +++ b/src/models/models_cosmac/modelcosmacdownlink.py @@ -0,0 +1,260 @@ +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 8 June 2023 +Updated: 2024 + +@desc + This model implements the CosMAC (Constellation-Aware Medium Access and Scheduling for IoT Satellites) downlink protocol. + Based on the paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + This model manages MAC layer functionality for satellite-to-ground transmission in IoT satellite constellations. + It uses a pre-loaded schedule to determine transmission times and spreading factors (SF), enabling efficient + downlink communication from satellites to IoT devices on the ground. + + The model coordinates with downlink radio models and data storage/generation models to transmit data packets + according to a predetermined schedule that considers constellation dynamics and IoT device requirements. +''' + +from src.models.imodel import IModel, EModelTag +from src.nodes.inode import ENodeType, INode +from src.simlogging.ilogger import ILogger +from src.models.network.macdata.macdata import MACData +import pickle + +class ModelCosmacDownlink(IModel): + __modeltag = EModelTag.MAC + __ownernode: INode + __supportednodeclasses = ['SatelliteBasic'] + __dependencies = [['ModelDownlinkRadio'], + ['ModelDataStore', 'ModelDataGenerator']] + + @property + def iName(self) -> str: + """ + @type + str + @desc + A string representing the name of the model class. For example, ModelPower + Note that the name should exactly match to your class name. + """ + return self.__class__.__name__ + + @property + def modelTag(self) -> EModelTag: + """ + @type + EModelTag + @desc + The model tag for the implemented model + """ + return self.__modeltag + + @property + def ownerNode(self): + """ + @type + INode + @desc + Instance of the owner node that incorporates this model instance. + The subclass (implementing a model) should keep a private variable holding the owner node instance. + This method can return that variable. + """ + return self.__ownernode + + @property + def supportedNodeClasses(self) -> 'list[str]': + ''' + @type + List of string + @desc + A model may not support all the node implementation. + supportedNodeClasses gives the list of names of the node implementation classes that it supports. + For example, if a model supports only the SatBasic and SatAdvanced, the list should be ['SatBasic', 'SatAdvanced'] + If the model supports all the node implementations, just keep the list EMPTY. + ''' + return self.__supportednodeclasses + + @property + def dependencyModelClasses(self) -> 'list[list[str]]': + ''' + @type + Nested list of string + @desc + dependencyModelClasses gives the nested list of name of the model implementations that this model has dependency on. + For example, if a model has dependency on the ModelPower and ModelOrbitalBasic, the list should be [['ModelPower'], ['ModelOrbitalBasic']]. + Now, if the model can work with EITHER of the ModelOrbitalBasic OR ModelOrbitalAdvanced, the these two should come under one sublist looking like [['ModelPower'], ['ModelOrbitalBasic', 'ModelOrbitalAdvanced']]. + So each exclusively dependent model should be in a separate sublist and all the models that can work with either of the dependent models should be in the same sublist. + If your model does not have any dependency, just keep the list EMPTY. + ''' + return self.__dependencies + + def __str__(self) -> str: + return "".join(["Model name: ", self.iName + ", " , "Model tag: " + self.__modeltag.__str__()]) + + # API dictionary where API name is the key and handler function is the value + __apiHandlerDictionary = { + } + + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the model. + An API offered by the model can be invoked through this method. + @param[in] _apiName + Name of the API. Each model should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + _ret = None + + try: + _ret = self.__apiHandlerDictionary[_apiName](self, _kwargs) + except Exception as e: + print(f"[ModelCosmacDownlink]: An unhandled API request has been received by {self.__ownernode.nodeID}:", e) + + return _ret + + + def Execute(self): + """ + Main execution method called at each simulation timestep. + + This method: + 1. Initializes required models (downlink radio, data store/generator) on first run + 2. Checks the pre-loaded schedule to determine if it's time to transmit + 3. Retrieves data from the data store/generator if needed + 4. Packages data into MAC frames and transmits via the downlink radio + 5. Advances to the next scheduled transmission slot + + @raises Exception: If no data store or data generator model is found + """ + if self.__downlinkModel is None: + self.__downlinkModel = self.__ownernode.has_ModelWithName("ModelDownlinkRadio") + self.__dataStore = self.__ownernode.has_ModelWithTag(EModelTag.DATASTORE) + if self.__dataStore is None: + self.__dataStore = self.__ownernode.has_ModelWithTag(EModelTag.DATAGENERATOR) + if self.__dataStore is None: + raise Exception(f"[ModelCosmacDownlink]: The node {self.__ownernode.nodeID} does not have a data store or data generator model") + + if self.__currentScheduleIndex == len(self.__loadedSchedule): + return + + #For now, the downlink just checks the schedule file and sends the data at the appropriate times + #Check if it is time to send the next packet + #The start and end time is [start, end) + if self.__ownernode.timestamp >= self.__loadedSchedule[self.__currentScheduleIndex][0] and \ + self.__ownernode.timestamp < self.__loadedSchedule[self.__currentScheduleIndex][1]: + #It is time to send the next packet + #Assign the SF + + if self.__currentData is None: + #We don't have any data loaded. Let's load the next data + _data = self.__dataStore.call_APIs("get_Data") + + #If there is data, let's package it + if _data is not None: + _time = self.__ownernode.timestamp.copy() + _size = _data.size + 4 #TODO: change this to the actual size + _payload = pickle.dumps(_data) + _data = MACData(creationTime=_time, + sourceRadioID=self.__downlinkModel.radioID, + size=_size, + intendedRadioID=-1, + sequenceNumber=self.__currentSequenceNumber, + dataPayloadString=_payload) + self.__currentData = _data + self.__currentSequenceNumber += 1 + + #If we were able to load data, let's send it + if self.__currentData is not None: + _success = self.__downlinkModel.call_APIs("send_Packet", _packet = self.__currentData) + if _success: + self.__currentData = None + + #Check if we need to move to the next schedule + while self.__currentScheduleIndex < len(self.__loadedSchedule) and self.__ownernode.timestamp >= self.__loadedSchedule[self.__currentScheduleIndex][1]: + self.__currentScheduleIndex += 1 + + def __init__( + self, + _ownernodeins: INode, + _loggerins: ILogger, + _scheduleFile: str) -> None: + ''' + Constructor for ModelCosmacDownlink. + + Initializes the COSMAC downlink model with a pre-loaded transmission schedule. + The schedule determines when and how data should be transmitted. + + @param[in] _ownernodeins: INode + Instance of the owner node that incorporates this model instance + @param[in] _loggerins: ILogger + Logger instance for recording model events and debugging + @param[in] _scheduleFile: str + Path to a pickle file containing the transmission schedule. + Schedule format: List of tuples (start_time, end_time, spreading_factor) + + @raises AssertionError: If _ownernodeins or _loggerins is None + @raises FileNotFoundError: If _scheduleFile cannot be found or loaded + ''' + assert _ownernodeins is not None + assert _loggerins is not None + + self.__ownernode = _ownernodeins + self.__logger = _loggerins + + self.__scheduleFile = _scheduleFile + self.__loadedSchedule = pickle.load(open(self.__scheduleFile, "rb")) + + # Process loaded schedule: if there are only two indices instead of three, + # set end time to 60 seconds after start time with default SF of 11 + for _index, _schedule in enumerate(self.__loadedSchedule): + if type(_schedule[0]) is int: + self.__loadedSchedule[_index] = (_schedule[1], _schedule[1].copy().add_seconds(60), 11) + + # Initialize state variables + self.__currentScheduleIndex = 0 + self.__downlinkModel = None # Will be initialized on first Execute() call + self.__dataStore = None # Will be initialized on first Execute() call + self.__currentSequenceNumber = 0 + self.__currentData = None # Currently queued data packet + +def init_ModelCosmacDownlink( + _ownernodeins: INode, + _loggerins: ILogger, + _modelArgs) -> IModel: + ''' + Factory function to initialize a ModelCosmacDownlink instance. + + This function serves as the standard initialization interface for the COSMAC downlink model, + extracting configuration parameters from the provided arguments. + + @param[in] _ownernodeins: INode + Instance of the owner node that incorporates this model instance + @param[in] _loggerins: ILogger + Logger instance for recording model events + @param[in] _modelArgs: object + Configuration object containing model-specific parameters + Required attributes: + - schedule_file (str): Path to pickle file with transmission schedule + + @return IModel + Initialized instance of ModelCosmacDownlink + + @raises AssertionError: If _ownernodeins or _loggerins is None + @raises AttributeError: If required attributes are missing from _modelArgs + ''' + + assert _ownernodeins is not None + assert _loggerins is not None + + return ModelCosmacDownlink(_ownernodeins, + _loggerins, + _modelArgs.schedule_file) diff --git a/src/models/models_cosmac/modelcosmacgateway.py b/src/models/models_cosmac/modelcosmacgateway.py new file mode 100644 index 0000000..ca31628 --- /dev/null +++ b/src/models/models_cosmac/modelcosmacgateway.py @@ -0,0 +1,440 @@ +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 8 June 2023 +@desc + This model implements the CosMAC (Constellation-Aware Medium Access and Scheduling for IoT Satellites) gateway protocol. + Based on the paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + This MAC Layer model is designed for satellite gateway functionality in IoT satellite constellations. + It manages uplink communication from IoT devices and implements adaptive collision control through: + + 1. Beacon transmission with adaptive alpha tuning based on collision feedback + 2. Uplink packet reception and ACK transmission + 3. Collision detection and adaptive scheduling parameter adjustment + 4. Data storage coordination for received IoT packets + + The model operates through the following steps: + 1. Examines the RX queue of the ModelAggregatorRadio (uplink) for received packets + 2. Transmits acknowledgments (ACK) back to devices using the uplink radio + 3. Stores received packets in the satellite's local storage + 4. Sends periodic beacons with collision control information + 5. Adapts transmission parameters based on collision statistics +''' + +import pickle +import numpy as np +import random + +from src.models.imodel import IModel, EModelTag +from src.nodes.inode import ENodeType, INode +from src.simlogging.ilogger import ELogType, ILogger +from src.models.network.macdata.macbeacon import MACBeacon +from src.models.network.macdata.macack import MACAck + +from src.utils import Time + +class ModelCosmacGateway(IModel): + __modeltag = EModelTag.MAC + __ownernode: INode + __supportednodeclasses = ['SatelliteBasic'] + __dependencies = [['ModelAggregatorRadio'], + ['ModelDataStore']] + + @property + def iName(self) -> str: + """ + @type + str + @desc + A string representing the name of the model class. For example, ModelPower + Note that the name should exactly match to your class name. + """ + return self.__class__.__name__ + + @property + def modelTag(self) -> EModelTag: + """ + @type + EModelTag + @desc + The model tag for the implemented model + """ + return self.__modeltag + + @property + def ownerNode(self): + """ + @type + INode + @desc + Instance of the owner node that incorporates this model instance. + The subclass (implementing a model) should keep a private variable holding the owner node instance. + This method can return that variable. + """ + return self.__ownernode + + @property + def supportedNodeClasses(self) -> 'list[str]': + ''' + @type + List of string + @desc + A model may not support all the node implementation. + supportedNodeClasses gives the list of names of the node implementation classes that it supports. + For example, if a model supports only the SatBasic and SatAdvanced, the list should be ['SatBasic', 'SatAdvanced'] + If the model supports all the node implementations, just keep the list EMPTY. + ''' + return self.__supportednodeclasses + + @property + def dependencyModelClasses(self) -> 'list[list[str]]': + ''' + @type + Nested list of string + @desc + dependencyModelClasses gives the nested list of name of the model implementations that this model has dependency on. + For example, if a model has dependency on the ModelPower and ModelOrbitalBasic, the list should be [['ModelPower'], ['ModelOrbitalBasic']]. + Now, if the model can work with EITHER of the ModelOrbitalBasic OR ModelOrbitalAdvanced, the these two should come under one sublist looking like [['ModelPower'], ['ModelOrbitalBasic', 'ModelOrbitalAdvanced']]. + So each exclusively dependent model should be in a separate sublist and all the models that can work with either of the dependent models should be in the same sublist. + If your model does not have any dependency, just keep the list EMPTY. + ''' + return self.__dependencies + + def __str__(self) -> str: + return "".join(["Model name: ", self.iName + ", " , "Model tag: " + self.__modeltag.__str__()]) + + # API dictionary where API name is the key and handler function is the value + __apiHandlerDictionary = { + } + + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the model. + An API offered by the model can be invoked through this method. + @param[in] _apiName + Name of the API. Each model should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + _ret = None + + try: + _ret = self.__apiHandlerDictionary[_apiName](self, _kwargs) + except Exception as e: + print(f"[ModelCosmacGateway]: An unhandled API request has been received by {self.__ownernode.nodeID}:", e) + + return _ret + + def __get_ReceivedData(self): + """ + @desc + This method returns the received data + @return + List of received data + """ + if self.__uplinkModel is None: + self.__uplinkModel = self.__ownernode.has_ModelWithName("ModelAggregatorRadio") + + #the received data is a list of the received data + _receivedData = [] + while (_data := self.__uplinkModel.call_APIs("get_ReceivedPacket")) is not None: + _receivedData.append(_data) + + return _receivedData + + def Execute(self): + """ + Main execution method for the CosMAC gateway model. + + This method implements the core CosMAC gateway functionality: + 1. Receives and processes uplink packets from IoT devices + 2. Sends acknowledgments for received packets + 3. Tracks collision statistics for adaptive parameter tuning + 4. Transmits periodic beacons with updated scheduling parameters + 5. Adjusts alpha parameters based on collision feedback + + The method operates on a per-timestep basis and maintains state across calls. + """ + # Receive all available data packets + _receivedDatas = self.__get_ReceivedData() + if len(_receivedDatas) > 1: + # Fine granularity assumption: only one packet per timestep expected + raise Exception("More than one data received. This is not expected. Make the granularity finer.") + + #If we have received a data, let's store it + if len(_receivedDatas) == 1: + _receivedData = _receivedDatas[0] + + _currentTime = self.__ownernode.timestamp.copy() + _size = self.__ackSize # ACK packet size in bytes + _ack = MACAck(creationTime=_currentTime, + sourceRadioID=self.__uplinkModel.radioID, + size=_size, + intendedRadioID=_receivedData.sourceRadioID, + sequenceNumber=_receivedData.sequenceNumber + 1, + receivedMACDataID=_receivedData.id) + + #print(f"Sending ACK with ID {_ack.id}", _ack) + self.__uplinkModel.call_APIs("send_Packet", _packet = _ack, _timeOffset = random.uniform(0, self.__maxAckDelay)) + + + _dataModel = self.__ownernode.has_ModelWithTag(EModelTag.DATASTORE) + if _dataModel is None: + raise Exception("Data storage is not found for owner node: " + str(self.__ownernode.nodeId)) + _dataModel.call_APIs("add_Data", _data = pickle.loads(_receivedData.dataPayloadString)) + + #If we're alpha tuning, we need to keep track of the collisions. + if True: + _collisionHappened = self.__uplinkModel.call_APIs("collision_Happened") + if _collisionHappened: + #we have a collision, let's store it + self.__collisionHistory.append((self.__ownernode.timestamp.copy().add_seconds(-1))) #The collision technically happened one second ago + #self.__logger.write_Log("Collision happened", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + + #Now, let's do the alpha tuning logic at the start of every 60 seconds. Don't do this until after the first beacon is sent + if True and self.__nextMinuteTime <= self.__ownernode.timestamp and self.__beaconSequenceNumber > 0: + #self.__logger.write_Log("Collision buffer: " + str(self.__collisionHistory), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + #We need to go through and group the collisions by the slot + + _startSlot = self.__nextMinuteTime.copy().add_seconds(-60) #Start of each slot. + _endOfSlot = _startSlot.copy().add_seconds(self.__slotLength) #End of each slot + _nSlotsWithCollisions = 0 #Number of slots with collisions + while _endOfSlot <= self.__nextMinuteTime: + _nCollisions = 0 + for _time in self.__collisionHistory: + if _startSlot <= _time and _time < _endOfSlot: + _nCollisions += 1 + if _nCollisions > 0: + _nSlotsWithCollisions += 1 + + _startSlot.add_seconds(self.__slotLength) #move to the next slot + _endOfSlot.add_seconds(self.__slotLength) #move to the next slot + + #To get a percentage, we need to find the number of slots with collisions and divide by the total number of slots + + #Let's count the number of slots with collisions in the last 60 seconds + _nSlots = 60 / self.__slotLength - 1 #We don't count the first slot since it's not a full slot as the beacon is still being sent + _collisionPercentage = _nSlotsWithCollisions / _nSlots + + self.__logger.write_Log("Collision percentage: " + str(_collisionPercentage), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + + #add the percentage to the history. If more than _windowInterval, remove the first one - we only want to keep the last _windowInterval + if len(self.__collisionPercentageHistory) >= self.__windowInterval: + self.__collisionPercentageHistory.pop(0) #The first one is the oldest one, so remove it + self.__collisionPercentageHistory.append(_collisionPercentage) #add the new one + + self.__collisionHistory = [] + + self.__nextMinuteTime = self.__getNextMinuteTime() + + #Now, when we are ready to send the beacon, see if we need to change the alpha + if self.__nextBeaconTime <= self.__ownernode.timestamp: + _increaseAlpha = None + + if self.__fovModel is None: + self.__fovModel = self.__ownernode.has_ModelWithTag(EModelTag.VIEWOFNODE) + + _nIot = len(self.__fovModel.call_APIs("get_View", _targetNodeTypes = [ENodeType.IOTDEVICE], _isDownView=True)) + self.__logger.write_Log(f"Number of IoT devices in view: {_nIot}, alpha tuning: {self.__alphaTuning}, window interval: {self.__windowInterval}", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + #If we have a collision percentage history, we can do the alpha tuning + if self.__alphaTuning and len(self.__collisionPercentageHistory) >= self.__windowInterval and _nIot > 0: + self.__logger.write_Log(f"Collision percentage history: {self.__collisionPercentageHistory}", ELogType.LOGDEBUG, self.__ownernode.timestamp, self.iName) + #Let's first fit a linear model to the collision percentage history + + #We want to make a line where the most recent minute's collision percentage is at the y-intercept and the previous minutes and -1, -2, -3, etc. are the x values + _xVals = np.array(range(-len(self.__collisionPercentageHistory) + 1, 1)) + _yVals = np.array(self.__collisionPercentageHistory) + + #Let's fit a line to the data + _slope, _intercept = np.polyfit(_xVals, _yVals, 1) + + #Let's find what the ideal collision percentage is + + #P(N >= 2) = 1 - P(N = 0) - P(N = 1) + #pIdealInOne = 1/n + #P(N = 0) = (1 - p)^n = (1 - 1/n)^n + #P(N = 1) = n * p * (1 - p)^(n-1) = n * 1/n * (1 - 1/n)^(n-1) = (1 - 1/n)^(n-1) + #P(N >= 2)= 1 - [(1-1/n)^(n-1) + (1-1/n)^n] + _pECIdeal = 1 - ((1 - 1/_nIot)**(_nIot - 1) + (1 - 1/_nIot)**_nIot) + + _pECupper = _pECIdeal * 2 + _pEClower = _pECIdeal * 0.5 + + #If the slope is non-negative, and the intercept is greater than our threshold, we decrease alpha + if (_slope >= 0 and _intercept >= _pECupper) or _intercept >= 2 * _pECIdeal: + _increaseAlpha = False + #If the slope is non-positive, and the intercept is less than our threshold, we increase alpha + elif (_intercept <= _pEClower): + _increaseAlpha = True + + self.__logger.write_Log(f"Decided to increase alpha: {_increaseAlpha}. Slope: {_slope}, Intercept: {_intercept}, pEC: {_pECIdeal}, pECupper: {_pECupper}, pEClower: {_pEClower}, history {self.__collisionPercentageHistory} ", + ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + + if _increaseAlpha is not None: + self.__collisionPercentageHistory = [] + + self.__nextMinuteTime = self.__getNextMinuteTime() + + _beacon = MACBeacon(creationTime=self.__ownernode.timestamp.copy(), + sourceRadioID=self.__ownernode.nodeID, + size = self.__beaconSize, + intendedRadioID=-1, + sequenceNumber=self.__beaconSequenceNumber, + numDevicesInView=_nIot, + alphaIncrease=_increaseAlpha) + self.__beaconSequenceNumber += 1 + + if self.__beaconSequenceNumber == 1: + self.__nextMinuteTime = self.__getNextMinuteTime() + + self.__uplinkModel.call_APIs("set_Frequency", _frequency = self.__beaconFrequency) + self.__uplinkModel.call_APIs("send_Packet", _packet = _beacon) + self.__uplinkModel.call_APIs("set_Frequency", _frequency = self.__ulFrequency) + + self.__nextBeaconTime = self.__getNextBeaconTime() + self.__logger.write_Log("Next beacon time {}".format(self.__nextBeaconTime), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + + + beaconCount = 0 + def __init__( + self, + _ownernodeins: INode, + _loggerins: ILogger, + _beaconInterval: int, + _beaconBackoff: int, + _alphaTuning: bool, + _windowInterval: int, + _slotLength: int) -> None: + ''' + Constructor for ModelCosmacGateway. + + Initializes the CosMAC gateway model with parameters for beacon transmission, + collision detection, and adaptive alpha tuning. + + @param[in] _ownernodeins: INode + Instance of the owner node (satellite) that incorporates this model + @param[in] _loggerins: ILogger + Logger instance for recording model events and debugging + @param[in] _beaconInterval: int + Interval between beacon transmissions in seconds + @param[in] _beaconBackoff: int + Backoff time for beacon transmissions in seconds (currently unused) + @param[in] _alphaTuning: bool + Enable adaptive alpha tuning based on collision feedback + @param[in] _windowInterval: int + Time window for collision statistics collection (in minutes) + @param[in] _slotLength: int + Duration of each transmission slot in seconds + + @raises AssertionError: If _ownernodeins or _loggerins is None + ''' + assert _ownernodeins is not None + assert _loggerins is not None + + self.__ownernode = _ownernodeins + self.__logger = _loggerins + + #For beacons: + self.__beaconInterval = _beaconInterval #seconds + self.__beaconBackoff = _beaconBackoff #seconds + self.__getNextBeaconTime = lambda: self.__ownernode.timestamp.copy().add_seconds(self.__beaconInterval) + self.__beaconSequenceNumber = 0 + + self.__nextBeaconTime = self.__ownernode.timestamp.copy().add_seconds(ModelCosmacGateway.beaconCount) + ModelCosmacGateway.beaconCount += 1 + + #For alpha tuning: + self.__alphaTuning = _alphaTuning + self.__windowInterval = _windowInterval #minutes + self.__slotLength = _slotLength + + self.__getNextMinuteTime = lambda: self.__ownernode.timestamp.copy().add_seconds(60) + + self.__nextMinuteTime = self.__getNextMinuteTime() + + self.__collisionHistory = [] # This is a list of timestamps when collisions happened + self.__collisionPercentageHistory = [] # This is a list of percentage of slots with collisions in one minute windows + + self.__uplinkModel = None + self.__fovModel = None + + # Radio frequencies (configurable) + self.__beaconFrequency = 0.4013e9 # 401.3 MHz - beacon frequency + self.__ulFrequency = 0.4015e9 # 401.5 MHz - uplink frequency + + # Packet sizes (configurable) + self.__ackSize = 4 # ACK packet size in bytes + self.__beaconSize = 4 # Beacon packet size in bytes + self.__maxAckDelay = 1.5 # Maximum random delay for ACK transmission + + +def init_ModelCosmacGateway( + _ownernodeins: INode, + _loggerins: ILogger, + _modelArgs) -> IModel: + ''' + Factory function to initialize a ModelCosmacGateway instance. + + Creates and configures a CosMAC gateway model instance with the provided parameters. + This function serves as the standard initialization interface for the simulator. + + @param[in] _ownernodeins: INode + Instance of the owner satellite node + @param[in] _loggerins: ILogger + Logger instance for recording model events + @param[in] _modelArgs: object + Configuration object containing model-specific parameters + Required attributes: + - beacon_backoff (int): Backoff time for beacon transmissions + - alpha_tuning (bool): Enable adaptive alpha tuning + - window_interval (int): Collision statistics window in minutes + + @return IModel + Initialized instance of ModelCosmacGateway + + @raises AssertionError: If _ownernodeins or _loggerins is None + @raises AttributeError: If required attributes are missing from _modelArgs + + @note + beacon_interval is hardcoded to 120 seconds and slot_length to 2 seconds + as per CosMAC protocol specifications. + ''' + + assert _ownernodeins is not None + assert _loggerins is not None + + return ModelCosmacGateway(_ownernodeins, + _loggerins, + 120, + _modelArgs.beacon_backoff, + _modelArgs.alpha_tuning, + _modelArgs.window_interval, + 2) + + +# self.__logger.write_Log(f"Received MACData with ID {_receivedData.id}", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# #Let's create the acks +# _currentTime = self.__ownernode.timestamp.copy() +# _size = 4 #(I'm assuming that the data size is 4 bytes) +# _ack = MACAck(creationTime=_currentTime, +# sourceRadioID=_uplinkModel.radioID, +# size=_size, +# intendedRadioID=_receivedData.sourceRadioID, +# sequenceNumber=_receivedData.sequenceNumber + 1, +# receivedMACDataID=_receivedData.id) + +# #Let's send the ack. +# _success = self.__send_Ack(_ack) +# if not _success: +# #The ack could not be sent. This is likely because either we don't have enough power or now the iot device is out of range +# #Let's log this but keep going. We can't do anything about it +# self.__logger.write_Log(f"Could not send ack for MACData with ID {_receivedData.id}", ELogType.LOGWARN, self.__ownernode.timestamp, self.iName) + diff --git a/src/models/models_cosmac/modelcosmacgs.py b/src/models/models_cosmac/modelcosmacgs.py new file mode 100644 index 0000000..c649c73 --- /dev/null +++ b/src/models/models_cosmac/modelcosmacgs.py @@ -0,0 +1,210 @@ +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 8 June 2023 +Updated: 2024 + +@desc + This model implements the CosMAC (Constellation-Aware Medium Access and Scheduling for IoT Satellites) ground station protocol. + Based on the paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + This model manages ground station functionality for receiving and processing data from satellites + in the CosMAC IoT satellite constellation system. It handles: + 1. Reception of MAC data packets from satellites via LoRa radio + 2. Data extraction and storage in the ground station's data store + 3. Integration with the broader CosMAC ecosystem for IoT data collection + + The model operates as a simple receiver, processing all incoming MAC data packets + and storing the payload data for further analysis or forwarding. +''' + +from src.models.imodel import IModel, EModelTag +from src.nodes.inode import ENodeType, INode +from src.simlogging.ilogger import ILogger +from src.models.network.macdata.macdata import MACData +import pickle + +class ModelCosmacGS(IModel): + __modeltag = EModelTag.MAC + __ownernode: INode + __supportednodeclasses = [] + __dependencies = [['ModelLoraRadio'], + ['ModelDataStore']] + + @property + def iName(self) -> str: + """ + @type + str + @desc + A string representing the name of the model class. For example, ModelPower + Note that the name should exactly match to your class name. + """ + return self.__class__.__name__ + + @property + def modelTag(self) -> EModelTag: + """ + @type + EModelTag + @desc + The model tag for the implemented model + """ + return self.__modeltag + + @property + def ownerNode(self): + """ + @type + INode + @desc + Instance of the owner node that incorporates this model instance. + The subclass (implementing a model) should keep a private variable holding the owner node instance. + This method can return that variable. + """ + return self.__ownernode + + @property + def supportedNodeClasses(self) -> 'list[str]': + ''' + @type + List of string + @desc + A model may not support all the node implementation. + supportedNodeClasses gives the list of names of the node implementation classes that it supports. + For example, if a model supports only the SatBasic and SatAdvanced, the list should be ['SatBasic', 'SatAdvanced'] + If the model supports all the node implementations, just keep the list EMPTY. + ''' + return self.__supportednodeclasses + + @property + def dependencyModelClasses(self) -> 'list[list[str]]': + ''' + @type + Nested list of string + @desc + dependencyModelClasses gives the nested list of name of the model implementations that this model has dependency on. + For example, if a model has dependency on the ModelPower and ModelOrbitalBasic, the list should be [['ModelPower'], ['ModelOrbitalBasic']]. + Now, if the model can work with EITHER of the ModelOrbitalBasic OR ModelOrbitalAdvanced, the these two should come under one sublist looking like [['ModelPower'], ['ModelOrbitalBasic', 'ModelOrbitalAdvanced']]. + So each exclusively dependent model should be in a separate sublist and all the models that can work with either of the dependent models should be in the same sublist. + If your model does not have any dependency, just keep the list EMPTY. + ''' + return self.__dependencies + + def __str__(self) -> str: + return "".join(["Model name: ", self.iName + ", " , "Model tag: " + self.__modeltag.__str__()]) + + # API dictionary where API name is the key and handler function is the value + __apiHandlerDictionary = { + } + + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the model. + An API offered by the model can be invoked through this method. + @param[in] _apiName + Name of the API. Each model should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + _ret = None + + try: + _ret = self.__apiHandlerDictionary[_apiName](self, _kwargs) + except Exception as e: + print(f"[ModelCosmacGS]: An unhandled API request has been received by {self.__ownernode.nodeID}:", e) + + return _ret + + + def Execute(self): + """ + Main execution method for the CosMAC ground station model. + + This method: + 1. Initializes required models (LoRa radio, data store) on first execution + 2. Receives all available packets from the LoRa radio interface + 3. Processes MAC data packets and extracts payload data + 4. Stores extracted data in the ground station's data store + + The method operates continuously, processing all available packets + in each execution cycle. + """ + # Initialize required models on first execution + if self.__loraModel is None: + self.__loraModel = self.__ownernode.has_ModelWithTag(EModelTag.BASICLORARADIO) + self.__dataStore = self.__ownernode.has_ModelWithTag(EModelTag.DATASTORE) + + # Receive all available packets + _receivedData = [] + while (_data := self.__loraModel.call_APIs("get_ReceivedPacket")) is not None: + _receivedData.append(_data) + + # Process received MAC data packets + if len(_receivedData) > 0: + for _data in _receivedData: + if isinstance(_data, MACData): + # Extract and store payload data + self.__dataStore.call_APIs("add_Data", _data = pickle.loads(_data.dataPayloadString)) + + def __init__( + self, + _ownernodeins: INode, + _loggerins: ILogger) -> None: + ''' + Constructor for ModelCosmacGS. + + Initializes the CosMAC ground station model for receiving and processing + data from satellites in the constellation. + + @param[in] _ownernodeins: INode + Instance of the ground station node that incorporates this model + @param[in] _loggerins: ILogger + Logger instance for recording model events and debugging + + @raises AssertionError: If _ownernodeins or _loggerins is None + ''' + assert _ownernodeins is not None + assert _loggerins is not None + + self.__ownernode = _ownernodeins + self.__logger = _loggerins + + self.__dataStore = None + self.__loraModel = None + +def init_ModelCosmacGS( + _ownernodeins: INode, + _loggerins: ILogger, + _modelArgs) -> IModel: + ''' + Factory function to initialize a ModelCosmacGS instance. + + Creates and configures a CosMAC ground station model instance. + This function serves as the standard initialization interface for the simulator. + + @param[in] _ownernodeins: INode + Instance of the ground station node + @param[in] _loggerins: ILogger + Logger instance for recording model events + @param[in] _modelArgs: object + Configuration object (currently unused for ground station model) + + @return IModel + Initialized instance of ModelCosmacGS + + @raises AssertionError: If _ownernodeins or _loggerins is None + ''' + + assert _ownernodeins is not None + assert _loggerins is not None + + return ModelCosmacGS(_ownernodeins, + _loggerins) \ No newline at end of file diff --git a/src/models/models_cosmac/modelcosmaciot.py b/src/models/models_cosmac/modelcosmaciot.py new file mode 100644 index 0000000..ebd4cab --- /dev/null +++ b/src/models/models_cosmac/modelcosmaciot.py @@ -0,0 +1,471 @@ +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 8 June 2023 +Updated: 2024 + +@desc + This model implements the CosMAC (Constellation-Aware Medium Access and Scheduling for IoT Satellites) IoT device protocol. + Based on the paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + This model controls IoT device behavior in satellite constellation communication, implementing: + 1. Beacon-based transmission scheduling with adaptive alpha parameters + 2. Multi-satellite beacon tracking and coordination + 3. Collision-aware transmission probability adjustment + 4. Acknowledgment-based reliable data transmission + 5. State machine for efficient packet transmission workflow + + The model operates through a 6-state machine: + - State 1: No data to send (idle) + - State 2: Data available, waiting for beacon + - State 3: Beacon received, calculating transmission probability + - State 4: Backoff period, waiting for transmission slot + - State 5: Transmitting data packet + - State 6: Waiting for acknowledgment +''' +from src.models.imodel import IModel, EModelTag +from src.nodes.inode import ENodeType, INode +from src.simlogging.ilogger import ELogType, ILogger +from src.models.network.macdata.macbeacon import MACBeacon +from src.models.network.macdata.macdata import MACData +from src.models.network.macdata.macack import MACAck +from src.utils import Time +import numpy as np + +import random +import pickle + +class ModelCosmacIoT(IModel): + __modeltag = EModelTag.MAC + __ownernode: INode + __supportednodeclasses = ['IoTBasic'] + __dependencies = [['ModelGenericRadio', 'ModelLoraRadio', 'ModelDownlinkRadio', 'ModelAggregatorRadio']] + + @property + def iName(self) -> str: + """ + @type + str + @desc + A string representing the name of the model class. For example, ModelPower + Note that the name should exactly match to your class name. + """ + return self.__class__.__name__ + + @property + def modelTag(self) -> EModelTag: + """ + @type + EModelTag + @desc + The model tag for the implemented model + """ + return self.__modeltag + + @property + def ownerNode(self): + """ + @type + INode + @desc + Instance of the owner node that incorporates this model instance. + The subclass (implementing a model) should keep a private variable holding the owner node instance. + This method can return that variable. + """ + return self.__ownernode + + @property + def supportedNodeClasses(self) -> 'list[str]': + ''' + @type + List of string + @desc + A model may not support all the node implementation. + supportedNodeClasses gives the list of names of the node implementation classes that it supports. + For example, if a model supports only the SatBasic and SatAdvanced, the list should be ['SatBasic', 'SatAdvanced'] + If the model supports all the node implementations, just keep the list EMPTY. + ''' + return self.__supportednodeclasses + + @property + def dependencyModelClasses(self) -> 'list[list[str]]': + ''' + @type + Nested list of string + @desc + dependencyModelClasses gives the nested list of name of the model implementations that this model has dependency on. + For example, if a model has dependency on the ModelPower and ModelOrbitalBasic, the list should be [['ModelPower'], ['ModelOrbitalBasic']]. + Now, if the model can work with EITHER of the ModelOrbitalBasic OR ModelOrbitalAdvanced, the these two should come under one sublist looking like [['ModelPower'], ['ModelOrbitalBasic', 'ModelOrbitalAdvanced']]. + So each exclusively dependent model should be in a separate sublist and all the models that can work with either of the dependent models should be in the same sublist. + If your model does not have any dependency, just keep the list EMPTY. + ''' + return self.__dependencies + + def __str__(self) -> str: + return "".join(["Model name: ", self.iName + ", " , "Model tag: " + self.__modeltag.__str__()]) + + # API dictionary where API name is the key and handler function is the value + __apiHandlerDictionary = { + } + + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the model. + An API offered by the model can be invoked through this method. + @param[in] _apiName + Name of the API. Each model should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + _ret = None + + try: + _ret = self.__apiHandlerDictionary[_apiName](self, _kwargs) + except Exception as e: + print(f"[ModelCosmacIoT]: An unhandled API request has been received by {self.__ownernode.nodeID}:", e) + + return _ret + + def __get_ReceivedData(self): + """ + @desc + This method returns all the received data from the LoRa radio model. It will empty the received data buffer of the LoRa radio model. + @return + List of received data + """ + _receivedData = [] + while (_data := self.__loraModel.call_APIs("get_ReceivedPacket")) is not None: + _receivedData.append(_data) + + return _receivedData + + def __check_BeaconsReceived(self, _receivedData): + """ + @desc + This method returns if a beacon is received + @param[in] _receivedData + List of received data. This should be the output of __get_ReceivedData and should contain either acks or beacons + @return + Nothing. Update the beacons dictionary + """ + for _data in _receivedData: + if isinstance(_data, MACBeacon): + self.__beacons[_data.sourceRadioID] = (self.__ownernode.timestamp.copy().add_seconds(self.__beaconInterval), _data.numDevicesInView) + self.__mostRecentBeaconTime = _data.creationTime.copy() + self.__waitingForNewBeacon = False #we have received a beacon. We are not waiting for a new one + + if _data.alphaIncrease == True: + self.__alpha += self.__alphaIncrease + #self.__logger.write_Log(f"Alpha: {self.__alpha}", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + elif _data.alphaIncrease == False: + self.__alpha *= self.__alphaDecrease + #self.__logger.write_Log(f"Alpha: {self.__alpha}", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + + self.__nextBeaconTime = min([i[0] for i in self.__beacons.values()]) + + if self.__nextBeaconTime is not None and self.__nextBeaconTime <= self.__ownernode.timestamp: + _toRemove = [] + for _radioID, _beacon in self.__beacons.items(): + if _beacon[0] <= self.__ownernode.timestamp: + _toRemove.append(_radioID) + for _radioID in _toRemove: + self.__beacons.pop(_radioID) + + def __send_Data(self): + """ + @desc + This method sends the data through the radio + @return + True if the data is sent, False otherwise + """ + return self.__loraModel.call_APIs("send_Packet", _packet = self.__currentData) + + def __check_AcksReceived(self, _desiredData, _receivedData): + """ + @desc + This method returns the received data + @param[in] _desiredData + The MACData unit that we are waiting for an ack + @param[in] _receivedData + List of received data. This should be the output of __get_ReceivedData and should contain either acks or beacons + @return + True if the ack is received, False otherwise + """ + for _data in _receivedData: + if isinstance(_data, MACAck) and _data.receivedMACDataID == _desiredData.id: + return True + return False + + def Execute(self): + """ + Main execution method for the CosMAC IoT device model. + + Implements the CosMAC protocol state machine for IoT devices: + 1. Manages data generation and queuing + 2. Tracks beacons from multiple satellites + 3. Calculates transmission probabilities based on constellation state + 4. Handles backoff and transmission timing + 5. Manages acknowledgment reception and retransmission + + The method maintains state across calls and adapts behavior based on + received beacons and collision feedback from satellites. + """ + # Initialize required models on first execution + if self.__loraModel is None: + self.__loraModel = self.__ownernode.has_ModelWithTag(EModelTag.BASICLORARADIO) + self.__dataGenerator = self.__ownernode.has_ModelWithTag(EModelTag.DATAGENERATOR) + + _receivedData = self.__get_ReceivedData() + # Okay, here we need to always check if we have received a beacon + # State 1: We have no data to send + # State 2: We have data to send and are waiting for a beacon + # State 3: We have received a beacon. Set a backoff period before sending data + # State 4: We are in the backoff period and waiting to send data + # State 5: We are past the backoff period and sending data + # State 6: We have sent the data and are waiting for an ACK + + #We always check if we have received a beacon + self.__check_BeaconsReceived(_receivedData) + + # #State 1: We have no data to send + if self.__currentState == 1: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__beaconFrequency) + + #let's see if we can get some + _data = self.__dataGenerator.call_APIs("get_Data") + if _data is not None: + #We need to add the MAC header to the data + _time = self.__ownernode.timestamp.copy() + _payload = pickle.dumps(_data) + _size = self.__packetSize # Data packet size in bytes + _macData = MACData(creationTime=_time, + sourceRadioID=self.__loraModel.radioID, + size=_size, + intendedRadioID=-1, + sequenceNumber=self.__sequenceNumber, + dataPayloadString=_payload) + + #self.__logger.write_Log(f"Data to send: " + str(_macData), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__sequenceNumber += 1 + self.__currentData = _macData + + #we have data to send. Proceed to state 2 + self.__currentState = 2 + + #State 2: We have data to send and are waiting for a beacon + if self.__currentState == 2: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__beaconFrequency) + + _beaconsReceived = len(self.__beacons) > 0 and not self.__waitingForNewBeacon + if _beaconsReceived: + #We have received a beacon. Let's go to state 3 + self.__currentState = 3 + + #State 3: We have received a beacon. Set a backoff period before sending data + if self.__currentState == 3: + #If we're here, we always need to wait for a new beacon + self.__waitingForNewBeacon = True + + _totalDevices = sum([vals[1] for satID, vals in self.__beacons.items()]) + if _totalDevices == 0: + _prob = 1 + else: + _prob = self.__alpha / _totalDevices * self.__nSlots + #Generate n random numbers between 0 and 1. If any of them is less than _prob, then we will transmit in that slot + #_randNums = np.random.rand(self.__nSlots - 1) #Ignore the first slot. + _random = random.random() + + #_transmitSlots = np.argwhere(_randNums < _prob).flatten() + #_transmitSlots += 1 #Add 1 to account for the first slot that we ignored + + #if len(_transmitSlots) == 0: + if _random > _prob: + #we didn't get a slot. Let's try again later. Move to state 2 and wait for another beacon + self.__currentState = 2 + else: + _slot = random.randint(1, self.__nSlots - 1) + self.__logger.write_Log(f"Probability of transmission: " + str(_prob*1/self.__nSlots) + ". Number of devices: "+ str(_totalDevices) + ". Number of beacons" + str(len(self.__beacons)), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + #we got a slot. Let's find the first one + self.__transmitTime = self.__mostRecentBeaconTime.copy().add_seconds(int(_slot) * self.__slotLength) + + #self.__logger.write_Log(f"Backing off till: " + str(self.__transmitTime), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 4 + + #State 4: We are in the backoff period and waiting to send data + if self.__currentState == 4: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__ulFrequency) + + #Let's check if the backoff period is over + if self.__transmitTime <= self.__ownernode.timestamp: + #we should send the data. Let's go to state 5 + self.__currentState = 5 + + #State 5: We are past the backoff period and sending data + if self.__currentState == 5: + #let's send the data + _success = self.__send_Data() + self.__retransmitTime = self.__ownernode.timestamp.copy().add_seconds(self.__retransmitInterval) + #we have sent the data. Let's go to state 6 + #self.__currentState = 1 + #self.__currentData = None + self.__currentState = 6 + + #State 6: listen for acks + if self.__currentState == 6: + #if we have received the desired ack, we can go back to state 1. + if self.__check_AcksReceived(self.__currentData, _receivedData): + self.__logger.write_Log("Ack received", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 1 + self.__currentData = None + + # if passed the timeout, we need to go back to state 2 and retransmit + elif self.__retransmitTime <= self.__ownernode.timestamp: + self.__logger.write_Log("Timeout on ack. Retransmitting", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 2 + + else: + #we are still waiting for the ack. Let's continue waiting. State remains 6 + return + + + def __init__( + self, + _ownernodeins: INode, + _loggerins: ILogger, + _slotLength: int, + _beaconInterval: int, + _alpha: float, + _alphaDecrease: float, + _alphaIncrease: float) -> None: + ''' + Constructor for ModelCosmacIoT. + + Initializes the CosMAC IoT device model with transmission parameters + and adaptive alpha tuning capabilities. + + @param[in] _ownernodeins: INode + Instance of the IoT device node that incorporates this model + @param[in] _loggerins: ILogger + Logger instance for recording model events and debugging + @param[in] _slotLength: int + Duration of each transmission slot in seconds + @param[in] _beaconInterval: int + Expected interval between beacon transmissions in seconds + @param[in] _alpha: float + Base alpha parameter for transmission probability calculation + @param[in] _alphaDecrease: float + Multiplicative factor for decreasing alpha (collision avoidance) + @param[in] _alphaIncrease: float + Additive factor for increasing alpha (improved channel utilization) + + @raises AssertionError: If _ownernodeins or _loggerins is None + + @note + Alpha parameters are currently hardcoded in the constructor and + override the provided values. This should be addressed in future versions. + ''' + assert _ownernodeins is not None + assert _loggerins is not None + + self.__ownernode = _ownernodeins + self.__logger = _loggerins + + self.__loraModel = None #the lora model instance + + self.__currentState = 1 #the current state of the model + self.__currentData = None #the data that is currently being sent + + self.__slotLength = _slotLength #the length of each slot in seconds + self.__beaconInterval = _beaconInterval + + self.__nSlots = int(_beaconInterval/_slotLength) #the number of slots in a beacon interval + self.__sequenceNumber = 0 #the sequence number of the data packet + + # Alpha parameters for transmission probability (use provided values) + self.__alpha = _alpha # Base alpha value for TPF model + self.__alphaIncrease = _alphaIncrease # Additive increase factor + self.__alphaDecrease = _alphaDecrease # Multiplicative decrease factor + + self.__beacons = {} #Dict of satID -> (timestamp, numDevices) + self.__waitingForNewBeacon = True #Flag to indicate if we are waiting for a new beacon + self.__nextBeaconTime = None #the time of the next beacon + self.__mostRecentBeaconTime = None #the time of the most recent beacon + + # Radio frequencies (configurable) + self.__beaconFrequency = 0.4013e9 # 401.3 MHz - beacon frequency + self.__ulFrequency = 0.4015e9 # 401.5 MHz - uplink frequency + + # Protocol parameters (configurable) + self.__retransmitInterval = 30 # ACK timeout in seconds + self.__packetSize = 100 # Data packet size in bytes + +def init_ModelCosmacIoT( + _ownernodeins: INode, + _loggerins: ILogger, + _modelArgs) -> IModel: + ''' + Factory function to initialize a ModelCosmacIoT instance. + + Creates and configures a CosMAC IoT device model with the provided parameters. + This function serves as the standard initialization interface for the simulator. + + @param[in] _ownernodeins: INode + Instance of the IoT device node + @param[in] _loggerins: ILogger + Logger instance for recording model events + @param[in] _modelArgs: object + Configuration object containing model-specific parameters + Required attributes: + - alpha (float): Base alpha parameter for transmission probability + - alpha_decrease (float): Multiplicative factor for alpha reduction + - alpha_increase (float): Additive factor for alpha increase + + @return IModel + Initialized instance of ModelCosmacIoT + + @raises AssertionError: If _ownernodeins or _loggerins is None + @raises AttributeError: If required attributes are missing from _modelArgs + + @note + slot_length is hardcoded to 2 seconds and beacon_interval to 120 seconds + as per CosMAC protocol specifications. + ''' + + assert _ownernodeins is not None + assert _loggerins is not None + + return ModelCosmacIoT( _ownernodeins, + _loggerins, + 2, + 120, + _modelArgs.alpha, + _modelArgs.alpha_decrease, + _modelArgs.alpha_increase) + + + + + + +# #We handle the state 6 first because it deals with the previous timestamp (waiting for ack) +# if self.__currentState == 6: +# #if we have received the desired ack, we can go back to state 1. +# if self.__check_AcksReceived(self.__currentData, _receivedData): +# self.__logger.write_Log("Ack received", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# self.__currentState = 1 + +# # if passed the timeout, we need to go back to state 2 and retransmit +# elif self.__transmitTime.copy().add_seconds(self.__retransmitInterval) <= self.__ownernode.timestamp: +# self.__logger.write_Log("Timeout on ack. Retransmitting", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# self.__currentState = 2 + +# else: +# #we are still waiting for the ack. Let's continue waiting. State remains 6 +# return diff --git a/src/models/models_cosmac/modelmaciottpf.py b/src/models/models_cosmac/modelmaciottpf.py new file mode 100644 index 0000000..22368dd --- /dev/null +++ b/src/models/models_cosmac/modelmaciottpf.py @@ -0,0 +1,418 @@ +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 8 June 2023 +Updated: 2024 + +@desc + This model implements the CosMAC (Constellation-Aware Medium Access and Scheduling for IoT Satellites) + MAC IoT with Transmission Probability Function (TPF) protocol. + Based on the paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + This model controls IoT device behavior using a transmission probability function approach. + It implements a simplified version of the CosMAC protocol with: + 1. Beacon-based transmission scheduling + 2. Probabilistic transmission decisions based on device count + 3. Acknowledgment-based reliability + 4. Fixed alpha parameter (no adaptive tuning) + + The model operates through a 6-state machine similar to ModelCosmacIoT but with + simpler probability calculations and fixed parameters. +''' +from src.models.imodel import IModel, EModelTag +from src.nodes.inode import INode +from src.simlogging.ilogger import ELogType, ILogger +from src.models.network.macdata.macbeacon import MACBeacon +from src.models.network.macdata.macdata import MACData +from src.models.network.macdata.macack import MACAck + +import random +import pickle +import numpy as np + +class ModelMACiotTPF(IModel): + __modeltag = EModelTag.MAC + __ownernode: INode + __supportednodeclasses = ['IoTBasic'] + __dependencies = [['ModelGenericRadio', 'ModelLoraRadio', 'ModelDownlinkRadio', 'ModelAggregatorRadio']] + + @property + def iName(self) -> str: + """ + @type + str + @desc + A string representing the name of the model class. For example, ModelPower + Note that the name should exactly match to your class name. + """ + return self.__class__.__name__ + + @property + def modelTag(self) -> EModelTag: + """ + @type + EModelTag + @desc + The model tag for the implemented model + """ + return self.__modeltag + + @property + def ownerNode(self): + """ + @type + INode + @desc + Instance of the owner node that incorporates this model instance. + The subclass (implementing a model) should keep a private variable holding the owner node instance. + This method can return that variable. + """ + return self.__ownernode + + @property + def supportedNodeClasses(self) -> 'list[str]': + ''' + @type + List of string + @desc + A model may not support all the node implementation. + supportedNodeClasses gives the list of names of the node implementation classes that it supports. + For example, if a model supports only the SatBasic and SatAdvanced, the list should be ['SatBasic', 'SatAdvanced'] + If the model supports all the node implementations, just keep the list EMPTY. + ''' + return self.__supportednodeclasses + + @property + def dependencyModelClasses(self) -> 'list[list[str]]': + ''' + @type + Nested list of string + @desc + dependencyModelClasses gives the nested list of name of the model implementations that this model has dependency on. + For example, if a model has dependency on the ModelPower and ModelOrbitalBasic, the list should be [['ModelPower'], ['ModelOrbitalBasic']]. + Now, if the model can work with EITHER of the ModelOrbitalBasic OR ModelOrbitalAdvanced, the these two should come under one sublist looking like [['ModelPower'], ['ModelOrbitalBasic', 'ModelOrbitalAdvanced']]. + So each exclusively dependent model should be in a separate sublist and all the models that can work with either of the dependent models should be in the same sublist. + If your model does not have any dependency, just keep the list EMPTY. + ''' + return self.__dependencies + + def __str__(self) -> str: + return "".join(["Model name: ", self.iName + ", " , "Model tag: " + self.__modeltag.__str__()]) + + # API dictionary where API name is the key and handler function is the value + __apiHandlerDictionary = { + } + + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the model. + An API offered by the model can be invoked through this method. + @param[in] _apiName + Name of the API. Each model should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + _ret = None + + try: + _ret = self.__apiHandlerDictionary[_apiName](self, _kwargs) + except Exception as e: + print(f"[ModelMACiotTPF]: An unhandled API request has been received by {self.__ownernode.nodeID}:", e) + + return _ret + + def __get_ReceivedData(self): + """ + @desc + This method returns all the received data from the LoRa radio model. It will empty the received data buffer of the LoRa radio model. + @return + List of received data + """ + _receivedData = [] + while (_data := self.__loraModel.call_APIs("get_ReceivedPacket")) is not None: + _receivedData.append(_data) + + return _receivedData + + def __check_BeaconsReceived(self, _receivedData): + """ + @desc + This method returns if a beacon is received + @param[in] _receivedData + List of received data. This should be the output of __get_ReceivedData and should contain either acks or beacons + @return + A tuple of the following: + 1. True if a beacon is received, False otherwise + 2. The number of devices in the footprint if a beacon is received, -1 otherwise + """ + for _data in _receivedData: + if isinstance(_data, MACBeacon): + #self.__logger.write_Log("Received beacon from: " + str(_data.sourceRadioID) + ". Current state:" + str(self.__currentState), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + return (True, _data.numDevicesInView) + return (False, -1) + + def __check_AcksReceived(self, _desiredData, _receivedData): + """ + @desc + This method returns the received data + @param[in] _desiredData + The MACData unit that we are waiting for an ack + @param[in] _receivedData + List of received data. This should be the output of __get_ReceivedData and should contain either acks or beacons + @return + True if the ack is received, False otherwise + """ + for _data in _receivedData: + if isinstance(_data, MACAck) and _data.receivedMACDataID == _desiredData.id: + return True + return False + + def __send_Data(self): + """ + @desc + This method sends the data through the radio + @return + True if the data is sent, False otherwise + """ + return self.__loraModel.call_APIs("send_Packet", _packet = self.__currentData) + + def Execute(self): + """ + Main execution method for the MAC IoT TPF model. + + Implements a simplified CosMAC protocol with transmission probability function: + 1. Data generation and queuing + 2. Beacon reception and processing + 3. Probabilistic transmission scheduling + 4. Acknowledgment handling and retransmission + + Uses a fixed transmission probability based on slot count and device count + without adaptive alpha tuning. + """ + # Initialize LoRa model on first execution + if self.__loraModel is None: + self.__loraModel = self.__ownernode.has_ModelWithTag(EModelTag.BASICLORARADIO) + + _receivedData = self.__get_ReceivedData() + _beaconsReceived, self.__numDevices = self.__check_BeaconsReceived(_receivedData) + # Here are our states + # 1: We have no data to send + # 2: We have data to send and are waiting for a beacon + # 3: We have received a beacon. Set a backoff period + # 4: We are in the backoff period and waiting to send data + # 5: We are past the backoff period and sending data. Don't wait for an ack - go back to state 2 + + #State 1: We have no data to send + if self.__currentState == 1: + #let's see if we can get some + if self.__dataGenerator is None: + self.__dataGenerator = self.__ownernode.has_ModelWithTag(EModelTag.DATAGENERATOR) + + _data = self.__dataGenerator.call_APIs("get_Data") + if _data is not None: + #We need to add the MAC header to the data + _time = self.__ownernode.timestamp.copy() + _payload = pickle.dumps(_data) + _size = self.__packetSize # Data packet size in bytes + _macData = MACData(creationTime=_time, + sourceRadioID=self.__loraModel.radioID, + size=_size, + intendedRadioID=-1, + sequenceNumber=self.__sequenceNumber, + dataPayloadString=_payload) + + #self.__logger.write_Log(f"Data to send: " + str(_macData.id), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__sequenceNumber += 1 + self.__currentData = _macData + + #we have data to send. Proceed to state 2 + self.__currentState = 2 + else: + #we have no data to send. Let's continue waiting. State remains 1 + return + + #State 2: We have data to send and are waiting for a beacon + if self.__currentState == 2: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__beaconFrequency) + + _beaconsReceived, self.__numDevices = self.__check_BeaconsReceived(_receivedData) + if _beaconsReceived: + #We have received a beacon. Let's go to state 3 + self.__currentState = 3 + else: + #Let's continue waiting for a beacon. State remains 2 + return + + #State 3: We have received a beacon. Set a backoff period before sending data + if self.__currentState == 3: + #We have n slots, each of which is m seconds long. + if self.__numDevices == 0: + _prob = 1 + else: + _prob = self.__nSlots/self.__numDevices + + #Generate n random numbers between 0 and 1. If any of them is less than _prob, then we will transmit in that slot + #_randNums = [random.random() for _ in range(self.__nSlots)] + # _transmitSlots = [_randNums[i] < _prob for i in range(self.__nSlots)] + + #if True not in _transmitSlots: + if random.random() > _prob: + #we didn't get a slot. Let's try again later. Move to state 2 and wait for another beacon + self.__currentState = 2 + else: + self.__logger.write_Log(f"Probability of transmission: " + str(_prob), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + _slot = random.randint(0, self.__nSlots - 1) + #we got a slot. Let's find the first one + #_slot = _transmitSlots.index(True) + self.__transmitTime = self.__ownernode.timestamp.copy().add_seconds(_slot * self.__slotLength) + + self.__currentState = 4 + + #State 4: We are in the backoff period and waiting to send data + if self.__currentState == 4: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__ulFrequency) + #Let's check if the backoff period is over + if self.__transmitTime <= self.__ownernode.timestamp: + #we should send the data. Let's go to state 5 + self.__currentState = 5 + else: + #we should not send the data. Let's try again later. State remains 4 + return + + #State 5: We are past the backoff period and sending data + if self.__currentState == 5: + #let's send the data + _success = self.__send_Data() + self.__currentState = 6 + self.__retransmitTime = self.__ownernode.timestamp.copy().add_seconds(self.__retransmitInterval) + + if self.__currentState == 6: + #if we have received the desired ack, we can go back to state 1. + if self.__check_AcksReceived(self.__currentData, _receivedData): + self.__logger.write_Log("Ack received", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 1 + self.__currentData = None + + + # if passed the timeout, we need to go back to state 2 and retransmit + elif self.__retransmitTime <= self.__ownernode.timestamp: + self.__logger.write_Log("Timeout on ack. Retransmitting", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 2 + + else: + #we are still waiting for the ack. Let's continue waiting. State remains 6 + return + + + def __init__( + self, + _ownernodeins: INode, + _loggerins: ILogger, + _slotLength: int, + _beaconInterval: int) -> None: + ''' + Constructor for ModelMACiotTPF. + + Initializes the MAC IoT TPF model with fixed transmission parameters. + This model provides a simplified version of CosMAC without adaptive tuning. + + @param[in] _ownernodeins: INode + Instance of the IoT device node that incorporates this model + @param[in] _loggerins: ILogger + Logger instance for recording model events and debugging + @param[in] _slotLength: int + Duration of each transmission slot in seconds + @param[in] _beaconInterval: int + Expected interval between beacon transmissions in seconds + + @raises AssertionError: If _ownernodeins or _loggerins is None + ''' + assert _ownernodeins is not None + assert _loggerins is not None + + self.__ownernode = _ownernodeins + self.__logger = _loggerins + + self.__lastBeacon = None #the frame of the last beacon + + self.__numDevices = -1 #the number of other iot devices in the footprint + self.__loraModel = None #the lora model instance + self.__dataGenerator = None #the data generator model instance + + self.__currentState = 1 #the current state of the model + self.__currentData = None #the data that is currently being sent + + self.__slotLength = _slotLength #the length of each slot in seconds + self.__beaconInterval = _beaconInterval + self.__nSlots = int(_beaconInterval/_slotLength) #the number of slots in a beacon interval + self.__sequenceNumber = 0 #the sequence number of the data packet + + # Radio frequencies (configurable) + self.__beaconFrequency = 0.4013e9 # 401.3 MHz - beacon frequency + self.__ulFrequency = 0.4015e9 # 401.5 MHz - uplink frequency + + # Protocol parameters (configurable) + self.__retransmitInterval = 30 # ACK timeout in seconds + self.__packetSize = 100 # Data packet size in bytes + +def init_ModelMACiotTPF( + _ownernodeins: INode, + _loggerins: ILogger, + _modelArgs) -> IModel: + ''' + Factory function to initialize a ModelMACiotTPF instance. + + Creates a simplified CosMAC IoT device model with fixed transmission parameters. + This variant uses transmission probability functions without adaptive tuning. + + @param[in] _ownernodeins: INode + Instance of the IoT device node + @param[in] _loggerins: ILogger + Logger instance for recording model events + @param[in] _modelArgs: object + Configuration object (currently unused - parameters are hardcoded) + + @return IModel + Initialized instance of ModelMACiotTPF + + @raises AssertionError: If _ownernodeins or _loggerins is None + + @note + slot_length is hardcoded to 2 seconds and beacon_interval to 120 seconds. + ''' + + assert _ownernodeins is not None + assert _loggerins is not None + + return ModelMACiotTPF( _ownernodeins, + _loggerins, + 2, + 120) + + + + + + +# #We handle the state 6 first because it deals with the previous timestamp (waiting for ack) +# if self.__currentState == 6: +# #if we have received the desired ack, we can go back to state 1. +# if self.__check_AcksReceived(self.__currentData, _receivedData): +# self.__logger.write_Log("Ack received", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# self.__currentState = 1 + +# # if passed the timeout, we need to go back to state 2 and retransmit +# elif self.__transmitTime.copy().add_seconds(self.__retransmitInterval) <= self.__ownernode.timestamp: +# self.__logger.write_Log("Timeout on ack. Retransmitting", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# self.__currentState = 2 + +# else: +# #we are still waiting for the ack. Let's continue waiting. State remains 6 +# return diff --git a/src/models/models_cosmac/modelslottedaloha.py b/src/models/models_cosmac/modelslottedaloha.py new file mode 100644 index 0000000..3e5cec2 --- /dev/null +++ b/src/models/models_cosmac/modelslottedaloha.py @@ -0,0 +1,406 @@ +''' +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +Created by: Om Chabra +Created on: 8 June 2023 +Updated: 2024 + +@desc + This model implements a Slotted ALOHA variant for comparison with the CosMAC protocol. + Based on the CosMAC paper: https://deepakv.web.illinois.edu/assets/papers/CosMAC_MobiCom_2024.pdf + + This model provides a baseline slotted ALOHA implementation for IoT satellite communication. + It includes: + 1. Beacon-based slot synchronization + 2. Random backoff transmission scheduling + 3. Fixed transmission probability + 4. Acknowledgment-based reliability + + This serves as a comparison protocol to demonstrate the improvements provided + by the CosMAC constellation-aware approach. +''' +from src.models.imodel import IModel, EModelTag +from src.nodes.inode import INode +from src.simlogging.ilogger import ELogType, ILogger +from src.models.network.macdata.macbeacon import MACBeacon +from src.models.network.macdata.macdata import MACData +from src.models.network.macdata.macack import MACAck + +import random +import pickle +import numpy as np + +class ModelSlottedAloha(IModel): + __modeltag = EModelTag.MAC + __ownernode: INode + __supportednodeclasses = ['IoTBasic'] + __dependencies = [['ModelGenericRadio', 'ModelLoraRadio', 'ModelDownlinkRadio', 'ModelAggregatorRadio']] + + @property + def iName(self) -> str: + """ + @type + str + @desc + A string representing the name of the model class. For example, ModelPower + Note that the name should exactly match to your class name. + """ + return self.__class__.__name__ + + @property + def modelTag(self) -> EModelTag: + """ + @type + EModelTag + @desc + The model tag for the implemented model + """ + return self.__modeltag + + @property + def ownerNode(self): + """ + @type + INode + @desc + Instance of the owner node that incorporates this model instance. + The subclass (implementing a model) should keep a private variable holding the owner node instance. + This method can return that variable. + """ + return self.__ownernode + + @property + def supportedNodeClasses(self) -> 'list[str]': + ''' + @type + List of string + @desc + A model may not support all the node implementation. + supportedNodeClasses gives the list of names of the node implementation classes that it supports. + For example, if a model supports only the SatBasic and SatAdvanced, the list should be ['SatBasic', 'SatAdvanced'] + If the model supports all the node implementations, just keep the list EMPTY. + ''' + return self.__supportednodeclasses + + @property + def dependencyModelClasses(self) -> 'list[list[str]]': + ''' + @type + Nested list of string + @desc + dependencyModelClasses gives the nested list of name of the model implementations that this model has dependency on. + For example, if a model has dependency on the ModelPower and ModelOrbitalBasic, the list should be [['ModelPower'], ['ModelOrbitalBasic']]. + Now, if the model can work with EITHER of the ModelOrbitalBasic OR ModelOrbitalAdvanced, the these two should come under one sublist looking like [['ModelPower'], ['ModelOrbitalBasic', 'ModelOrbitalAdvanced']]. + So each exclusively dependent model should be in a separate sublist and all the models that can work with either of the dependent models should be in the same sublist. + If your model does not have any dependency, just keep the list EMPTY. + ''' + return self.__dependencies + + def __str__(self) -> str: + return "".join(["Model name: ", self.iName + ", " , "Model tag: " + self.__modeltag.__str__()]) + + # API dictionary where API name is the key and handler function is the value + __apiHandlerDictionary = { + } + + def call_APIs( + self, + _apiName: str, + **_kwargs): + ''' + This method acts as an API interface of the model. + An API offered by the model can be invoked through this method. + @param[in] _apiName + Name of the API. Each model should have a list of the API names. + @param[in] _kwargs + Keyworded arguments that are passed to the corresponding API handler + @return + The API return + ''' + _ret = None + + try: + _ret = self.__apiHandlerDictionary[_apiName](self, _kwargs) + except Exception as e: + print(f"[ModelSlottedAloha]: An unhandled API request has been received by {self.__ownernode.nodeID}:", e) + + return _ret + + def __get_ReceivedData(self): + """ + @desc + This method returns all the received data from the LoRa radio model. It will empty the received data buffer of the LoRa radio model. + @return + List of received data + """ + _receivedData = [] + while (_data := self.__loraModel.call_APIs("get_ReceivedPacket")) is not None: + _receivedData.append(_data) + + return _receivedData + + def __check_BeaconsReceived(self, _receivedData): + """ + @desc + This method returns if a beacon is received + @param[in] _receivedData + List of received data. This should be the output of __get_ReceivedData and should contain either acks or beacons + @return + A tuple of the following: + 1. True if a beacon is received, False otherwise + 2. The number of devices in the footprint if a beacon is received, -1 otherwise + """ + for _data in _receivedData: + if isinstance(_data, MACBeacon): + #self.__logger.write_Log("Received beacon from: " + str(_data.sourceRadioID) + ". Current state:" + str(self.__currentState), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + return (True, _data.numDevicesInView) + return (False, -1) + + def __send_Data(self): + """ + @desc + This method sends the data through the radio + @return + True if the data is sent, False otherwise + """ + return self.__loraModel.call_APIs("send_Packet", _packet = self.__currentData) + + def __check_AcksReceived(self, _desiredData, _receivedData): + """ + @desc + This method returns the received data + @param[in] _desiredData + The MACData unit that we are waiting for an ack + @param[in] _receivedData + List of received data. This should be the output of __get_ReceivedData and should contain either acks or beacons + @return + True if the ack is received, False otherwise + """ + for _data in _receivedData: + if isinstance(_data, MACAck) and _data.receivedMACDataID == _desiredData.id: + return True + return False + + def Execute(self): + """ + Main execution method for the Slotted ALOHA model. + + Implements a traditional slotted ALOHA protocol for comparison with CosMAC: + 1. Data generation and queuing + 2. Beacon reception for slot synchronization + 3. Random backoff slot selection + 4. Fixed probability transmission decisions + 5. Acknowledgment handling and retransmission + + Uses simpler probability calculations compared to CosMAC's adaptive approach. + """ + # Initialize LoRa model on first execution + if self.__loraModel is None: + self.__loraModel = self.__ownernode.has_ModelWithTag(EModelTag.BASICLORARADIO) + + _receivedData = self.__get_ReceivedData() + _beaconsReceived, self.__numDevices = self.__check_BeaconsReceived(_receivedData) + # Here are our states + # 1: We have no data to send + # 2: We have data to send and are waiting for a beacon + # 3: We have received a beacon. Set a backoff period + # 4: We are in the backoff period and waiting to send data + # 5: We are past the backoff period and sending data. Don't wait for an ack - go back to state 2 + + #State 1: We have no data to send + if self.__currentState == 1: + #let's see if we can get some + if self.__dataGenerator is None: + self.__dataGenerator = self.__ownernode.has_ModelWithTag(EModelTag.DATAGENERATOR) + + _data = self.__dataGenerator.call_APIs("get_Data") + if _data is not None: + #We need to add the MAC header to the data + _time = self.__ownernode.timestamp.copy() + _payload = pickle.dumps(_data) + _size = self.__packetSize # Data packet size in bytes + _macData = MACData(creationTime=_time, + sourceRadioID=self.__loraModel.radioID, + size=_size, + intendedRadioID=-1, + sequenceNumber=self.__sequenceNumber, + dataPayloadString=_payload) + + + #self.__logger.write_Log(f"Data to send: " + str(_macData.id), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__sequenceNumber += 1 + self.__currentData = _macData + + #we have data to send. Proceed to state 2 + self.__currentState = 2 + else: + #we have no data to send. Let's continue waiting. State remains 1 + return + + #State 2: We have data to send and are waiting for a beacon + if self.__currentState == 2: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__beaconFrequency) + _beaconsReceived, self.__numDevices = self.__check_BeaconsReceived(_receivedData) + if _beaconsReceived: + _prob = 2.5 * self.__nSlots / (1e-4) + if random.random() < _prob: + #We have received a beacon. Let's go to state 3 + self.__currentState = 3 + #if the probability is not met, we should not send the data. Let's try again later. State remains 2 + else: + #Let's continue waiting for a beacon. State remains 2 + return + + #State 3: We have received a beacon. Set a backoff period before sending data + if self.__currentState == 3: + #We have n slots, each of which is m seconds long. + _backoff = np.random.randint(1, self.__nSlots) + self.__transmitTime = self.__ownernode.timestamp.copy().add_seconds(_backoff * self.__slotLength) + + #self.__logger.write_Log(f"Backing off till: " + str(self.__transmitTime), ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 4 + + #State 4: We are in the backoff period and waiting to send data + if self.__currentState == 4: + self.__loraModel.call_APIs("set_Frequency", _frequency = self.__ulFrequency) + #Let's check if the backoff period is over + if self.__transmitTime <= self.__ownernode.timestamp: + #we should send the data. Let's go to state 5 + self.__currentState = 5 + else: + #we should not send the data. Let's try again later. State remains 4 + return + + #State 5: We are past the backoff period and sending data + #State 5: We are past the backoff period and sending data + if self.__currentState == 5: + #let's send the data + _success = self.__send_Data() + self.__currentState = 6 + self.__retransmitTime = self.__ownernode.timestamp.copy().add_seconds(self.__retransmitInterval) + + if self.__currentState == 6: + #if we have received the desired ack, we can go back to state 1. + if self.__check_AcksReceived(self.__currentData, _receivedData): + self.__logger.write_Log("Ack received", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 1 + self.__currentData = None + + + # if passed the timeout, we need to go back to state 2 and retransmit + elif self.__retransmitTime <= self.__ownernode.timestamp: + self.__logger.write_Log("Timeout on ack. Retransmitting", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) + self.__currentState = 2 + + else: + #we are still waiting for the ack. Let's continue waiting. State remains 6 + return + + + def __init__( + self, + _ownernodeins: INode, + _loggerins: ILogger, + _slotLength: int, + _beaconInterval: int) -> None: + ''' + Constructor for ModelSlottedAloha. + + Initializes the Slotted ALOHA model for baseline comparison with CosMAC. + Uses traditional slotted ALOHA parameters and algorithms. + + @param[in] _ownernodeins: INode + Instance of the IoT device node that incorporates this model + @param[in] _loggerins: ILogger + Logger instance for recording model events and debugging + @param[in] _slotLength: int + Duration of each transmission slot in seconds + @param[in] _beaconInterval: int + Expected interval between beacon transmissions in seconds + + @raises AssertionError: If _ownernodeins or _loggerins is None + ''' + assert _ownernodeins is not None + assert _loggerins is not None + + self.__ownernode = _ownernodeins + self.__logger = _loggerins + + self.__lastBeacon = None #the frame of the last beacon + + self.__numDevices = -1 #the number of other iot devices in the footprint + self.__loraModel = None #the lora model instance + self.__dataGenerator = None #the data generator model instance + + self.__currentState = 1 #the current state of the model + self.__currentData = None #the data that is currently being sent + + self.__slotLength = _slotLength #the length of each slot in seconds + self.__beaconInterval = _beaconInterval + self.__nSlots = int(_beaconInterval/_slotLength) #the number of slots in a beacon interval + self.__sequenceNumber = 0 #the sequence number of the data packet + + # Radio frequencies (configurable) + self.__beaconFrequency = 0.4013e9 # 401.3 MHz - beacon frequency + self.__ulFrequency = 0.4015e9 # 401.5 MHz - uplink frequency + + # Protocol parameters (configurable) + self.__retransmitInterval = 30 # ACK timeout in seconds + self.__packetSize = 100 # Data packet size in bytes + + +def init_ModelSlottedAloha( + _ownernodeins: INode, + _loggerins: ILogger, + _modelArgs) -> IModel: + ''' + Factory function to initialize a ModelSlottedAloha instance. + + Creates a traditional Slotted ALOHA model for comparison with CosMAC protocols. + This model serves as a baseline to demonstrate CosMAC improvements. + + @param[in] _ownernodeins: INode + Instance of the IoT device node + @param[in] _loggerins: ILogger + Logger instance for recording model events + @param[in] _modelArgs: object + Configuration object (currently unused - parameters are hardcoded) + + @return IModel + Initialized instance of ModelSlottedAloha + + @raises AssertionError: If _ownernodeins or _loggerins is None + + @note + slot_length is hardcoded to 2 seconds and beacon_interval to 120 seconds. + ''' + + assert _ownernodeins is not None + assert _loggerins is not None + + return ModelSlottedAloha( _ownernodeins, + _loggerins, + 2, + 120) + + + + + + +# #We handle the state 6 first because it deals with the previous timestamp (waiting for ack) +# if self.__currentState == 6: +# #if we have received the desired ack, we can go back to state 1. +# if self.__check_AcksReceived(self.__currentData, _receivedData): +# self.__logger.write_Log("Ack received", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# self.__currentState = 1 + +# # if passed the timeout, we need to go back to state 2 and retransmit +# elif self.__transmitTime.copy().add_seconds(self.__retransmitInterval) <= self.__ownernode.timestamp: +# self.__logger.write_Log("Timeout on ack. Retransmitting", ELogType.LOGINFO, self.__ownernode.timestamp, self.iName) +# self.__currentState = 2 + +# else: +# #we are still waiting for the ack. Let's continue waiting. State remains 6 +# return