From c3c8cc17895dd415a9963ae92b40f489cba903eb Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Wed, 17 Sep 2025 12:27:19 -0600 Subject: [PATCH 1/4] Migration to Python 3.13 --- .DS_Store | Bin 0 -> 14340 bytes Dockerfile | 4 +- LocalFeeder/.DS_Store | Bin 0 -> 6148 bytes LocalFeeder/FeederSimulator.py | 78 +++++++--- LocalFeeder/requirements.txt | 4 +- LocalFeeder/sender_cosim.py | 7 +- LocalFeeder/server.py | 37 +++-- LocalFeeder/tests/.DS_Store | Bin 0 -> 6148 bytes broker/Dockerfile | 3 +- broker/oedisi.code-workspace | 14 ++ broker/requirements.txt | 6 +- broker/server.py | 152 +++++++++++++------ lindistflow_federate/requirements.txt | 2 +- measuring_federate/requirements.txt | 4 +- measuring_federate/server.py | 36 +++-- recorder/record_subscription.py | 4 +- recorder/requirements.txt | 4 +- recorder/server.py | 7 +- system.json | 203 ++++++++++++++++++++++++++ wls_federate/requirements.txt | 4 +- 20 files changed, 458 insertions(+), 111 deletions(-) create mode 100644 .DS_Store create mode 100644 LocalFeeder/.DS_Store create mode 100644 LocalFeeder/tests/.DS_Store create mode 100644 broker/oedisi.code-workspace create mode 100644 system.json diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..250a9f034595ee9a8034b192ca453a47cac8b5cc GIT binary patch literal 14340 zcmeHNe~c7Y9e>|B;AS`|0|kyfpjmqcXt~n8oOoA|4LnP~sfXk(&&-kbgLW^Q+H zF%3=Qyv@w}_5Hs0zQ5jk-|r4%jCK@_ZpIRfF%=Kb^+kAnjIlV+rNsOBGb^>Ah42`7 zP}Uh6Wf^9(oy^2#`Z8bfNx-o{N!-nv0Z13L#9$$+s&e+er4Clmyky( z;y}cKhyxJ^A`V0xxOEQjvlmHi^YiE(aUkMA#DNM2`2G;b!}EbsKI7+N>%fa&;dwv5 z!i$75`~cAwFXaQJe8$fODsTiUavzmSE(ZDNXpeJnD(#%xyP)N)U zytn{oG~nmaJK{jZf!Q4BMbk2tgRh-o9xJjuvzWuiSel(coAANWQ()$9+Z`?W-!L&+ z3%pDJ5lIZ%)EM?y_wcBjoA8&hwY}N7b8gl5zx=|DfA72c^7iI&jY{0$R&giB<@L_+ zWiLjQB8SJFlS7{dw9KIfm_feeHaDI}=Em25Sm8#4@pEpK2hCyBpnM5vo`6=Qp%0FT z^PGXkSa>#}{Y*$d-V9N%0Xq*o8~6rrys$yOvZBsuuJnl{n7czySwxDCAXtV8kjF+W$8e{1xJAeC1n#ikp=zp@*4wA2dwY7j;(c4rb;YNXTe`dA z$^O1`=QMRi=l$Cbq)*y8$NePd6$mNaKyefV)2C3a!HS@eU*j7?4JcX6UsGNwA8|Dy ztAcI}84$I95w0H%(qBwo-I5N{jC;D-aQ z4Zz&QK8&)jD=!OFyjA&(bpa=f}LB)+A{BQ9c7;16Fz(4q0cPOILKZ0en?+n}HAL@0zPvN|YiFL>!1X5OLtG;=uhFXdaG3e&iSA&A%me zHZAwbn?3*keZTL6S8(OKd#!$XGjAEX&ez{so|lc|FwM%d;~w(-YzTP`PxV*s!l7~G zmDGol*W-sbarfq)ZCwwQUo_Yz>B2G#K1hM`4B%(M$TVCzU!kD}T=lSQ1Q(>ELg1b04sSAFWhzyA~rqUolGX-H*DoDM0a0rAIiO9AA$#kkm~vn7tk3XDkzS1FNOPM zVcc_}B8W{8vv5Hci7B($TjWRMJv|cy>4To?}3L>yY}qAx%iGH`cmyq?HHm}PQf~1nfap` zZ`5=Q%NaW~Wf_I>kx9$5vbK59$>dCJX-l_ub7lLg#j%dqnsS#9SVdIzPW4DJYn>`u zg)?G~yJB(eF$0U=>l%C(L)E{liJs0_JoYH>8O)n_8>%Xr*T?SB4tgL&)iXX|m$pee zcoJbYP51nkZf$eyke$hzwx%0CTEDh6HZobFR)55A+@`%Zb_D+m_i`&`OmMbyAmP`R`&s49|GAE_ zF7_b4dp(Ao+QANZfj!ACvM;l5uq*6E_7eLk`vvDfT&^8*N?KDU!+Div$m=4ilN|Q+^Xq+s{(^;CPb9A0QPEXNi>GSjj zx=vvirR(D&)b^fLW~uG1gsPxLzdh5kx!(3^^)tWesORZ2|hP&$Ez9m)zT&RV4fpl*ZyTs+m9K_n z&6D+6C~-jbg%S&yhqRBYQLIB-v$kwkRg3a1>=3lyJgssS)>})^@48!s_UrRY)phGp zeMDd2S35R#sjxI%5ml`t(XGLr^p;X}V;`#ISG42<8Z1knSE@evkj9N&XQJNGF_^+g z*YvkQZNJLC#V)hw*^i*Mud?5=-?KNMstl^C(@LSPt7$E*qxH0b?xim3qkh^#NhofL zhG-}4qKBcj2cftlbd<6*N(PkIr3pGk9u=XwAE6m~f<8(Y=`&E`&y|$;Y5KOWfEBfT zxU7(VPCuxxmh$obFQ;<6*HL2mMm#rk;OJI_ixphhJG3_6Uw5Jn`B44u|KFk1Mazyj z5OJUx4$!>RaEc%Ka|KIxm{{^n1Pc;Al literal 0 HcmV?d00001 diff --git a/Dockerfile b/Dockerfile index ca837a6..87e1590 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM python:3.10.6-slim-bullseye +FROM python:3.13-slim-bullseye #USER root RUN apt-get update && apt-get install -y git ssh - +RUN apt install cmake -y RUN mkdir -p /root/.ssh WORKDIR /simulation diff --git a/LocalFeeder/.DS_Store b/LocalFeeder/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..6d98c7eed1b37ec21d0e724bb6fd6270f3c303dc GIT binary patch literal 6148 zcmeHKO>fgc5S?vO>a;?Q5J>cZWQl7P+A0DeE+I{kIB;nY8~~X(Hq_Lyi|sU2RixbE zH}Dq_M}7$W7f$eIb_0o<9(q9tcB0+4AM$te5vlet*e0qIk%PooT}8IQxS!J* zTQV){KqX`JsYiV}ppd*X-nPLiU={e=6yRrf4Nt2_M|6T`_xJPbJ&e=7AE!P%`1Nof zsqsS$rE^(9-jDq>joD=b1Ew^<&?zN&DI`=NP=L}aicpJCkEE|bZz$(dDd;DRG8=T2 zI`Hb#A;!pLl$gSKj=E9e)Dbbq&x8LEQTxB5=G5S2m-z2X{44YT(L$MbMqSK!fN^3- zRbVdJ!ZE&=#&Ezn5s4dmY_8;BUpN2%+oa-?`Q^ndAD=XXMwJAfIiL~pqj8VIu`aK z2JikXV;Qs3isbV%lRZDj(;wwW<`{qRkK!mVwzj^hrAl@A(u%X+T&>F(TZjyK)Y?H#V%Zr&M>YtFTsx9;tGhw&gu`N+W(Zc(wz2B+`=g_*@4ce5l; z6FxLk#?VWMM&1yDqt1(FBIVSgNwx2GC0vFuMTAL2>`63Sqx?Vv%nnJV9Ve{ zBYI#;Q-PYQ%qND@bhNw1uVrweQPWA8j}K)cEAxe-Bs#`-#hp}3qs^@XR)Kj1D!N+d z`+x8J@Bev{tyu-E0{@f(s@myx+E|jgThAB@!F^O*9GxnYoUoz*q5gBr){4 YTmZHVPBfwgX8#C?3^ub0{80sd0zT;m-~a#s literal 0 HcmV?d00001 diff --git a/LocalFeeder/FeederSimulator.py b/LocalFeeder/FeederSimulator.py index b35ba97..66c9c99 100644 --- a/LocalFeeder/FeederSimulator.py +++ b/LocalFeeder/FeederSimulator.py @@ -1,43 +1,55 @@ """Core class to abstract OpenDSS into Feeder class.""" -import csv -import json +from typing import Dict, List, Optional, Set, Tuple +from time import strptime +from enum import Enum import logging -import math -import os import random +import math import time -from enum import Enum -from time import strptime -from typing import Dict, List, Optional, Set, Tuple +import json +import csv +import os -import boto3 -import numpy as np -import opendssdirect as dss -import xarray as xr -from botocore import UNSIGNED +from scipy.sparse import coo_matrix, csc_matrix from botocore.config import Config +from pydantic import BaseModel +from botocore import UNSIGNED +import opendssdirect as dss from dss_functions import ( get_capacitors, get_generators, - get_loads, get_pvsystems, get_voltages, + get_loads, ) +import xarray as xr +import numpy as np +import boto3 from oedisi.types.data_types import ( - Command, - InverterControl, InverterControlMode, + InverterControl, IncidenceList, + Command, ) -from pydantic import BaseModel -from scipy.sparse import coo_matrix, csc_matrix logger = logging.getLogger(__name__) logger.addHandler(logging.StreamHandler()) logger.setLevel(logging.INFO) +def command(command_str:str)-> str: + logger.info(f"OpenDSS Command: {command_str}") + try: + dss.Text.Command(command_str) + result = dss.Text.Result() + logger.info(f"OpenDSS Reply: {result}") + return result + except Exception as e: + logger.error(f"OpenDSS Error: {e}") + raise ValueError(e) + + def permutation(from_list, to_list): """Create permutation representing change in from_list to to_list. @@ -153,13 +165,18 @@ def __init__(self, config: FeederConfig): raise Exception("Set existing_feeder_file when uploading data") else: self._feeder_file = config.existing_feeder_file - + logger.info(f"Using feeder file: {self._feeder_file}") self.open_lines = config.open_lines self.load_feeder() - if self._sensor_location is None: + logger.info("No sensor location provided, creating measurement lists") self.create_measurement_lists() + else: + logger.info( + f"Using sensor location {self._sensor_location}, not creating measurement lists" + ) + logger.info("Running initial snapshot") self.snapshot_run() assert self._state == OpenDSSState.SNAPSHOT_RUN, f"{self._state}" @@ -278,6 +295,7 @@ def create_measurement_lists( ): """Initialize list of sensor locations for the measurement federate.""" random.seed(voltage_seed) + logger.info(f"Creating measurement lists") os.makedirs("sensors", exist_ok=True) voltage_subset = random.sample( self._AllNodeNames, @@ -285,6 +303,7 @@ def create_measurement_lists( ) with open(os.path.join("sensors", "voltage_ids.json"), "w") as fp: json.dump(voltage_subset, fp, indent=4) + logger.info(f"Voltage sensors exported to sensors/voltage_ids.json") random.seed(real_seed) real_subset = random.sample( @@ -293,6 +312,7 @@ def create_measurement_lists( ) with open(os.path.join("sensors", "real_ids.json"), "w") as fp: json.dump(real_subset, fp, indent=4) + logger.info(f"Real power sensors exported to sensors/real_ids.json") random.seed(reactive_seed) reactive_subset = random.sample( @@ -301,6 +321,7 @@ def create_measurement_lists( ) with open(os.path.join("sensors", "reactive_ids.json"), "w") as fp: json.dump(reactive_subset, fp, indent=4) + logger.info(f"Reactive power sensors exported to sensors/reactive_ids.json") def get_circuit_name(self): """Get name of current opendss circuit.""" @@ -334,11 +355,19 @@ def get_bus_coords(self) -> Dict[str, Tuple[float, float]] | None: def load_feeder(self): """Load feeder once downloaded. Relies on legacy mode.""" + logger.info("Loading feeder into OpenDSS") # Real solution is kvarlimit with kvarmax dss.Basic.LegacyModels(True) - dss.Text.Command("clear") - dss.Text.Command("redirect " + self._feeder_file) - result = dss.Text.Result() + logger.info("Enabling legacy models") + if not os.path.exists(self._feeder_file): + raise ValueError(f"Feeder file {self._feeder_file} not found") + + command("clear") + + base_path = os.getcwd() + logger.info("Current working directory: " + base_path) + result = command(f'redirect "{self._feeder_file}"') + logger.info(f"Feeder loaded") if not result == "": raise ValueError("Feeder not loaded: " + result) self._circuit = dss.Circuit @@ -357,7 +386,7 @@ def load_feeder(self): self._source_indexes.append( self._AllNodeNames.index(Bus.upper() + "." + str(phase)) ) - + logger.info("Setting up base voltages") self.setup_vbase() self._pvsystems = set() @@ -366,12 +395,13 @@ def load_feeder(self): if self.tap_setting is not None: # Doesn't work with AutoTrans or 3-winding transformers. - dss.Text.Command(f"batchedit transformer..* wdg=2 tap={self.tap_setting}") + command(f"batchedit transformer..* wdg=2 tap={self.tap_setting}") if self.open_lines is not None: for l in self.open_lines: self.open_line(l) self._state = OpenDSSState.LOADED + logger.info("Feeder loaded into OpenDSS") def disable_elements(self): """Disable most elements. Used in disabled_run.""" diff --git a/LocalFeeder/requirements.txt b/LocalFeeder/requirements.txt index 8c71306..8de7330 100644 --- a/LocalFeeder/requirements.txt +++ b/LocalFeeder/requirements.txt @@ -1,5 +1,5 @@ -helics==3.4.0 -helics-apps==3.4.0 +helics +helics-apps pydantic pyarrow scipy diff --git a/LocalFeeder/sender_cosim.py b/LocalFeeder/sender_cosim.py index fd07f3b..8bcee0f 100644 --- a/LocalFeeder/sender_cosim.py +++ b/LocalFeeder/sender_cosim.py @@ -487,14 +487,19 @@ def go_cosim( def run_simulator(broker_config: BrokerConfig): """Load static_inputs and input_mapping and run JSON.""" + logger.info("Starting feeder simulator") + logger.info("Loading static_inputs.json and input_mapping.json") with open("static_inputs.json") as f: parameters = json.load(f) with open("input_mapping.json") as f: input_mapping = json.load(f) + logger.info(f"Feeder parameters: {parameters}") config = FeederConfig(**parameters) + logger.info(f"Feeder config: {config}") sim = FeederSimulator(config) + logger.info(f"Simulator created, starting co-simulation") go_cosim(sim, config, input_mapping, broker_config) - + logger.info(f"Simulator Complete") if __name__ == "__main__": run_simulator(BrokerConfig(broker_ip="127.0.0.1")) diff --git a/LocalFeeder/server.py b/LocalFeeder/server.py index 85032fc..79abcb0 100644 --- a/LocalFeeder/server.py +++ b/LocalFeeder/server.py @@ -17,8 +17,8 @@ from oedisi.types.common import ServerReply, HeathCheck, DefaultFileNames from oedisi.types.common import BrokerConfig +logger = logging.getLogger("uvicorn.error") REQUEST_TIMEOUT_SEC = 1200 - app = FastAPI() base_path = os.getcwd() @@ -53,22 +53,37 @@ def read_root(): @app.get("/sensor") async def sensor(): - logging.info(os.getcwd()) + logger.info("Checking for sensors.json file") + logger.info(os.getcwd()) sensor_path = os.path.join(base_path, "sensors", "sensors.json") while not os.path.exists(sensor_path): time.sleep(1) - logging.info(f"waiting {sensor_path}") - logging.info("success") + logger.info(f"waiting {sensor_path}") + logger.info("success") data = json.load(open(sensor_path, "r")) return data +@app.post("/sensor") +async def sensor_post(sensor_list:list[str]): + sensor_dir = os.path.join(base_path, "sensors") + sensor_path = os.path.join(sensor_dir, "sensors.json") + try: + os.makedirs(sensor_dir, exist_ok=True) + with open(sensor_path, "w") as f: + json.dump(sensor_list, f, indent=2) + response = ServerReply(detail=f"Wrote {len(sensor_list)} sensors to {sensor_path}").dict() + return JSONResponse(response, 200) + except Exception as e: + err = traceback.format_exc() + logger.error(f"Failed to write sensors file: {err}") + raise HTTPException(status_code=500, detail=str(err)) @app.post("/profiles") async def upload_profiles(file: UploadFile): try: data = file.file.read() if not file.filename.endswith(".zip"): - HTTPException(400, "Invalid file type. Only zipped profiles are accepted.") + raise HTTPException(400, "Invalid file type. Only zipped profiles are accepted.") profile_path = "./profiles" @@ -101,7 +116,7 @@ async def upload_model(file: UploadFile): try: data = file.file.read() if not file.filename.endswith(".zip"): - HTTPException( + raise HTTPException( 400, "Invalid file type. Only zipped opendss models are accepted." ) @@ -120,24 +135,24 @@ async def upload_model(file: UploadFile): return JSONResponse(response, 200) else: - HTTPException(400, "A valid opendss model should have a master.dss file.") + raise HTTPException(400, "A valid opendss model should have a master.dss file.") except Exception as e: - HTTPException(500, "Unknown error while uploading userdefined opendss model.") + raise HTTPException(500, "Unknown error while uploading userdefined opendss model.") @app.post("/run") async def run_feeder( broker_config: BrokerConfig, background_tasks: BackgroundTasks ): # :BrokerConfig - logging.info(broker_config) + logger.info(broker_config) try: background_tasks.add_task(run_simulator, broker_config) response = ServerReply(detail="Task sucessfully added.").dict() - return JSONResponse(response, 200) except Exception as e: err = traceback.format_exc() - HTTPException(500, str(err)) + logger.error(f"Error in /run: {err}") + raise HTTPException(500, str(err)) @app.post("/configure") diff --git a/LocalFeeder/tests/.DS_Store b/LocalFeeder/tests/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..c9f3cd6ef995cec0fd30ee08dc0cf11c9509789f GIT binary patch literal 6148 zcmeHKO>fgc5S>j!YbyffP^5A|vc$D2X%&GG7Zb{XD@JetWbD{b3)dUP4p9|F@*VyQ zXZ{F(2j1*%A!&lRA%te6*|#6_cI`LTu9t|^XqFul^@+%XGj@CwTa3rqH>~4&c7e*w zF{XsZbVfPF8`0A7FDk&f>mge)P3Qu-`<1`=X{pC)sZ&Jo=kW-B^xYV0L_RghbB!{l z47`StD~vCHW~B3a;@0?@PRgQg27_;^v(vq{yXWnBJ?~9)rsq+UG_!h?OkZ*9xz=TR zSx?dzMLv)F`;T?iBt?}^4WY<#guH%PRGFTS^sLHi!;S2K=X-wKzqMEl2gAeQNB!yoArFY!TyQG_6Wqy~ijsZ>-4m z4#MRSEvG3RvGD7{soRKOm3n`LTZ2_OZj6L!q@bYlt@H4cug-&}m?$6$hytvD+t9q8 z$RP@d0;0eb1$ci5;EbWe)}q-uP?#$Ku#0YEsPoSva{`Bfd z0~Z%MY%N+kDR=o$Ze`_eD9Wsk`2!P9Dzqr2C?E=~E3jjmeLnx6{QmyGPLh@=APW3f z3aIXBbUMN-xwCcS<@l@(;BVn<9M@X>NkPRN#fasj_yBGU{Q*~ip~Kc9dSLP+U}TU& J6!@zO`~VH*iX;F4 literal 0 HcmV?d00001 diff --git a/broker/Dockerfile b/broker/Dockerfile index 0c009d8..a65fadf 100644 --- a/broker/Dockerfile +++ b/broker/Dockerfile @@ -1,7 +1,8 @@ -FROM python:3.10.6-slim-bullseye +FROM python:3.13-slim-bullseye RUN apt-get update RUN apt-get install -y git ssh +RUN apt install build-essential cmake git python3-dev -y RUN mkdir broker diff --git a/broker/oedisi.code-workspace b/broker/oedisi.code-workspace new file mode 100644 index 0000000..d011135 --- /dev/null +++ b/broker/oedisi.code-workspace @@ -0,0 +1,14 @@ +{ + "folders": [ + { + "path": "../../oedisi" + }, + { + "path": ".." + }, + { + "path": "../../oedisi-ieee123" + } + ], + "settings": {} +} \ No newline at end of file diff --git a/broker/requirements.txt b/broker/requirements.txt index 2adca9c..8cc7176 100644 --- a/broker/requirements.txt +++ b/broker/requirements.txt @@ -1,8 +1,8 @@ -helics==3.4.0 -helics-apps==3.4.0 +helics +helics-apps pyyaml fastapi uvicorn oedisi>=2.0.2,<3 -grequests +httpx python-multipart diff --git a/broker/server.py b/broker/server.py index 72f3ae5..8d0e278 100644 --- a/broker/server.py +++ b/broker/server.py @@ -1,19 +1,20 @@ -from fastapi import FastAPI, BackgroundTasks, UploadFile -from fastapi.responses import FileResponse, JSONResponse -from fastapi.exceptions import HTTPException -import helics as h -import grequests +from functools import cache import traceback import requests import zipfile -import uvicorn import logging import socket import time -import yaml import json import os -import json +import asyncio + +from fastapi import FastAPI, BackgroundTasks, UploadFile +from fastapi.responses import FileResponse, JSONResponse +from fastapi.exceptions import HTTPException +import helics as h +import httpx +import uvicorn from oedisi.componentframework.system_configuration import ( WiringDiagram, @@ -23,23 +24,23 @@ from oedisi.tools.broker_utils import get_time_data logger = logging.getLogger("uvicorn.error") - app = FastAPI() -is_kubernetes_env = ( - os.environ["KUBERNETES_SERVICE_NAME"] - if "KUBERNETES_SERVICE_NAME" in os.environ - else None -) - WIRING_DIAGRAM_FILENAME = "system.json" WIRING_DIAGRAM: WiringDiagram | None = None +@cache +def kubernetes_service(): + if "KUBERNETES_SERVICE_NAME" in os.environ: + return os.environ["KUBERNETES_SERVICE_NAME"] # works with kurenetes + elif "SERVICE_NAME" in os.environ: + return os.environ["SERVICE_NAME"] # works with minikube + else: + return None def build_url(host: str, port: int, enpoint: list): - if is_kubernetes_env: - KUBERNETES_SERVICE_NAME = os.environ["KUBERNETES_SERVICE_NAME"] - url = f"http://{host}.{KUBERNETES_SERVICE_NAME}:{port}/" + if kubernetes_service(): + url = f"http://{host}.{kubernetes_service()}:{port}/" else: url = f"http://{host}:{port}/" url = url + "/".join(enpoint) + "/" @@ -91,12 +92,13 @@ async def upload_profiles(file: UploadFile): HTTPException( 400, "Invalid file type. Only zip files are accepted." ) + + logger.info(f"Writing profile file to disk {file.filename}") with open(file.filename, "wb") as f: f.write(data) url = build_url(ip, port, ["profiles"]) - logger.info(f"making a request to url - {url}") - + logger.info(f"Uploading profile file {file.filename} to {url}") files = {"file": open(file.filename, "rb")} r = requests.post(url, files=files) response = ServerReply(detail=r.text).dict() @@ -107,6 +109,24 @@ async def upload_profiles(file: UploadFile): raise HTTPException(status_code=500, detail=str(err)) +@app.post("/sensors") +async def add_sensors(sensors: list[str]): + try: + component_map, _, _ = read_settings() + for hostname in component_map: + if "feeder" in hostname: + ip = hostname + port = component_map[hostname] + url = build_url(ip, port, ["sensor"]) + logger.info(f"Uploading sensors to {url}") + r = requests.post(url, json=sensors) + response = ServerReply(detail=r.text).dict() + return JSONResponse(response, r.status_code) + raise HTTPException(status_code=404, detail="Unable to upload sensors") + except Exception as e: + err = traceback.format_exc() + raise HTTPException(status_code=500, detail=str(err)) + @app.post("/model") async def upload_model(file: UploadFile): try: @@ -120,12 +140,12 @@ async def upload_model(file: UploadFile): HTTPException( 400, "Invalid file type. Only zip files are accepted." ) + logger.info(f"Writing model file to disk {file.filename}") with open(file.filename, "wb") as f: f.write(data) url = build_url(ip, port, ["model"]) - logger.info(f"making a request to url - {url}") - + logger.info(f"Uploading model file {file.filename} to {url}") files = {"file": open(file.filename, "rb")} r = requests.post(url, files=files) response = ServerReply(detail=r.text).dict() @@ -139,7 +159,6 @@ async def upload_model(file: UploadFile): @app.get("/results") def download_results(): component_map, _, _ = read_settings() - for hostname in component_map: if "recorder" in hostname: host = hostname @@ -157,6 +176,7 @@ def download_results(): with zipfile.ZipFile(file_path, "w") as zipMe: for feather_file in find_filenames(): zipMe.write(feather_file, compress_type=zipfile.ZIP_DEFLATED) + logger.info(f"Added {feather_file} to zip") try: return FileResponse(path=file_path, filename=file_path, media_type="zip") @@ -165,9 +185,10 @@ def download_results(): @app.get("/terminate") -def terminate_simulation(): +async def terminate_simulation(): try: h.helicsCloseLibrary() + logger.info("Closed helics library") return JSONResponse({"detail": "Helics broker sucessfully closed"}, 200) except Exception as e: raise HTTPException(status_code=404, detail="Failed download ") @@ -179,7 +200,7 @@ def _get_feeder_info(component_map: dict): return host, component_map[host] -def run_simulation(): +async def run_simulation(): component_map, broker_ip, api_port = read_settings() feeder_host, feeder_port = _get_feeder_info(component_map) logger.info(f"{broker_ip}, {api_port}") @@ -188,31 +209,76 @@ def run_simulation(): broker = h.helicsCreateBroker("zmq", "", initstring) app.state.broker = broker - logging.info(broker) + logger.info(f"Created broker: {broker}") isconnected = h.helicsBrokerIsConnected(broker) logger.info(f"Broker connected: {isconnected}") logger.info(str(component_map)) - replies = [] - broker_host = socket.gethostname() - for service_ip, service_port in component_map.items(): - if service_ip != broker_host: - url = build_url(service_ip, service_port, ["run"]) - logger.info(f"making a request to url - {url}") + async with httpx.AsyncClient(timeout=None) as client: + tasks = [] + for service_ip, service_port in component_map.items(): + if service_ip != broker_host: + url = build_url(service_ip, service_port, ["run"]) + logger.info(f"service_ip: {service_ip}, service_port: {service_port}") + logger.info(f"making a request to url - {url}") + + myobj = { + "broker_port": 23404, + "broker_ip": broker_ip, + "api_port": api_port, + "feeder_host": feeder_host, + "feeder_port": feeder_port, + } + logger.info(f"{myobj}") + # create tasks so we can monitor them periodically + task = asyncio.create_task(client.post(url[:-1], json=myobj)) + tasks.append(task) + + # Periodically log task status every 5 seconds until all done + if tasks: + pending = set(tasks) + while pending: + done = {t for t in pending if t.done()} + for idx, t in enumerate(tasks): + state = ( + "done" + if t.done() + else "cancelled" + if t.cancelled() + else "pending" + ) + info = None + if t.done() and not t.cancelled(): + try: + res = t.result() + info = f"status_code={getattr(res, 'status_code', 'N/A')}" + except Exception as exc: + info = f"exception={exc}" + logger.info(f"Task {idx}: {state} {info or ''}") + + # remove completed tasks from pending + pending -= done + + if pending: + await asyncio.sleep(5) + else: + # ensure exceptions are observed to avoid warnings + for idx, t in enumerate(tasks): + try: + res = t.result() + logger.info(f"Task {idx} succeeded: {getattr(res, 'status_code', 'N/A')}") + except Exception as exc: + logger.error(f"Task {idx} failed: {exc}") - myobj = { - "broker_port": 23404, - "broker_ip": broker_ip, - "api_port": api_port, - "feeder_host": feeder_host, - "feeder_port": feeder_port, - } - replies.append(grequests.post(url, json=myobj)) - grequests.map(replies) while h.helicsBrokerIsConnected(broker): - time.sleep(1) + time.sleep(1) + query_result = broker.query("broker", "current_state") + logger.info(f"Federates expected: {len(component_map)-1}") + logger.info(f"Federates connected: {len(broker.query("broker", "federates"))}") + logger.info(f"Simulation state: {query_result['state']}") + logger.info(f"Global time: {query_result['attributes']['parent']}") h.helicsCloseLibrary() return @@ -256,7 +322,7 @@ async def configure(wiring_diagram: WiringDiagram): ) -@app.get("/status/") +@app.get("/status") async def status(): try: name_2_timedata = {} diff --git a/lindistflow_federate/requirements.txt b/lindistflow_federate/requirements.txt index 4041332..4d48e19 100644 --- a/lindistflow_federate/requirements.txt +++ b/lindistflow_federate/requirements.txt @@ -1,4 +1,4 @@ -helics==3.4.0 +helics pydantic>=1.7,<2 cvxpy numpy diff --git a/measuring_federate/requirements.txt b/measuring_federate/requirements.txt index 3a67b6e..84687a6 100644 --- a/measuring_federate/requirements.txt +++ b/measuring_federate/requirements.txt @@ -1,5 +1,5 @@ -helics==3.4.0 -helics-apps==3.4.0 +helics +helics-apps pydantic pyarrow numpy diff --git a/measuring_federate/server.py b/measuring_federate/server.py index f3632d3..621cd64 100644 --- a/measuring_federate/server.py +++ b/measuring_federate/server.py @@ -1,30 +1,41 @@ -from fastapi import FastAPI, BackgroundTasks, HTTPException -from measuring_federate import run_simulator -from fastapi.responses import JSONResponse +from functools import cache import traceback import requests -import uvicorn -import socket import logging -import sys +import socket import json import os +from fastapi import FastAPI, BackgroundTasks, HTTPException +from measuring_federate import run_simulator +from fastapi.responses import JSONResponse +import uvicorn + from oedisi.componentframework.system_configuration import ComponentStruct from oedisi.types.common import ServerReply, HeathCheck, DefaultFileNames from oedisi.types.common import BrokerConfig app = FastAPI() -is_kubernetes_env = os.environ['SERVICE_NAME'] if 'SERVICE_NAME' in os.environ else None +@cache +def kubernetes_service(): + if "KUBERNETES_SERVICE_NAME" in os.environ: + return os.environ["KUBERNETES_SERVICE_NAME"] # works with kurenetes + elif "SERVICE_NAME" in os.environ: + return os.environ["SERVICE_NAME"] # works with minikube + else: + return None def build_url(host:str, port:int, enpoint:list): - if is_kubernetes_env: - SERVICE_NAME = os.environ['SERVICE_NAME'] - url = f"http://{host}.{SERVICE_NAME}:{port}/" + + if kubernetes_service(): + logging.info("Containers running in docker-compose environment") + url = f"http://{host}.{kubernetes_service()}:{port}/" else: + logging.info("Containers running in kubernetes environment") url = f"http://{host}:{port}/" url = url + "/".join(enpoint) + logging.info(f"Built url {url}") return url @app.get("/") @@ -43,14 +54,15 @@ async def run_model(broker_config:BrokerConfig, background_tasks: BackgroundTask feeder_host = broker_config.feeder_host feeder_port = broker_config.feeder_port url = build_url(feeder_host, feeder_port, ['sensor']) - logging.info(url) + logging.info(f"Making a request to url - {url}") try: reply = requests.get(url) sensor_data = reply.json() if not sensor_data: msg = "empty sensor list" raise HTTPException(404, msg) - logging.info(sensor_data) + logging.info(f"Received sensor data {sensor_data}") + logging.info("Writing sensor data to sensors.json") with open("sensors.json", "w") as outfile: json.dump(sensor_data, outfile) diff --git a/recorder/record_subscription.py b/recorder/record_subscription.py index abd5e38..72a4b5e 100644 --- a/recorder/record_subscription.py +++ b/recorder/record_subscription.py @@ -11,11 +11,11 @@ from oedisi.types.common import BrokerConfig from oedisi.types.data_types import MeasurementArray -logger = logging.getLogger(__name__) +# logger = logging.getLogger(__name__) +logger = logging.getLogger("uvicorn.error") logger.addHandler(logging.StreamHandler()) logger.setLevel(logging.INFO) - class Recorder: def __init__( self, diff --git a/recorder/requirements.txt b/recorder/requirements.txt index 2c3eaa5..fb3f15e 100644 --- a/recorder/requirements.txt +++ b/recorder/requirements.txt @@ -1,5 +1,5 @@ -helics==3.4.0 -helics-apps==3.4.0 +helics +helics-apps pydantic pyarrow numpy diff --git a/recorder/server.py b/recorder/server.py index 8e5249d..0f6a708 100644 --- a/recorder/server.py +++ b/recorder/server.py @@ -16,10 +16,9 @@ from record_subscription import run_simulator - +logger = logging.getLogger("uvicorn.error") app = FastAPI() - @app.get("/") def read_root(): hostname = socket.gethostname() @@ -48,8 +47,10 @@ def download_results(): @app.post("/run") async def run_model(broker_config: BrokerConfig, background_tasks: BackgroundTasks): - logging.info(broker_config) + logger.info(broker_config) + print(broker_config) try: + logger.info("Adding task to background tasks") background_tasks.add_task(run_simulator, broker_config) response = ServerReply(detail="Task sucessfully added.").dict() return JSONResponse(response, 200) diff --git a/system.json b/system.json new file mode 100644 index 0000000..b10d15d --- /dev/null +++ b/system.json @@ -0,0 +1,203 @@ +{ + "name": "docker_test", + "components": [ + { + "name": "feeder", + "type": "LocalFeeder", + "host": "feeder", + "container_port": 5678, + "parameters": { + "use_smartds": false, + "user_uploads_model": true, + "profile_location": "profiles", + "opendss_location": "opendss", + "existing_feeder_file": "opendss/master.dss", + "start_date": "2017-01-01 00:00:00", + "number_of_timesteps": 96, + "run_freq_sec": 900, + "topology_output": "topology.json" + } + }, + { + "name": "recorder_voltage_real", + "type": "Recorder", + "host": "recorder-voltage-real", + "container_port": 5679, + "parameters": {"feather_filename": "voltage_real.feather", + "csv_filename": "voltage_real.csv" + } + }, + { + "name": "recorder_voltage_imag", + "type": "Recorder", + "host": "recorder-voltage-imag", + "container_port": 5680, + "parameters": {"feather_filename": "voltage_imag.feather", + "csv_filename": "voltage_imag.csv" + } + }, + { + "name": "recorder_voltage_mag", + "type": "Recorder", + "host": "recorder-voltage-mag", + "container_port": 5681, + "parameters": {"feather_filename": "voltage_mag.feather", + "csv_filename": "voltage_mag.csv" + } + }, + { + "name": "recorder_voltage_angle", + "type": "Recorder", + "host": "recorder-voltage-angle", + "container_port": 5682, + "parameters": {"feather_filename": "voltage_angle.feather", + "csv_filename": "voltage_angle.csv" + } + }, + { + "name": "state_estimator", + "type": "StateEstimatorComponent", + "host": "state-estimator", + "container_port": 5683, + "parameters": { + "algorithm_parameters": {"tol": 1e-5} + } + }, + { + "name": "sensor_voltage_real", + "type": "MeasurementComponent", + "host": "sensor-voltage-real", + "container_port": 5684, + "parameters": { + "gaussian_variance": 0.0, + "random_percent": 0.0, + "measurement_file": "sensors.json" + } + }, + { + "name": "sensor_voltage_magnitude", + "type": "MeasurementComponent", + "host": "sensor-voltage-magnitude", + "container_port": 5685, + "parameters": { + "gaussian_variance": 0.0, + "random_percent": 0.0, + "measurement_file": "sensors.json" + } + }, + { + "name": "sensor_voltage_imaginary", + "type": "MeasurementComponent", + "host": "sensor-voltage-imaginary", + "container_port": 5686, + "parameters": { + "gaussian_variance": 0.0, + "random_percent": 0.0, + "measurement_file": "sensors.json" + } + }, + { + "name": "sensor_power_real", + "type": "MeasurementComponent", + "host": "sensor-power-real", + "container_port": 5687, + "parameters": { + "gaussian_variance": 0.0, + "random_percent": 0.0, + "measurement_file": "sensors.json" + } + }, + { + "name": "sensor_power_imaginary", + "type": "MeasurementComponent", + "host": "sensor-power-imaginary", + "container_port": 5688, + "parameters": { + "gaussian_variance": 0.0, + "random_percent": 0.0, + "measurement_file": "sensors.json" + } + } + + ], + "links": [ + { + "source": "feeder", + "source_port": "voltages_magnitude", + "target": "sensor_voltage_magnitude", + "target_port": "subscription" + }, + { + "source": "feeder", + "source_port": "voltages_real", + "target": "sensor_voltage_real", + "target_port": "subscription" + }, + { + "source": "feeder", + "source_port": "voltages_imag", + "target": "sensor_voltage_imaginary", + "target_port": "subscription" + }, + { + "source": "feeder", + "source_port": "powers_real", + "target": "sensor_power_real", + "target_port": "subscription" + }, + { + "source": "feeder", + "source_port": "powers_imag", + "target": "sensor_power_imaginary", + "target_port": "subscription" + }, + { + "source": "feeder", + "source_port": "topology", + "target": "state_estimator", + "target_port": "topology" + }, + { + "source": "sensor_voltage_magnitude", + "source_port": "publication", + "target": "state_estimator", + "target_port": "voltages_magnitude" + }, + { + "source": "sensor_power_real", + "source_port": "publication", + "target": "state_estimator", + "target_port": "powers_real" + }, + { + "source": "sensor_power_imaginary", + "source_port": "publication", + "target": "state_estimator", + "target_port": "powers_imaginary" + }, + { + "source": "feeder", + "source_port": "voltages_real", + "target": "recorder_voltage_real", + "target_port": "subscription" + }, + { + "source": "feeder", + "source_port": "voltages_imag", + "target": "recorder_voltage_imag", + "target_port": "subscription" + }, + { + "source": "state_estimator", + "source_port": "voltage_angle", + "target": "recorder_voltage_angle", + "target_port": "subscription" + }, + { + "source": "state_estimator", + "source_port": "voltage_mag", + "target": "recorder_voltage_mag", + "target_port": "subscription" + } + ] +} diff --git a/wls_federate/requirements.txt b/wls_federate/requirements.txt index 718db35..540fa67 100644 --- a/wls_federate/requirements.txt +++ b/wls_federate/requirements.txt @@ -1,5 +1,5 @@ -helics==3.4.0 -helics-apps==3.4.0 +helics +helics-apps pydantic scipy numpy From b5baa704e8414c5124f491a11db00fecddc9257f Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Sat, 20 Sep 2025 10:11:30 -0600 Subject: [PATCH 2/4] bug fix --- LocalFeeder/Dockerfile | 1 + broker/server.py | 10 ++++++---- measuring_federate/Dockerfile | 1 + measuring_federate/requirements.txt | 2 +- recorder/Dockerfile | 1 + wls_federate/Dockerfile | 1 + 6 files changed, 11 insertions(+), 5 deletions(-) diff --git a/LocalFeeder/Dockerfile b/LocalFeeder/Dockerfile index 8a2427b..500dd62 100644 --- a/LocalFeeder/Dockerfile +++ b/LocalFeeder/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.10.6-slim-bullseye RUN apt-get update RUN apt-get install -y git ssh +RUN apt install build-essential cmake git python3-dev -y RUN mkdir LocalFeeder COPY . ./LocalFeeder WORKDIR ./LocalFeeder diff --git a/broker/server.py b/broker/server.py index 8d0e278..aeadcaa 100644 --- a/broker/server.py +++ b/broker/server.py @@ -3,27 +3,29 @@ import requests import zipfile import logging +import asyncio +import logging import socket import time import json import os -import asyncio from fastapi import FastAPI, BackgroundTasks, UploadFile from fastapi.responses import FileResponse, JSONResponse from fastapi.exceptions import HTTPException import helics as h -import httpx import uvicorn +import httpx from oedisi.componentframework.system_configuration import ( - WiringDiagram, ComponentStruct, + WiringDiagram, ) from oedisi.types.common import ServerReply, HeathCheck from oedisi.tools.broker_utils import get_time_data logger = logging.getLogger("uvicorn.error") +logger.setLevel(logging.DEBUG) app = FastAPI() WIRING_DIAGRAM_FILENAME = "system.json" @@ -310,7 +312,7 @@ async def configure(wiring_diagram: WiringDiagram): url = build_url(component.host, component.container_port, ["configure"]) logger.info(f"making a request to url - {url}") - r = requests.post(url, json=component_model.dict()) + r = requests.post(url[:-1], json=component_model.dict()) assert ( r.status_code == 200 ), f"POST request to update configuration failed for url - {url}" diff --git a/measuring_federate/Dockerfile b/measuring_federate/Dockerfile index 29f1b71..e9d6ecc 100644 --- a/measuring_federate/Dockerfile +++ b/measuring_federate/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.10.6-slim-bullseye RUN apt-get update RUN apt-get install -y git ssh +RUN apt install build-essential cmake git python3-dev -y RUN mkdir MeasurementComponent COPY . ./MeasurementComponent WORKDIR ./MeasurementComponent diff --git a/measuring_federate/requirements.txt b/measuring_federate/requirements.txt index 84687a6..fbbb8be 100644 --- a/measuring_federate/requirements.txt +++ b/measuring_federate/requirements.txt @@ -7,6 +7,6 @@ pandas fastapi uvicorn requests -grequests +httpx oedisi>=2.0.2,<3 diff --git a/recorder/Dockerfile b/recorder/Dockerfile index 82988ba..f6eb825 100644 --- a/recorder/Dockerfile +++ b/recorder/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.10.6-slim-bullseye RUN apt-get update RUN apt-get install -y git ssh +RUN apt install build-essential cmake git python3-dev -y RUN mkdir Recorder COPY . ./Recorder WORKDIR ./Recorder diff --git a/wls_federate/Dockerfile b/wls_federate/Dockerfile index cc55cb6..56f6c3b 100644 --- a/wls_federate/Dockerfile +++ b/wls_federate/Dockerfile @@ -1,6 +1,7 @@ FROM python:3.10.6-slim-bullseye RUN apt-get update RUN apt-get install -y git ssh +RUN apt install build-essential cmake git python3-dev -y RUN mkdir StateEstimatorComponent COPY . ./StateEstimatorComponent WORKDIR ./StateEstimatorComponent From c7ef12c4c951364cf80c1bbccffb320836c2f826 Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Wed, 7 Jan 2026 08:55:44 -0700 Subject: [PATCH 3/4] adding workflow --- .DS_Store | Bin 14340 -> 14340 bytes .github/workflows/publish-on-release.yml | 64 ++++++++++ README.md | 37 ++++++ best_practice_guide.md | 86 ++++++++++++++ best_practice_guide.pdf | Bin 0 -> 57755 bytes broker/oedisi.code-workspace | 3 + broker/server.py | 143 +++++++++++++++-------- 7 files changed, 286 insertions(+), 47 deletions(-) create mode 100644 .github/workflows/publish-on-release.yml create mode 100644 best_practice_guide.md create mode 100644 best_practice_guide.pdf diff --git a/.DS_Store b/.DS_Store index 250a9f034595ee9a8034b192ca453a47cac8b5cc..9a46ddce5e03a11ce50554cdd495ebb63bfa55b0 100644 GIT binary patch delta 927 zcmZvb&rcIU6vy8{u`LwXQc49i777Ff`4yw}V8D{7jWIM9O^Zsi-AC9$#%P(V^ z2#LnTgl4$<2Y4jC>A?dRFCI*|crfrE;K{^z&}q9ET%0|;@67vr-@e(M+0wW4?OC)` zPR#SMF~3&@4J@KgBzM1DbC(ZWD{ha=gQDf_L7tF?xIWlciAlGc_-g-V^Nq>pBjINg z*C(UgH4x={K|g;Ld|8dYm83xlqbdL(?(5?GMgN+U-wy>!nzEv23CjxXb5W6? z<&wIrXjDyEc+E;`8#p6%IM4?;w_qw z5t3l0W@(J3pO}kmHE!WToaPz-I1((KVp3_@Fl4IY*!oi3hdws4gUk)CGX2^H|vtMW#!|!`ftyGK#(4$aKQad0-fB z!F_lH%di1ucnj~~1AKySa0EY*8+D^TG>opIn`jE%MR7Eb6tsvmv{d&uAiK^5JK6!6 zLU_Rh1u#&M`9a82DTH>KE=`Dp3r3-Kvf_j(_5TeLK;w+ajsC6;0PYLFKIZ^-PXK%~ zyi;mEt|ZQ>Dqh6E0Q|4Xb@7BrNND-%DF{9XMIfR{)8TsKaD{|RuU3aEVp9wX;?XXH zWlt&OYb}hzI>;hL9OZuTXm7YO)H~34`mW4W76#sX%lmy5_xKMNKZ-2;pZSXZ0ELYF AdjJ3c delta 261 zcmZoEXepTB&zLeXAWEt%xF|0tKQEnufq~J*B`GIA3CQA@?-4$I<3%&}i4S-;vvaU; zFq%wWC!jI;tqlL>OaWKc$t_~-leNVsFm9jxLAg*(yt>-N$V5lM)Vx+lq1w{iP)EVi z%(S+alS5Ql-#REhJ0~|UzjJbcm^@?G=0vGgtQ#M2F*E8 zelpa00R{%9t3dbE{RaaEhRMFF6S<5nL8cp88W~Ksm9UtspvKLaQk:` and also with the normalized tag (strips a leading `v`). + +Required GitHub repository secrets: + +- `DOCKERHUB_USERNAME` — your Docker Hub username. +- `PAT_TOKEN` — your Docker Hub password or personal access token stored as a repository secret. + +How to test + +- Publish a release (or tag) on GitHub for this repository — the workflow triggers on published releases. +- To run the same logic locally (useful for debugging), you can run: + +```bash +export RELEASE_TAG=v1.2.3 +export DOCKERHUB_USERNAME=youruser +export PAT_TOKEN=... +echo "$PAT_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin +mapfile -t dirs < <(find . -maxdepth 4 -type f -name Dockerfile -exec dirname {} \; | sort -u) +NORMALIZED_TAG=${RELEASE_TAG#v} +for d in "${dirs[@]}"; do + name=$(echo "$d" | sed 's|^\./||; s|/|_|g') + docker buildx build --platform linux/amd64,linux/arm64 -t "${DOCKERHUB_USERNAME}/${name}:${RELEASE_TAG}" -t "${DOCKERHUB_USERNAME}/${name}:${NORMALIZED_TAG}" "${d}" --push +done +``` + +Notes + +- Add the two secrets in the repository Settings → Secrets → Actions. +- Images will be pushed to Docker Hub; ensure your account has push permission and consider Docker Hub rate limits. + diff --git a/best_practice_guide.md b/best_practice_guide.md new file mode 100644 index 0000000..e9899d5 --- /dev/null +++ b/best_practice_guide.md @@ -0,0 +1,86 @@ + +# **Best Practice Guide for OEDISI Component Development** +- **Purpose:** Practical, repeatable steps for building a new OEDISI component. +- **Audience:** Component developers and integrators working with the `oedisi` framework. + +**Repository & Project Setup** +- **Prefer location:** Create the new component inside the `oedisi-example` repository. Keeping components together simplifies testing, version control, and CI/integration workflows. + - **If adding to `oedisi-example`:** Create a top-level directory `oedisi-example//` and follow the recommended layout. + - **Required docs:** Add `LICENSE.md` and `CONTRIBUTORS.json` that describe commit/PR expectations and how to run tests locally. +- **Alternate location:** For independently versioned components, create a dedicated repository under the `openEDI` organization. Note this increases CI and release maintenance overhead and is NOT the prefered approach +- **Prohibited:** Avoid private or internal-only repositories, since those complicate shared CI and community review. + +**Recommended Component Layout** +- `component/` — source package (python module, go module, etc.). +- `tests/unit/` — unit tests for core logic. +- `tests/integration/` — system-level tests that run the component with other federates or a minimal orchestrator. +- `examples/` — example configs, `component_definition.json`, `input_mapping.json`, sample data. +- `Dockerfile` / `docker-compose.yml` — for replicable integration test runs. +- `README.md` — usage, configuration, and quickstart (also update central docs). + +**Scaffolding Steps** + +- Create the directory under `oedisi-example/` and initialize git (or create a repository under `openEDI` if appropriate). +- Add `.gitignore`, `LICENSE.md`, and `README.md`. +- Scaffold a minimal package in `component/` with a clear entry point, configuration loader, and a simple run loop that can be exercised by tests. +- Add `component_definition.json` describing inputs/outputs and include an example `input_mapping.json` in `examples/`. + +**Testing Strategy** + +- Unit tests: Keep logic pure and unit-testable. Use `pytest` (or the project standard). Put tests in `tests/unit/`. +- Mock dependencies: Abstract external systems (HELICS, databases) with adapter interfaces so unit tests can use fast mocks. +- Integration tests: Put end-to-end tests in `tests/integration/`. Use a local HELICS broker or `docker-compose` to run multiple federates for system tests. +- Test data & fixtures: Store small representative datasets in `examples/` or `tests/fixtures/` so CI runs quickly. + +**Continuous Integration** + +- GitHub Actions: Create workflows to run unit tests on each PR and a separate workflow/job for integration tests (nightly or on-demand if they are long-running). +- Integration job config: Use `services`, `docker-compose`, or reusable workflows to bring up HELICS and dependent services. +- Dependency caching: Cache dependencies to speed up CI runs. + +**Documentation & Discovery** + +- Live docs: Keep `best_practice_guide.md` and the component `README.md` up to date as you add features. +- Component metadata: Maintain `component_definition.json` to reflect actual inputs, outputs, and configuration. +- Examples: Provide at least one runnable example in `examples/` demonstrating a typical run and expected outputs. + +**Collaboration & Avoiding Silos** + +- Don't build in a silo: Avoid side-loading models or making large, undocumented changes in a single component. That hides requirements from core maintainers and duplicates effort. +- Start minimal: Implement a small, well-documented component that demonstrates the required inputs/outputs and includes a `component_definition.json`. Use this artifact to propose core API or schema changes. +- Communicate early: Open an issue or RFC in the appropriate `openEDI` repo describing the change, expected APIs, and link to the minimal example so reviewers can run and test it. +- Design for extensibility: Prefer plug-ins or optional configuration over hard-coded changes. Provide fallbacks so the component can run even if core features are not yet available. + +**Release & Contribution Workflow** + +- Use semantic or conventional commits to help automate changelogs. +- Use PR templates and require at least one reviewer for new components or breaking changes. +- PR checklist: unit tests pass, integration smoke-tested (if applicable), docs updated, and `component_definition.json` validated. + +**Checklist (Quick Start)** + +- Create component scaffold under `oedisi-example//`. +- Add `component_definition.json` and an example `helics_config.json` in `examples/`. +- Implement unit tests in `tests/unit/` and run them locally. +- Add an integration test in `tests/integration/` and provide `docker-compose.yml` to run it. +- Update the component `README.md` and this `best_practice_guide.md` with usage and test instructions. + +**Example commands** + +Run unit tests: + +```bash +pytest tests/unit +``` + +Run integration locally (example using docker-compose): + +```bash +docker-compose -f docker-compose.yml up --build +``` + +**Where to document changes** + +- Update the component `README.md` for usage and configuration examples. +- Update this file: `oedisi-example/best_practice_guide.md` with lessons learned and new steps. + diff --git a/best_practice_guide.pdf b/best_practice_guide.pdf new file mode 100644 index 0000000000000000000000000000000000000000..727b5fd6c7a7b2ed7579e4388a1071b263a9d3eb GIT binary patch literal 57755 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 max_count: + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.FEDERATE_CONNECTION_TIMED_OUT + raise HTTPException( + status_code=500, + detail = F"Failed to start simulation after {max_wait_time_min} minutes. Please check the logs for more details.", + ) else: - # ensure exceptions are observed to avoid warnings for idx, t in enumerate(tasks): try: res = t.result() @@ -274,26 +306,49 @@ async def run_simulation(): except Exception as exc: logger.error(f"Task {idx} failed: {exc}") + # max_wait_time_min = 1 + # max_count = max_wait_time_min * 60 / 5 + # count = 0 + while h.helicsBrokerIsConnected(broker): - time.sleep(1) + await asyncio.sleep(sleep_delay_sec) + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.RUNNING query_result = broker.query("broker", "current_state") - logger.info(f"Federates expected: {len(component_map)-1}") - logger.info(f"Federates connected: {len(broker.query("broker", "federates"))}") + expected_federates = len(component_map)-1 + connected_federates = len(broker.query("broker", "federates")) + logger.info(f"Federates expected: {expected_federates}") + logger.info(f"Federates connected: {connected_federates}") logger.info(f"Simulation state: {query_result['state']}") - logger.info(f"Global time: {query_result['attributes']['parent']}") - h.helicsCloseLibrary() + logger.info(f"Global time: {get_time_data(broker)}") + + if expected_federates != connected_federates: + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.FEDERATE_RUNTIME_FAILURE + raise HTTPException( + error_code=500, + detail= "Federate failure. Check out the logs" + ) + count += 1 + h.helicsCloseLibrary() + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.COMPLETED return @app.post("/run") async def run_feeder(background_tasks: BackgroundTasks): + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.STARTING_SIMULATION try: background_tasks.add_task(run_simulation) - response = ServerReply(detail="Task sucessfully added.").dict() + response = ServerReply(detail="Task sucessfully added.").model_dump() return JSONResponse({"detail": response}, 200) except Exception as e: err = traceback.format_exc() + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.SIMULATION_ERROR raise HTTPException(status_code=404, detail=str(err)) @@ -301,46 +356,40 @@ async def run_feeder(background_tasks: BackgroundTasks): async def configure(wiring_diagram: WiringDiagram): global WIRING_DIAGRAM WIRING_DIAGRAM = wiring_diagram + json.dump(wiring_diagram.model_dump(), open(WIRING_DIAGRAM_FILENAME, "w")) + try: + for component in wiring_diagram.components: + component_model = ComponentStruct(component=component, links=[]) + for link in wiring_diagram.links: + if link.target == component.name: + component_model.links.append(link) - json.dump(wiring_diagram.dict(), open(WIRING_DIAGRAM_FILENAME, "w")) - for component in wiring_diagram.components: - component_model = ComponentStruct(component=component, links=[]) - for link in wiring_diagram.links: - if link.target == component.name: - component_model.links.append(link) - - url = build_url(component.host, component.container_port, ["configure"]) - logger.info(f"making a request to url - {url}") - - r = requests.post(url[:-1], json=component_model.dict()) - assert ( - r.status_code == 200 - ), f"POST request to update configuration failed for url - {url}" - return JSONResponse( - ServerReply( - detail="Sucessfully updated config files for all containers" - ).dict(), - 200, - ) + url = build_url(component.host, component.container_port, ["configure"]) + logger.info(f"making a request to url - {url}") + r = requests.post(url[:-1], json=component_model.model_dump()) + assert ( + r.status_code == 200 + ), f"POST request to update configuration failed for url - {url}" + + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.FEDERATE_CONFIGURATION_SUCESSS + return JSONResponse( + ServerReply( + detail="Sucessfully updated config files for all containers" + ).model_dump(), + 200, + ) + except Exception as _: + err = traceback.format_exc() + async with app.state.simulation_lock: + app.state.simulation_state = SimulationState.FEDERATE_CONFIGURATION_FAILURE + raise HTTPException(status_code=404, detail=str(err)) @app.get("/status") async def status(): - try: - name_2_timedata = {} - connected = h.helicsBrokerIsConnected(app.state.broker) - if connected: - for time_data in get_time_data(app.state.broker): - if (time_data.name not in name_2_timedata) or ( - name_2_timedata[time_data.name] != time_data - ): - name_2_timedata[time_data.name] = time_data - return {"connected": connected, "timedata": name_2_timedata, "error": False} - except AttributeError as e: - return {"reply": str(e), "error": True} + return {"Simulation status": app.state.simulation_state.value} if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=int(os.environ["PORT"])) - # test_function() - # read_settings() From a3ca15db9e3b9aea79186ca987a4f6168cb44609 Mon Sep 17 00:00:00 2001 From: Aadil Latif Date: Wed, 7 Jan 2026 16:33:10 -0700 Subject: [PATCH 4/4] workflow edit --- .github/workflows/publish-on-release.yml | 47 +++---- .gitignore | 4 +- LocalFeeder/Dockerfile | 1 + broker/Dockerfile | 1 - workflow_runner.py | 157 +++++++++++++++++++++++ 5 files changed, 179 insertions(+), 31 deletions(-) create mode 100644 workflow_runner.py diff --git a/.github/workflows/publish-on-release.yml b/.github/workflows/publish-on-release.yml index 7189413..add5af1 100644 --- a/.github/workflows/publish-on-release.yml +++ b/.github/workflows/publish-on-release.yml @@ -29,36 +29,25 @@ jobs: - name: Log in to Docker Hub uses: docker/login-action@v2 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_KEY }} + username: ${{ secrets.DOCKERHUB_USERNAME_AL }} + password: ${{ secrets.DOCKERHUB_API_KEY_AL }} - - name: Find Docker contexts and build+push + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install docker python-dotenv requests rich + + - name: Run workflow_runner.py env: RELEASE_TAG: ${{ github.event.inputs.release_tag || github.ref_name }} - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_USERNAME_AL: ${{ secrets.DOCKERHUB_USERNAME_AL }} + DOCKERHUB_API_KEY_AL: ${{ secrets.DOCKERHUB_API_KEY_AL }} + MAILJET_API_KEY: ${{ secrets.MAILJET_API_KEY }} + MAILJET_API_SECRET: ${{ secrets.MAILJET_API_SECRET }} run: | - set -eu - echo "Finding Dockerfiles..." - # find directories containing a Dockerfile (limit depth to avoid scanning big folders) - mapfile -t dirs < <(find . -maxdepth 4 -type f -name Dockerfile -exec dirname {} \; | sort -u) - if [ ${#dirs[@]} -eq 0 ]; then - echo "No Dockerfiles found. Exiting." - exit 0 - fi - - echo "Found contexts:" - for d in "${dirs[@]}"; do - echo " - $d" - done - - # Normalize a tag without a leading 'v' (so both 'v1.2.3' and '1.2.3' are available) - NORMALIZED_TAG=$(echo "${RELEASE_TAG}" | sed 's/^v//') - - for d in "${dirs[@]}"; do - # create an image name from path: replace leading ./ and slashes with _ - name=$(echo "$d" | sed 's|^\./||; s|/|_|g') - image_with_v="${DOCKERHUB_USERNAME}/${name}:${RELEASE_TAG}" - image_no_v="${DOCKERHUB_USERNAME}/${name}:${NORMALIZED_TAG}" - echo "Building and pushing ${image_with_v} and ${image_no_v} from context ${d}" - docker buildx build --platform linux/amd64,linux/arm64 -t "${image_with_v}" -t "${image_no_v}" "${d}" --push - done + python workflow_runner.py diff --git a/.gitignore b/.gitignore index 3db999d..900b322 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,6 @@ test_system_runner.json recorder_* local_feeder build -.vscode/ \ No newline at end of file +.vscode/ +.secrets +.env \ No newline at end of file diff --git a/LocalFeeder/Dockerfile b/LocalFeeder/Dockerfile index 500dd62..b82d1f6 100644 --- a/LocalFeeder/Dockerfile +++ b/LocalFeeder/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.10.6-slim-bullseye +LABEL org.opencontainers.image.authors="Aadil Latif " RUN apt-get update RUN apt-get install -y git ssh RUN apt install build-essential cmake git python3-dev -y diff --git a/broker/Dockerfile b/broker/Dockerfile index a65fadf..dea9963 100644 --- a/broker/Dockerfile +++ b/broker/Dockerfile @@ -1,5 +1,4 @@ FROM python:3.13-slim-bullseye - RUN apt-get update RUN apt-get install -y git ssh RUN apt install build-essential cmake git python3-dev -y diff --git a/workflow_runner.py b/workflow_runner.py new file mode 100644 index 0000000..94f012b --- /dev/null +++ b/workflow_runner.py @@ -0,0 +1,157 @@ +from requests.auth import HTTPBasicAuth +from collections import defaultdict +from pathlib import Path +import requests +import json +import os + +from dotenv import load_dotenv +import requests +from rich import print +import docker + + +load_dotenv() + +# --- Configuration --- +DOCKERHUB_USERNAME_AL = os.getenv("DOCKERHUB_USERNAME_AL") +DOCKERHUB_API_KEY_AL = os.getenv("DOCKERHUB_API_KEY_AL") +MAILJET_API_KEY = os.environ["MAILJET_API_KEY"] +MAILJET_API_SECRET = os.environ["MAILJET_API_SECRET"] +TAG = os.environ["RELEASE_TAG"] + + +def collect_components(): + # Simulate collecting components + print("Collecting components...") + components = json.load(open("components.json", "r")) # Load components from a JSON file + for component_name, component_path in components.items(): + try: + print(f"Building image for component {component_name}...") + component_path = Path(component_path).parent + if not component_path.is_dir(): + raise ValueError(f"Component path {component_path} is not a directory") + if not (component_path / "Dockerfile").is_file(): + raise ValueError(f"No Dockerfile found in {component_path}") + build_and_push_docker_image(component_name, component_path, ) + except Exception as e: + print(f"An unexpected error occurred: {e}") + +def build_and_push_docker_image(image_name, docker_file_path, tag="v0.0.1"): + client = docker.from_env() + REPOSITORY_NAME = f"{DOCKERHUB_USERNAME_AL}/{image_name}:{tag}".lower() + email_msg = f"Building Docker image: {REPOSITORY_NAME} from directory {docker_file_path}" + + try: + # Build the image + image, build_logs = client.images.build( + path=str(docker_file_path), + tag=REPOSITORY_NAME, + rm=True, # Remove intermediate containers after a successful build + nocache=False, # Do not use cache when building the image + ) + + email_msg += f"\nSuccessfully built image: {REPOSITORY_NAME}" + + labels = image.attrs['Config'].get('Labels') or {} + author_names = [] + author_emails = [] + authors_label = labels.get("org.opencontainers.image.authors") + if authors_label: + for author in authors_label.split(","): + author = author.strip() + if "<" in author and ">" in author: + name, email = author.split("<", 1) + author_names.append(name.strip()) + author_emails.append(email.strip("> ").strip()) + else: + author_names.append(author) + author_emails.append(None) + + print(f"Image authors: {{'names': author_names, 'emails': author_emails}}") + + + except docker.errors.BuildError as e: + email_msg += f"\nError building image: {e}" + print(f"Error building image: {e}") + return + + print(f"2. Logging in to Docker Hub as {DOCKERHUB_USERNAME_AL}") + email_msg += f"\nLogging in to Docker Hub as {DOCKERHUB_USERNAME_AL}" + + email_msg += f"\nPushing image to Docker Hub: {REPOSITORY_NAME}" + print(f"3. Pushing image to Docker Hub: {REPOSITORY_NAME}") + try: + # Push the image to Docker Hub + # The push operation returns an iterator of events + push_logs = client.images.push( + repository=f"{DOCKERHUB_USERNAME_AL}/{image_name}".lower(), + tag=TAG, + stream=True, + decode=True + ) + for line in push_logs: + if 'status' in line: + print(line['status']) + elif 'error' in line: + print(f"Error during push: {line['error']}") + email_msg += f"\nError during push: {line['error']}" + + print("Image pushed successfully to Docker Hub.") + email_msg += f"\nImage pushed successfully to Docker Hub." + + + except docker.errors.APIError as e: + print(f"Docker API Error during push: {e}") + email_msg += f"Docker API Error during push: {e}" + except Exception as e: + print(f"An unexpected error occurred: {e}") + email_msg += f"An unexpected error occurred: {e}" + + for name, email in zip(author_names, author_emails): + if not email: + print(f"Skipping author '{name}' - no email available") + continue + + payload = { + "Messages": [ + { + "From": { + "Email": "aadil.latif@gmail.com", + "Name": "Aadil Latif" + }, + "To": [ + { + "Email": email, + "Name": name + } + ], + "Subject": "OEDISI GitHub Workflow Notification", + "TextPart": email_msg, + } + ] + } + + try: + response = requests.post( + "https://api.mailjet.com/v3.1/send", + json=payload, + auth=HTTPBasicAuth(MAILJET_API_KEY, MAILJET_API_SECRET), + timeout=30, + ) + response.raise_for_status() + print(f"Mail sent to {email}: {response.status_code}") + except requests.exceptions.HTTPError as http_err: + # Surface the server response body to help debugging 400 errors + try: + err_text = response.text + except Exception: + err_text = str(http_err) + print(f"Mailjet HTTP error for {email}: {response.status_code} - {err_text}") + except Exception as e: + print(f"Failed to send mail to {email}: {e}") + + +if __name__ == "__main__": + os.system("docker login -u {} -p {}".format(DOCKERHUB_USERNAME_AL, DOCKERHUB_API_KEY_AL)) + collect_components() \ No newline at end of file