Skip to content

Commit

Permalink
Merge pull request #144 from NASA-AMMOS/feat/procedural-goals
Browse files Browse the repository at this point in the history
Update scheduling commands for new schema and procedural goals
  • Loading branch information
cartermak authored Dec 18, 2024
2 parents 41d9c64 + 36de0d7 commit aeae349
Show file tree
Hide file tree
Showing 16 changed files with 202 additions and 124 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
DOCKER_TAG=v2.18.0
DOCKER_TAG=v3.2.0
REPOSITORY_DOCKER_URL=ghcr.io/nasa-ammos

AERIE_USERNAME=aerie
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ jobs:
strategy:
matrix:
python-version: ["3.6.15", "3.11"]
aerie-version: ["2.18.0"]
aerie-version: ["3.0.1", "3.1.1", "3.2.0"]
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ __pycache__/
args.json
**/*.DS_Store
tests/integration_tests/artifacts
venv/
.idea/
60 changes: 40 additions & 20 deletions src/aerie_cli/aerie_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -612,20 +612,23 @@ def delete_plan(self, plan_id: int) -> str:

return resp["name"]

def upload_file(self, path: str) -> int:
upload_timestamp = arrow.utcnow().isoformat()
path_obj = Path(path)
server_side_path = (
path_obj.stem + "--" + upload_timestamp + path_obj.suffix
)
with open(path, "rb") as f:
resp = self.aerie_host.post_to_gateway_files(
server_side_path, f)
return resp["id"]

def upload_mission_model(
self, mission_model_path: str, project_name: str, mission: str, version: str
) -> int:

# Create unique jar identifier for server side
upload_timestamp = arrow.utcnow().isoformat()
server_side_jar_name = (
Path(mission_model_path).stem + "--" + upload_timestamp + ".jar"
)
with open(mission_model_path, "rb") as jar_file:
resp = self.aerie_host.post_to_gateway_files(
server_side_jar_name, jar_file)

jar_id = resp["id"]
jar_id = self.upload_file(mission_model_path)

create_model_mutation = """
mutation CreateModel($model: mission_model_insert_input!) {
Expand Down Expand Up @@ -1449,7 +1452,7 @@ def get_scheduling_goals_by_specification(self, spec_id):

return resp

def create_dictionary(self, dictionary: str, dictionary_type: Union[str, DictionaryType]) -> int:
def create_dictionary(self, dictionary: str) -> int:
"""Upload an AMPCS command, channel, or parameter dictionary to an Aerie instance
Args:
Expand All @@ -1460,23 +1463,21 @@ def create_dictionary(self, dictionary: str, dictionary_type: Union[str, Diction
int: Dictionary ID
"""

if not isinstance(dictionary_type, DictionaryType):
dictionary_type = DictionaryType(dictionary_type)

query = """
mutation CreateDictionary($dictionary: String!, $type: String!) {
createDictionary: uploadDictionary(dictionary: $dictionary, type: $type) {
id
mutation CreateDictionary($dictionary: String!) {
createDictionary: uploadDictionary(dictionary: $dictionary) {
command
channel
parameter
}
}
"""
resp = self.aerie_host.post_to_graphql(
query,
dictionary=dictionary,
type=dictionary_type.value
dictionary=dictionary
)
return next(iter(resp.values()))["id"]

return resp["id"]

def list_dictionaries(self) -> Dict[DictionaryType, List[DictionaryMetadata]]:
"""List all command, parameter, and channel dictionaries
Expand Down Expand Up @@ -1739,7 +1740,7 @@ def upload_scheduling_goals(self, upload_object):
"""

upload_scheduling_goals_query = """
mutation InsertGoal($input:[scheduling_goal_definition_insert_input]!){
mutation InsertGoal($input: [scheduling_goal_definition_insert_input!]!){
insert_scheduling_goal_definition(objects: $input){
returning {goal_id}
}
Expand Down Expand Up @@ -1767,6 +1768,25 @@ def get_scheduling_specification_for_plan(self, plan_id):
)
return resp[0]["id"]

def get_goal_id_for_name(self, name):
get_goal_id_for_name_query = """
query GetNameForGoalId($name: String!) {
scheduling_goal_metadata(where: {name: {_eq: $name}}) {
id
}
}
"""

resp = self.aerie_host.post_to_graphql(
get_goal_id_for_name_query,
name=name
)
if len(resp) == 0:
raise RuntimeError(f"No goals found with name {name}.")
elif len(resp) > 1:
raise RuntimeError(f"Multiple goals found with name {name}.")
return resp[0]["id"]

def add_goals_to_specifications(self, upload_object):
"""
Bulk operation to add goals to specification.
Expand Down
32 changes: 18 additions & 14 deletions src/aerie_cli/aerie_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
from attrs import define, field

COMPATIBLE_AERIE_VERSIONS = [
"2.18.0"
"3.0.0",
"3.0.1",
"3.1.0",
"3.1.1",
"3.2.0",
]

class AerieHostVersionError(RuntimeError):
Expand Down Expand Up @@ -114,20 +118,20 @@ def post_to_graphql(self, query: str, **kwargs) -> Dict:
headers=self.get_auth_headers(),
)

if resp.ok:
try:
resp_json = resp.json()
except json.decoder.JSONDecodeError:
raise RuntimeError(f"Failed to process response")
resp.raise_for_status()
try:
resp_json = resp.json()
except json.decoder.JSONDecodeError:
raise RuntimeError(f"Failed to process response")

if "success" in resp_json.keys() and not resp_json["success"]:
raise RuntimeError("GraphQL request was not successful")
elif "errors" in resp_json.keys():
raise RuntimeError(
f"GraphQL Error: {json.dumps(resp_json['errors'])}"
)
else:
data = next(iter(resp.json()["data"].values()))
if "success" in resp_json.keys() and not resp_json["success"]:
raise RuntimeError("GraphQL request was not successful")
elif "errors" in resp_json.keys():
raise RuntimeError(
f"GraphQL Error: {json.dumps(resp_json['errors'])}"
)
else:
data = next(iter(resp.json()["data"].values()))

if data is None:
raise RuntimeError(f"Failed to process response: {resp}")
Expand Down
135 changes: 82 additions & 53 deletions src/aerie_cli/commands/scheduling.py
Original file line number Diff line number Diff line change
@@ -1,68 +1,98 @@
import typer
from pathlib import Path
from typing import Optional

from aerie_cli.commands.command_context import CommandContext

app = typer.Typer()

@app.command()
def upload(
model_id: int = typer.Option(
..., help="The mission model ID to associate with the scheduling goal", prompt=True
),
plan_id: int = typer.Option(
..., help="Plan ID", prompt=True
),
schedule: str = typer.Option(
..., help="Text file with one path on each line to a scheduling rule file, in decreasing priority order", prompt=True
)
):
"""Upload scheduling goal"""
client = CommandContext.get_client()

upload_obj = []
with open(schedule, "r") as infile:
for filepath in infile.readlines():
filepath = filepath.strip()
filename = filepath.split("/")[-1]
with open(filepath, "r") as f:
# Note that as of Aerie v2.3.0, the metadata (incl. model_id and goal name) are stored in a separate table,
# so we need to create a metadata entry along with the definition:
goal_obj = {
"definition": f.read(),
"metadata": {
"data": {
"name": filename,
"models_using": {
"data": {
"model_id": model_id
}
}
}
}
}
upload_obj.append(goal_obj)

resp = client.upload_scheduling_goals(upload_obj)

typer.echo(f"Uploaded scheduling goals to venue.")

uploaded_ids = [kv["goal_id"] for kv in resp]

#priority order is order of filenames in decreasing priority order
#will append to existing goals in specification priority order
specification = client.get_scheduling_specification_for_plan(plan_id)
def new(
path: Path = typer.Argument(default=...),
description: Optional[str] = typer.Option(
None, '--description', '-d', help="Description metadata"
),
public: bool = typer.Option(False, '--public', '-pub', help="Indicates a public goal visible to all users (default false)"),
name: Optional[str] = typer.Option(
None, '--name', '-n', help="Name of the new goal (default is the file name without extension)"
),
model_id: Optional[int] = typer.Option(
None, '--model', '-m', help="Mission model ID to associate with the scheduling goal"
),
plan_id: Optional[int] = typer.Option(
None, '--plan', '-p', help="Plan ID of the specification to add this to"
)
):
"""Upload new scheduling goal"""

upload_to_spec = [{"goal_id": goal_id, "specification_id": specification} for goal_id in uploaded_ids]
client = CommandContext.get_client()
filename = path.stem
extension = path.suffix
if name is None:
name = filename
upload_obj = {}
if extension == '.ts':
with open(path, "r") as f:
upload_obj["definition"] = f.read()
upload_obj["type"] = "EDSL"
elif extension == '.jar':
jar_id = client.upload_file(path)
upload_obj["uploaded_jar_id"] = jar_id
upload_obj["parameter_schema"] = {}
upload_obj["type"] = "JAR"
else:
raise RuntimeError(f"Unsupported goal file extension: {extension}")
metadata = {"name": name}
if description is not None:
metadata["description"] = description
metadata["public"] = public
if model_id is not None:
metadata["models_using"] = {"data": {"model_id": model_id}}
if plan_id is not None:
spec_id = client.get_scheduling_specification_for_plan(plan_id)
metadata["plans_using"] = {"data": {"specification_id": spec_id}}
upload_obj["metadata"] = {"data": metadata}
resp = client.upload_scheduling_goals([upload_obj])
id = resp[0]["goal_id"]
typer.echo(f"Uploaded scheduling goal to venue. ID: {id}")

client.add_goals_to_specifications(upload_to_spec)

typer.echo(f"Assigned goals in priority order to plan ID {plan_id}.")
@app.command()
def update(
path: Path = typer.Argument(default=...),
goal_id: Optional[int] = typer.Option(None, '--goal', '-g', help="Goal ID of goal to be updated (will search by name if omitted)"),
name: Optional[str] = typer.Option(None, '--name', '-n', help="Name of the goal to be updated (ignored if goal is provided, default is the file name without extension)"),
):
"""Upload an update to a scheduling goal"""
client = CommandContext.get_client()
filename = path.stem
extension = path.suffix
if goal_id is None:
if name is None:
name = filename
goal_id = client.get_goal_id_for_name(name)
upload_obj = {"goal_id": goal_id}
if extension == '.ts':
with open(path, "r") as f:
upload_obj["definition"] = f.read()
upload_obj["type"] = "EDSL"
elif extension == '.jar':
jar_id = client.upload_file(path)
upload_obj["uploaded_jar_id"] = jar_id
upload_obj["parameter_schema"] = {}
upload_obj["type"] = "JAR"
else:
raise RuntimeError(f"Unsupported goal file extension: {extension}")

resp = client.upload_scheduling_goals([upload_obj])
id = resp[0]["goal_id"]
typer.echo(f"Uploaded new version of scheduling goal to venue. ID: {id}")


@app.command()
def delete(
goal_id: int = typer.Option(
..., help="Goal ID of goal to be deleted", prompt=True
..., '--goal', '-g', help="Goal ID of goal to be deleted", prompt=True
)
):
"""Delete scheduling goal"""
Expand All @@ -77,13 +107,12 @@ def delete_all_goals_for_plan(
..., help="Plan ID", prompt=True
),
):

client = CommandContext.get_client()

specification = client.get_scheduling_specification_for_plan(plan_id)
clear_goals = client.get_scheduling_goals_by_specification(specification) #response is in asc order
clear_goals = client.get_scheduling_goals_by_specification(specification) # response is in asc order

if len(clear_goals) == 0: #no goals to clear
if len(clear_goals) == 0: # no goals to clear
typer.echo("No goals to delete.")
return

Expand Down
18 changes: 11 additions & 7 deletions tests/integration_tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,19 @@ python3 -m pytest .

## Updating Tests for New Aerie Versions

Integration tests are automatically run by CI against all supported Aerie versions. To add and test support for a new Aerie version:
Integration tests are automatically run by CI against all supported Aerie versions. Update as follows with the supported set of Aerie versions:

1. Download the appropriate version release JAR for the [Banananation model](https://github.com/NASA-AMMOS/aerie/packages/1171106/versions) and add it to `tests/integration_tests/models`, named as `banananation-X.X.X.jar` (substituting the correct version number).
2. Update the [`.env`](../../.env) file `DOCKER_TAG` value to the new version string. This defaults the local deployment to the latest Aerie version.
3. Update [`docker-compose-test.yml`](../../docker-compose-test.yml) as necessary to match the new Aerie version. The [aerie-ui compose file](https://github.com/NASA-AMMOS/aerie-ui/blob/develop/docker-compose-test.yml) can be a helpful reference to identify changes.
4. Manually run the integration tests and update the code and tests as necessary for any Aerie changes.
1. Integration tests require a JAR for the Banananation model for each tested Aerie version. [Download official artifacts from Github](https://github.com/NASA-AMMOS/aerie/packages/1171106/versions) and add to `tests/integration_tests/files/models`, named as `banananation-X.X.X.jar` (substituting the correct version number). Remove outdated JAR files.
2. Update the `COMPATIBLE_AERIE_VERSIONS` array in [`aerie_host.py`](../../src/aerie_cli/aerie_host.py).
3. Update the [`.env`](../../.env) file `DOCKER_TAG` value to the latest compatible version. This sets the default value for a local Aerie deployment.
4. Update [`docker-compose-test.yml`](../../docker-compose-test.yml) as necessary to match the supported Aerie versions. The [aerie-ui compose file](https://github.com/NASA-AMMOS/aerie-ui/blob/develop/docker-compose-test.yml) can be a helpful reference to identify changes.
5. Update the `aerie-version` list in the [CI configuration](../../.github/workflows/test.yml) to include the new version.
6. If breaking changes are necessary to support the new Aerie version, remove any Aerie versions which are no longer supported from the CI configuration and remove the corresponding banananation JAR file.
7. Open a PR and verify all tests still pass.

To verify changes:

1. Manually run the integration tests and update the code and tests as necessary for any Aerie changes.
2. If breaking changes are necessary to support the new Aerie version, remove any Aerie versions which will no longer be supported as described above.
3. Open a PR and verify all CI tests pass.

## Summary of Integration Tests

Expand Down
Binary file added tests/integration_tests/files/goals/goal2.jar
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
2 changes: 1 addition & 1 deletion tests/integration_tests/test_expansion.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def set_up_environment(request):

global command_dictionary_id
with open(COMMAND_DICTIONARY_PATH, 'r') as fid:
command_dictionary_id = client.create_dictionary(fid.read(), "COMMAND")
command_dictionary_id = client.create_dictionary(fid.read())

global parcel_id
parcel_id = client.create_parcel(Parcel("Integration Test", command_dictionary_id, None, None, []))
Expand Down
Loading

0 comments on commit aeae349

Please sign in to comment.