Skip to content

odys.domain.entities.portfolio

Collection of energy assets (generators, storages, loads). Validates unique asset names and provides typed accessors by asset class.

See AssetPortfolio in the User Guide.

Asset portfolio management for energy systems.

This module provides the AssetPortfolio class, the collection of assets the user owns and operates.

AssetPortfolio

A collection of assets the user owns and operates.

Assets are indexed by name, which must be unique. Markets are not assets and are rejected; pass them to EnergySystem instead. Chargers and electric vehicles must be both present or both absent: a vehicle can only charge through a charger, and a charger without vehicles serves no purpose.

Source code in src/odys/domain/entities/portfolio.py
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
class AssetPortfolio:
    """A collection of assets the user owns and operates.

    Assets are indexed by name, which must be unique. Markets are not assets
    and are rejected; pass them to `EnergySystem` instead. Chargers and
    electric vehicles must be both present or both absent: a vehicle can only
    charge through a charger, and a charger without vehicles serves no purpose.
    """

    def __init__(
        self,
        assets: Iterable[Asset] | None = None,
    ) -> None:
        """Initialize an asset portfolio.

        Args:
            assets: Iterable of assets to add to the portfolio.

        Raises:
            OdysValidationError: If an entity is not an asset, if names are not unique,
                or if the portfolio has chargers without electric vehicles or the reverse.
        """
        asset_list = tuple(assets or ())
        self._validate_only_assets(asset_list)
        self._validate_unique_asset_names(asset_list)
        self._validate_chargers_and_evs_together(asset_list)
        self._assets: dict[str, Asset] = {asset.name: asset for asset in asset_list}

    def get_asset(self, name: str) -> Asset:
        """Retrieve an asset from the portfolio by name.

        Args:
            name: The name of the asset to retrieve.

        Returns:
            The asset with the specified name.

        Raises:
            OdysValidationError: If no asset with the specified name exists.

        """
        if name not in self._assets:
            msg = f"Asset with name '{name}' does not exist."
            raise OdysValidationError(msg)
        return self._assets[name]

    def assets_of(self, asset_type: type[AssetT]) -> tuple[AssetT, ...]:
        """Return all assets of the given type, in insertion order.

        Args:
            asset_type: The asset class to filter by (subclasses included).

        Returns:
            A tuple with every asset that is an instance of `asset_type`.
        """
        return tuple(asset for asset in self._assets.values() if isinstance(asset, asset_type))

    @property
    def assets(self) -> MappingProxyType[str, Asset]:
        """Get a read-only view of all assets in the portfolio.

        Returns:
            A mapping proxy containing all assets indexed by name.

        """
        return MappingProxyType(self._assets)

    @staticmethod
    def _validate_only_assets(entities: tuple[object, ...]) -> None:
        """Reject non-assets from callers that bypass type checking (hence `object`, not `Asset`)."""
        for entity in entities:
            if not isinstance(entity, Asset):
                name = getattr(entity, "name", repr(entity))
                msg = (
                    f"AssetPortfolio only accepts assets; got '{name}' of type {type(entity).__name__}. "
                    "Pass markets to EnergySystem(markets=...) instead."
                )
                raise OdysValidationError(msg)

    @staticmethod
    def _validate_unique_asset_names(assets: tuple[Asset, ...]) -> None:
        names_count = Counter(asset.name for asset in assets)
        duplicates = [name for name, count in names_count.items() if count > 1]
        if duplicates:
            msg = f"Duplicate asset names in input: {duplicates}"
            raise OdysValidationError(msg)

    @staticmethod
    def _validate_chargers_and_evs_together(assets: tuple[Asset, ...]) -> None:
        number_of_chargers = sum(isinstance(asset, Charger) for asset in assets)
        number_of_evs = sum(isinstance(asset, ElectricVehicle) for asset in assets)
        if (number_of_chargers > 0) != (number_of_evs > 0):
            msg = (
                "Portfolio must contain both chargers and electric vehicles, or neither: "
                f"found {number_of_chargers} charger(s) and {number_of_evs} electric vehicle(s)"
            )
            raise OdysValidationError(msg)

assets property

Get a read-only view of all assets in the portfolio.

Returns:

Type Description
MappingProxyType[str, Asset]

A mapping proxy containing all assets indexed by name.

__init__(assets=None)

Initialize an asset portfolio.

Parameters:

Name Type Description Default
assets Iterable[Asset] | None

Iterable of assets to add to the portfolio.

None

Raises:

Type Description
OdysValidationError

If an entity is not an asset, if names are not unique, or if the portfolio has chargers without electric vehicles or the reverse.

Source code in src/odys/domain/entities/portfolio.py
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
def __init__(
    self,
    assets: Iterable[Asset] | None = None,
) -> None:
    """Initialize an asset portfolio.

    Args:
        assets: Iterable of assets to add to the portfolio.

    Raises:
        OdysValidationError: If an entity is not an asset, if names are not unique,
            or if the portfolio has chargers without electric vehicles or the reverse.
    """
    asset_list = tuple(assets or ())
    self._validate_only_assets(asset_list)
    self._validate_unique_asset_names(asset_list)
    self._validate_chargers_and_evs_together(asset_list)
    self._assets: dict[str, Asset] = {asset.name: asset for asset in asset_list}

assets_of(asset_type)

Return all assets of the given type, in insertion order.

Parameters:

Name Type Description Default
asset_type type[AssetT]

The asset class to filter by (subclasses included).

required

Returns:

Type Description
tuple[AssetT, ...]

A tuple with every asset that is an instance of asset_type.

Source code in src/odys/domain/entities/portfolio.py
66
67
68
69
70
71
72
73
74
75
def assets_of(self, asset_type: type[AssetT]) -> tuple[AssetT, ...]:
    """Return all assets of the given type, in insertion order.

    Args:
        asset_type: The asset class to filter by (subclasses included).

    Returns:
        A tuple with every asset that is an instance of `asset_type`.
    """
    return tuple(asset for asset in self._assets.values() if isinstance(asset, asset_type))

get_asset(name)

Retrieve an asset from the portfolio by name.

Parameters:

Name Type Description Default
name str

The name of the asset to retrieve.

required

Returns:

Type Description
Asset

The asset with the specified name.

Raises:

Type Description
OdysValidationError

If no asset with the specified name exists.

Source code in src/odys/domain/entities/portfolio.py
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def get_asset(self, name: str) -> Asset:
    """Retrieve an asset from the portfolio by name.

    Args:
        name: The name of the asset to retrieve.

    Returns:
        The asset with the specified name.

    Raises:
        OdysValidationError: If no asset with the specified name exists.

    """
    if name not in self._assets:
        msg = f"Asset with name '{name}' does not exist."
        raise OdysValidationError(msg)
    return self._assets[name]