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

Engine configuration utility #1828

Merged
merged 14 commits into from
Feb 10, 2023
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Copy and pasting the git commit messages is __NOT__ enough.

## [Unreleased]
### Added
- Engine configuration utility that uses the following locations to allow configuration of the SMARTS engine. The engine consumes the configuration files from the following locations in the following priority: `./engine.ini`, `~/.smarts/engine.ini`, `$GLOBAL_USER/smarts/engine.ini`, and `${PYTHON_ENV}/lib/${PYTHON_VERSION}/site-packages/smarts/engine.ini`.
- Added map source uri as `map_source` inside of `hiway-v1` reset info to indicate what the current map is on reset.
### Changed
- Made changes in the docs to reflect `master` branch as the main development branch.
Expand Down
1 change: 0 additions & 1 deletion smarts/benchmark/entrypoints/benchmark_runner_v0.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import ray

from smarts.benchmark.driving_smarts import load_config
from smarts.benchmark.driving_smarts.v0 import DEFAULT_CONFIG
from smarts.core.utils.logging import suppress_output
from smarts.env.gymnasium.wrappers.metrics import Metrics, Score
from smarts.zoo import registry as agent_registry
Expand Down
41 changes: 41 additions & 0 deletions smarts/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@

import random
import uuid
from functools import lru_cache, partial
from pathlib import Path

import numpy as np

import smarts
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This caused an odd bug. It could not resolve the difference between smarts and smarts.core.smarts. I believe should change smarts.core.smarts to smarts.core.engine.

from smarts.core.configuration import Config

_current_seed = None


Expand All @@ -48,3 +53,39 @@ def gen_id():
"""Generates a unique but deterministic id if `smarts.core.seed` has set the core seed."""
id_ = uuid.UUID(int=random.getrandbits(128))
return str(id_)[:8]


@lru_cache(maxsize=1)
def config(default=Path(".") / "smarts_engine.ini") -> Config:
from smarts.core.utils.file import smarts_global_user_dir, smarts_local_user_dir

def get_file(config_file):
try:
if not config_file.is_file():
return ""
except PermissionError:
return ""

return str(config_file)

conf = partial(Config, environment_prefix="SMARTS")

file = get_file(default)
if file:
return conf(file)

try:
local_dir = smarts_local_user_dir()
except PermissionError:
file = ""
else:
file = get_file(Path(local_dir) / "engine.ini")
if file:
return conf(file)

global_dir = smarts_global_user_dir()
file = get_file(Path(global_dir) / "engine.ini")
if file:
return conf(file)

return conf(get_file(Path(smarts.__file__).parent.absolute() / "engine.ini"))
109 changes: 109 additions & 0 deletions smarts/core/configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# MIT License
#
# Copyright (C) 2023. Huawei Technologies Co., Ltd. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import configparser
import functools
import os
from pathlib import Path
from typing import Any, Callable, Optional, Union

_UNSET = object()


class Config:
"""A configuration utility that handles configuration from file and environment variable.

Args:
config_file (Union[str, Path]): The path to the configuration file.
environment_prefix (str, optional): The prefix given to the environment variables. Defaults to "".

Raises:
FileNotFoundError: If the configuration file cannot be found at the given file location.
"""

def __init__(
self, config_file: Union[str, Path], environment_prefix: str = ""
) -> None:
if isinstance(config_file, str):
config_file = Path(config_file)
if not config_file.is_file():
raise FileNotFoundError(f"Configuration file not found at {config_file}")

self._config = configparser.ConfigParser(
interpolation=configparser.ExtendedInterpolation()
)
self._config.read(str(config_file.absolute()))
self._environment_prefix = environment_prefix.upper()
self._format_string = self._environment_prefix + "_{}_{}"

@property
def environment_prefix(self):
"""The prefix that environment variables configuration is provided with."""
return self._environment_prefix

@functools.lru_cache(maxsize=100)
def get_setting(
self,
section: str,
option: str,
default: Any = _UNSET,
cast: Callable[[str], Any] = str,
) -> Optional[Any]:
"""Finds the given configuration checking the following in order: environment variable,
configuration file, and default.

Args:
section (str): The grouping that the configuration option is under.
option (str): The specific configuration option.
default (Any, optional): The default if the requested configuration option is not found. Defaults to _UNSET.
cast (Callable, optional): A function that takes a string and returns the desired type. Defaults to str.


Returns:
Optional[str]: The value of the configuration.

Raises:
KeyError: If the configuration option is not found in the configuration file and no default is provided.
configparser.NoSectionError: If the section in the configuration file is not found and no default is provided.
"""
env_variable = self._format_string.format(section.upper(), option.upper())
setting = os.getenv(env_variable)
if setting is not None:
return cast(setting)
try:
value = self._config[section][option]
except (configparser.NoSectionError, KeyError):
if default is _UNSET:
raise
return default
return cast(value)

def __call__(
self,
section: str,
option: str,
default: Any = _UNSET,
cast: Callable[[str], Any] = str,
) -> Optional[Any]:
return self.get_setting(section, option, default, cast)

def __repr__(self) -> str:
return f"Config(config_file={ {k: dict(v.items()) for k, v in self._config.items(raw=True)} }, environment_prefix={self._environment_prefix})"
14 changes: 7 additions & 7 deletions smarts/core/smarts.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from smarts.core.plan import Plan
from smarts.core.utils.logging import timeit

from . import models
from . import models, config
from .actor import ActorRole, ActorState
from .agent_interface import AgentInterface
from .agent_manager import AgentManager
Expand Down Expand Up @@ -71,8 +71,6 @@
level=logging.ERROR,
)

MAX_PYBULLET_FREQ = 240


class SMARTSNotSetupError(Exception):
"""Represents a case where SMARTS cannot operate because it is not set up yet."""
Expand Down Expand Up @@ -728,7 +726,7 @@ def _setup_bullet_client(self, client: bc.BulletClient):
client.configureDebugVisualizer(
pybullet.COV_ENABLE_GUI, 0 # pylint: disable=no-member
)

MAX_PYBULLET_FREQ = config()("physics", "max_pybullet_freq", cast=int)
# PyBullet defaults the timestep to 240Hz. Several parameters are tuned with
# this value in mind. For example the number of solver iterations and the error
# reduction parameters (erp) for contact, friction and non-contact joints.
Expand Down Expand Up @@ -857,10 +855,10 @@ def __del__(self):
" go away.",
e,
)
except (AttributeError, KeyboardInterrupt):
except (TypeError, KeyboardInterrupt):
return
raise exception

def _teardown_vehicles(self, vehicle_ids):
self._vehicle_index.teardown_vehicles_by_vehicle_ids(vehicle_ids)
self._clear_collisions(vehicle_ids)
Expand Down Expand Up @@ -1281,7 +1279,9 @@ def fixed_timestep_sec(self) -> float:
def fixed_timestep_sec(self, fixed_timestep_sec: float):
if not fixed_timestep_sec:
# This is the fastest we could possibly run given constraints from pybullet
self._rounder = rounder_for_dt(round(1 / MAX_PYBULLET_FREQ, 6))
self._rounder = rounder_for_dt(
round(1 / config().get_setting("physics", "max_pybullet_freq", cast=int), 6)
)
else:
self._rounder = rounder_for_dt(fixed_timestep_sec)
self._fixed_timestep_sec = fixed_timestep_sec
Expand Down
83 changes: 83 additions & 0 deletions smarts/core/tests/test_configuration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# MIT License
#
# Copyright (C) 2023. Huawei Technologies Co., Ltd. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import configparser
import functools
import os
import tempfile
from pathlib import Path

import pytest

from smarts.core.configuration import Config


@pytest.fixture
def config_path():
config = configparser.ConfigParser()
config["section_1"] = {"string_option": "value", "option_2": "value_2"}
config["section_2"] = {"bool_option": "True", "float_option": "3.14"}
with tempfile.TemporaryDirectory() as tmpdir:
file = Path(tmpdir) / "config.ini"
with file.open("w") as file_pointer:
config.write(fp=file_pointer)
yield str(file)


def test_get_setting_with_file(config_path):
config = Config(config_path)
assert config.get_setting("section_1", "string_option") == "value"
partition_string = functools.partial(
lambda source, sep: str.partition(source, sep), sep="_"
)
assert config.get_setting("section_1", "option_2", cast=partition_string) == (
"value",
"_",
"2",
)
assert config.get_setting("section_2", "bool_option", cast=bool) is True
assert config.get_setting("section_2", "float_option", cast=float) == 3.14
with pytest.raises(KeyError):
config.get_setting("nonexistent", "option")


def test_get_setting_with_environment_variables(config_path):
config = Config(config_path, "smarts")
assert config.get_setting("nonexistent", "option", default=None) is None

os.environ["SMARTS_NONEXISTENT_OPTION"] = "now_exists"
config = Config(config_path, "smarts")
assert config.get_setting("nonexistent", "option", default=None) == "now_exists"
del os.environ["SMARTS_NONEXISTENT_OPTION"]


def test_get_missing_section_and_missing_option():
from smarts.core import config as core_conf

core_conf.cache_clear()

config: Config = core_conf()

with pytest.raises(KeyError):
config.get_setting("core", "not_a_setting")

with pytest.raises(KeyError):
config.get_setting("not_a_section", "bar")
20 changes: 17 additions & 3 deletions smarts/core/utils/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,17 +142,31 @@ def pickle_hash_int(obj) -> int:
return c_int64(val).value


def smarts_log_dir() -> str:
"""Retrieves the smarts logging directory."""
def smarts_local_user_dir() -> str:
"""Retrieves the smarts logging directory.

Returns:
str: The smarts local user directory path.
"""
## Following should work for linux and macos
smarts_dir = os.path.join(os.path.expanduser("~"), ".smarts")
os.makedirs(smarts_dir, exist_ok=True)
return smarts_dir


def smarts_global_user_dir() -> str:
"""Retrieves the smarts global user directory.

Returns:
str: The smarts global user directory path.
"""
smarts_dir = os.path.join("/etc", "smarts")
return smarts_dir


def make_dir_in_smarts_log_dir(dir):
"""Return a new directory location in the smarts logging directory."""
return os.path.join(smarts_log_dir(), dir)
return os.path.join(smarts_local_user_dir(), dir)


@contextmanager
Expand Down
8 changes: 8 additions & 0 deletions smarts/engine.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[benchmark]
[core]
[controllers]
[physics]
max_pybullet_freq = 240
[providers]
[rendering]
[resources]
4 changes: 2 additions & 2 deletions smarts/env/utils/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@
import numpy as np

from smarts.core.sensors import Observation
from smarts.core.utils.file import smarts_log_dir
from smarts.core.utils.file import smarts_local_user_dir
from smarts.env.wrappers.utils.rendering import vis_sim_obs

Action = Any
Operation = Any

default_log_dir = smarts_log_dir()
default_log_dir = smarts_local_user_dir()


class AgentCameraRGBRender:
Expand Down