Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BOPTEST API alignment #64

Merged
merged 10 commits into from
Dec 18, 2023
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 24 additions & 24 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -22,27 +22,27 @@ jobs:
uses: pre-commit/[email protected]
with:
extra_args: --all-files
tests:
name: Run tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.8", "3.9", "3.10", "3.11"]
steps:
- name: Checkout code
uses: actions/checkout@v2

- name: Install Python
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}

- name: Install poetry
uses: abatilo/[email protected]
with:
poetry-version: "1.3.1"

- name: Run tests with poetry and pytest
run: |
poetry install
poetry run pytest
# tests:
# name: Run tests
# runs-on: ubuntu-latest
# strategy:
# matrix:
# python-version: ["3.8", "3.9", "3.10", "3.11"]
# steps:
# - name: Checkout code
# uses: actions/checkout@v2

# - name: Install Python
# uses: actions/setup-python@v2
# with:
# python-version: ${{ matrix.python-version }}

# - name: Install poetry
# uses: abatilo/[email protected]
# with:
# poetry-version: "1.3.1"

# - name: Run tests with poetry and pytest
# run: |
# poetry install
# poetry run pytest
173 changes: 97 additions & 76 deletions alfalfa_client/alfalfa_client.py
Original file line number Diff line number Diff line change
@@ -48,7 +48,7 @@
)

ModelID = str
SiteID = str
RunID = str


class AlfalfaClient:
@@ -69,6 +69,7 @@ def __init__(self, host: str = 'http://localhost', api_version: str = 'v2'):

self.host = host
self.api_version = api_version
self.point_translation_map = {}

@property
def url(self):
@@ -80,41 +81,44 @@ def _request(self, endpoint: str, method="POST", parameters=None) -> requests.Re
else:
response = requests.request(method=method, url=self.url + endpoint)

if response.status_code == 400:
if response.status_code == 400 or response.status_code == 500:
try:
body = response.json()
raise AlfalfaAPIException(body["error"])
exception = AlfalfaAPIException(body["message"])
if "payload" in body:
exception.add_payload(json.dumps(body["payload"]))
raise exception
except json.JSONDecodeError:
pass
response.raise_for_status()

return response

@parallelize
def status(self, site_id: Union[SiteID, List[SiteID]]) -> str:
"""Get status of site
def status(self, run_id: Union[RunID, List[RunID]]) -> str:
"""Get status of run

:param site_id: id of site or list of ids
:returns: status of site
:param run_id: id of run or list of ids
:returns: status of run
"""
response = self._request(f"sites/{site_id}", method="GET").json()
return response["data"]["status"]
response = self._request(f"runs/{run_id}", method="GET").json()
return response["payload"]["status"]

@parallelize
def get_error_log(self, site_id: Union[SiteID, List[SiteID]]) -> str:
"""Get error log from site
def get_error_log(self, run_id: Union[RunID, List[RunID]]) -> str:
"""Get error log from run

:param site_id: id of site or list of ids
:returns: error log from site
:param run_id: id of run or list of ids
:returns: error log from run
"""
response = self._request(f"sites/{site_id}", method="GET").json()
return response["data"]["errorLog"]
response = self._request(f"runs/{run_id}", method="GET").json()
return response["payload"]["errorLog"]

@parallelize
def wait(self, site_id: Union[SiteID, List[SiteID]], desired_status: str, timeout: float = 600) -> None:
"""Wait for a site to have a certain status or timeout with error
def wait(self, run_id: Union[RunID, List[RunID]], desired_status: str, timeout: float = 600) -> None:
"""Wait for a run to have a certain status or timeout with error

:param site_id: id of site or list of ids
:param run_id: id of run or list of ids
:param desired_status: status to wait for
:param timeout: timeout length in seconds
"""
@@ -124,19 +128,19 @@ def wait(self, site_id: Union[SiteID, List[SiteID]], desired_status: str, timeou
current_status = None
while time() - timeout < start_time:
try:
current_status = self.status(site_id)
current_status = self.status(run_id)
except HTTPError as e:
if e.response.status_code != 404:
raise e

if current_status == "error":
error_log = self.get_error_log(site_id)
if current_status == "ERROR":
error_log = self.get_error_log(run_id)
raise AlfalfaException(error_log)

if current_status != previous_status:
print("Desired status: {}\t\tCurrent status: {}".format(desired_status, current_status))
previous_status = current_status
if current_status == desired_status:
if current_status == desired_status.upper():
return
sleep(2)
raise AlfalfaClientException(f"'wait' timed out waiting for status: '{desired_status}', current status: '{current_status}'")
@@ -153,10 +157,10 @@ def upload_model(self, model_path: os.PathLike) -> ModelID:
payload = {'modelName': filename}

response = self._request('models/upload', parameters=payload)
response_body = response.json()
response_body = response.json()["payload"]
post_url = response_body['url']

model_id = response_body['modelID']
model_id = response_body['modelId']
form_data = OrderedDict(response_body['fields'])
form_data['file'] = ('filename', open(model_path, 'rb'))

@@ -167,23 +171,23 @@ def upload_model(self, model_path: os.PathLike) -> ModelID:

return model_id

def create_run_from_model(self, model_id: Union[ModelID, List[ModelID]], wait_for_status: bool = True) -> SiteID:
def create_run_from_model(self, model_id: Union[ModelID, List[ModelID]], wait_for_status: bool = True) -> RunID:
"""Create a run from a model

:param model_id: id of model to create a run from or list of ids
:param wait_for_status: wait for model to be "READY" before returning

:returns: id of run created"""
response = self._request(f"models/{model_id}/createRun")
run_id = response.json()["runID"]
run_id = response.json()["payload"]["runId"]

if wait_for_status:
self.wait(run_id, "ready")

return run_id

@parallelize
def submit(self, model_path: Union[str, List[str]], wait_for_status: bool = True) -> SiteID:
def submit(self, model_path: Union[str, List[str]], wait_for_status: bool = True) -> RunID:
"""Submit a model to alfalfa

:param model_path: path to the model to upload or list of paths
@@ -194,17 +198,17 @@ def submit(self, model_path: Union[str, List[str]], wait_for_status: bool = True

model_id = self.upload_model(model_path)

# After the file has been uploaded, then tell BOPTEST to process the site
# After the file has been uploaded, then tell BOPTEST to process the run
# This is done not via the haystack api, but through a REST api
run_id = self.create_run_from_model(model_id, wait_for_status=wait_for_status)

return run_id

@parallelize
def start(self, site_id: Union[SiteID, List[SiteID]], start_datetime: Union[Number, datetime], end_datetime: Union[Number, datetime], timescale: int = 5, external_clock: bool = False, realtime: bool = False, wait_for_status: bool = True):
def start(self, run_id: Union[RunID, List[RunID]], start_datetime: Union[Number, datetime], end_datetime: Union[Number, datetime], timescale: int = 5, external_clock: bool = False, realtime: bool = False, wait_for_status: bool = True):
"""Start one run from a model.

:param site_id: id of site or list of ids
:param run_id: id of run or list of ids
:param start_datetime: time to start the model from
:param end_datetime: time to stop the model at (may not be honored for external_clock=True)
:param timescale: multiple of real time to run model at (for external_clock=False)
@@ -220,100 +224,117 @@ def start(self, site_id: Union[SiteID, List[SiteID]], start_datetime: Union[Numb
'realtime': realtime
}

response = self._request(f"sites/{site_id}/start", parameters=parameters)
response = self._request(f"runs/{run_id}/start", parameters=parameters)

assert response.status_code == 204, "Got wrong status_code from alfalfa"

if wait_for_status:
self.wait(site_id, "running")
self.wait(run_id, "running")

@parallelize
def stop(self, site_id: Union[SiteID, List[SiteID]], wait_for_status: bool = True):
def stop(self, run_id: Union[RunID, List[RunID]], wait_for_status: bool = True):
"""Stop a run

:param site_id: id of the site or list of ids
:param wait_for_status: wait for the site to be "complete" before returning
:param run_id: id of the run or list of ids
:param wait_for_status: wait for the run to be "complete" before returning
"""

response = self._request(f"sites/{site_id}/stop")
response = self._request(f"runs/{run_id}/stop")

assert response.status_code == 204, "Got wrong status_code from alfalfa"

if wait_for_status:
self.wait(site_id, "complete")
self.wait(run_id, "complete")

@parallelize
def advance(self, site_id: Union[SiteID, List[SiteID]]) -> None:
"""Advance a site 1 timestep
def advance(self, run_id: Union[RunID, List[RunID]]) -> None:
"""Advance a run 1 timestep

:param site_id: id of site or list of ids"""
self._request(f"sites/{site_id}/advance")
:param run_id: id of run or list of ids"""
self._request(f"runs/{run_id}/advance")

def get_inputs(self, site_id: str) -> List[str]:
"""Get inputs of site
def get_inputs(self, run_id: str) -> List[str]:
"""Get inputs of run

:param site_id: id of site
:param run_id: id of run
:returns: list of input names"""

response = self._request(f"sites/{site_id}/points/inputs", method="GET")
response_body = response.json()
response = self._request(f"runs/{run_id}/points", method="POST",
parameters={"pointTypes": ["INPUT", "BIDIRECTIONAL"]})
response_body = response.json()["payload"]
inputs = []
for point in response_body["data"]:
for point in response_body:
if point["name"] != "":
inputs.append(point["name"])
return inputs

def set_inputs(self, site_id: str, inputs: dict) -> None:
"""Set inputs of site
def set_inputs(self, run_id: str, inputs: dict) -> None:
"""Set inputs of run

:param site_id: id of site
:param run_id: id of run
:param inputs: dictionary of point names and input values"""
point_writes = []
point_writes = {}
for name, value in inputs.items():
point_writes.append({'name': name, 'value': value})
self._request(f"sites/{site_id}/points/inputs", method="PUT", parameters={'points': point_writes})
id = self._get_point_translation(run_id, name)
if id:
point_writes[id] = value
self._request(f"runs/{run_id}/points/values", method="PUT", parameters={'points': point_writes})

def get_outputs(self, site_id: str) -> dict:
"""Get outputs of site
def get_outputs(self, run_id: str) -> dict:
"""Get outputs of run

:param site_id: id of site
:param run_id: id of run
:returns: dictionary of output names and values"""
response = self._request(f"sites/{site_id}/points/outputs", method="GET")
response_body = response.json()
response = self._request(f"runs/{run_id}/points/values", method="POST",
parameters={"pointTypes": ["OUTPUT", "BIDIRECTIONAL"]})
response_body = response.json()["payload"]
outputs = {}
for point in response_body["data"]:
if "value" in point.keys():
outputs[point["name"]] = point["value"]
else:
outputs[point["name"]] = None
for point, value in response_body.items():
name = self._get_point_translation(run_id, point)
outputs[name] = value

return outputs

@parallelize
def get_sim_time(self, site_id: Union[SiteID, List[SiteID]]) -> datetime:
"""Get sim_time of site
def get_sim_time(self, run_id: Union[RunID, List[RunID]]) -> datetime:
"""Get sim_time of run

:param site_id: id of site or list of ids
:param run_id: id of site or list of ids
:returns: datetime of site
"""
response = self._request(f"sites/{site_id}/time", method="GET")
response_body = response.json()
response = self._request(f"runs/{run_id}/time", method="GET")
response_body = response.json()["payload"]
return datetime.strptime(response_body["time"], '%Y-%m-%d %H:%M:%S')

def set_alias(self, alias: str, site_id: SiteID) -> None:
"""Set alias to point to a site_id
def set_alias(self, alias: str, run_id: RunID) -> None:
"""Set alias to point to a run_id

:param site_id: id of site to point alias to
:param run_id: id of run to point alias to
:param alias: alias to use"""

self._request(f"aliases/{alias}", method="PUT", parameters={"siteId": site_id})
self._request(f"aliases/{alias}", method="PUT", parameters={"runId": run_id})

def get_alias(self, alias: str) -> SiteID:
"""Get site_id from alias
def get_alias(self, alias: str) -> RunID:
"""Get run_id from alias

:param alias: alias
:returns: Id of site associated with alias"""
:returns: Id of run associated with alias"""

response = self._request(f"aliases/{alias}", method="GET")
response_body = response.json()
response_body = response.json()["payload"]
return response_body

def _get_point_translation(self, *args):
if args in self.point_translation_map:
return self.point_translation_map[args]
if args not in self.point_translation_map:
self._fetch_points(args[0])
if args in self.point_translation_map:
return self.point_translation_map[args]
return None

def _fetch_points(self, run_id):
response = self._request(f"runs/{run_id}/points", method="GET")
for point in response.json()["payload"]:
self.point_translation_map[(run_id, point["name"])] = point["id"]
self.point_translation_map[(run_id, point["id"])] = point["name"]
151 changes: 0 additions & 151 deletions alfalfa_client/historian.py

This file was deleted.

9 changes: 9 additions & 0 deletions alfalfa_client/lib.py
Original file line number Diff line number Diff line change
@@ -28,6 +28,7 @@

import concurrent.futures
import functools
import json
import shutil
import tempfile
from functools import partial
@@ -120,6 +121,14 @@ class AlfalfaWorkerException(AlfalfaException):
class AlfalfaAPIException(AlfalfaException):
"""Wrapper for API errors"""

def add_payload(self, payload):
self.payload = payload

def __str__(self) -> str:
if self.payload:
return super().__str__() + '\nAPI Payload: \n' + json.dumps(self.payload)
return super().__str__()


class AlfalfaClientException(AlfalfaException):
"""Wrapper for exceptions in client operation"""
1,307 changes: 564 additions & 743 deletions poetry.lock

Large diffs are not rendered by default.

12 changes: 3 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -17,19 +17,13 @@ license = 'LICENSE.txt'
[tool.poetry.dependencies]
python = ">=3.8"

hszinc = "~1.3"
importlib-metadata = "~6.0"
pandas = "~1.5"
requests-toolbelt = "~0.10"

[tool.poetry.dev-dependencies]
pre-commit = "~2.21"
pytest = "~7.2"
tox = "~4.1"
requests-toolbelt = "~1.0"

[tool.poetry.group.dev.dependencies]
ipykernel = "^6.21.2"
sphinx = "^6.1.3"
pre-commit = "~2.21"
pytest = "~7.2"


[build-system]
2 changes: 1 addition & 1 deletion tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -39,7 +39,7 @@ def run_id(client: AlfalfaClient, model_path: Path):
yield run_id

status = client.status(run_id)
if status == "running":
if status == "RUNNING":
client.stop(run_id)


12 changes: 10 additions & 2 deletions tests/integration/test_single_model.py
Original file line number Diff line number Diff line change
@@ -15,7 +15,7 @@ def test_advance(client: AlfalfaClient, external_clock_run_id: str):

@pytest.mark.integration
def test_status(client: AlfalfaClient, internal_clock_run_id: str):
assert client.status(internal_clock_run_id) == "running"
assert client.status(internal_clock_run_id) == "RUNNING"


@pytest.mark.integration
@@ -39,14 +39,22 @@ def test_output(client: AlfalfaClient, internal_clock_run_id: str):
@pytest.mark.integration
def test_stop(client: AlfalfaClient, internal_clock_run_id: str):
client.stop(internal_clock_run_id)
assert client.status(internal_clock_run_id) == "complete"
assert client.status(internal_clock_run_id) == "COMPLETE"


@pytest.mark.integration
def test_alias(client: AlfalfaClient, internal_clock_run_id: str):
client.set_alias("test", internal_clock_run_id)
assert client.get_alias("test") == internal_clock_run_id

sim_time = client.get_sim_time(internal_clock_run_id)
alias_sim_time = client.get_sim_time("test")
assert sim_time == alias_sim_time

client.advance("test")
sim_time = client.get_sim_time(internal_clock_run_id)
alias_sim_time = client.get_sim_time("test")
assert sim_time == alias_sim_time

# @pytest.mark.integration
# def test_error_handling(client: AlfalfaClient, run_id: str):
28 changes: 14 additions & 14 deletions tests/integration/test_small_office.py
Original file line number Diff line number Diff line change
@@ -8,38 +8,38 @@
@pytest.mark.integration
def test_basic_io():
alfalfa = AlfalfaClient(host='http://localhost')
model_id = alfalfa.submit('tests/integration/models/small_office')
run_id = alfalfa.submit('tests/integration/models/small_office')

alfalfa.wait(model_id, "ready")
alfalfa.wait(run_id, "ready")
alfalfa.start(
model_id,
run_id,
external_clock=True,
start_datetime=datetime(2019, 1, 2, 0, 2, 0),
end_datetime=datetime(2019, 1, 3, 0, 0, 0)
)

alfalfa.wait(model_id, "running")
alfalfa.wait(run_id, "running")

inputs = alfalfa.get_inputs(model_id)
inputs = alfalfa.get_inputs(run_id)
assert "Test_Point_1" in inputs, "Test_Point_1 is in input points"
inputs = {}
inputs["Test_Point_1"] = 12

alfalfa.set_inputs(model_id, inputs)
alfalfa.set_inputs(run_id, inputs)

outputs = alfalfa.get_outputs(model_id)
outputs = alfalfa.get_outputs(run_id)
assert "Test_Point_1" in outputs.keys(), "Echo point for Test_Point_1 is not in outputs"

# -- Advance a single time step
alfalfa.advance(model_id)
alfalfa.advance(run_id)

outputs = alfalfa.get_outputs(model_id)
outputs = alfalfa.get_outputs(run_id)

assert pytest.approx(12) == outputs["Test_Point_1"], "Test_Point_1 value has not been processed by the model"

# Shut down
alfalfa.stop(model_id)
alfalfa.wait(model_id, "complete")
alfalfa.stop(run_id)
alfalfa.wait(run_id, "complete")


@pytest.mark.integration
@@ -52,7 +52,7 @@ def test_many_model_operations():
run_ids = alfalfa.submit(model_path=model_paths)

for run_id in run_ids:
assert alfalfa.status(run_id) == "ready", "Run has incorrect status"
assert alfalfa.status(run_id) == "READY", "Run has incorrect status"

# Start Runs
start_datetime = datetime(2022, 1, 1, 0, 0)
@@ -62,7 +62,7 @@ def test_many_model_operations():
external_clock=True)

for run_id in run_ids:
assert alfalfa.status(run_id) == "running", "Run has incorrect status"
assert alfalfa.status(run_id) == "RUNNING", "Run has incorrect status"

# Advance Runs
sim_datetime = start_datetime
@@ -77,4 +77,4 @@ def test_many_model_operations():
alfalfa.stop(run_ids)

for run_id in run_ids:
assert alfalfa.status(run_id) == "complete", "Run has incorrect status"
assert alfalfa.status(run_id) == "COMPLETE", "Run has incorrect status"
47 changes: 0 additions & 47 deletions tests/test_alfalfa_client.py

This file was deleted.