-
Notifications
You must be signed in to change notification settings - Fork 6
Improve Lifetime validation and error handling
#249
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
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
262bbe2
Make `ElectricalComponent.model` required
llucax 7d3d651
Add `BaseLifetime` abstract base class
llucax 10571ed
Make `Lifetime` inherit from `BaseLifetime`
llucax 1be62d0
Add `InvalidLifetime`
llucax 858cb31
Add `InvalidLifetimeError`
llucax 6c43306
Update `lifetime_from_proto` to return `InvalidLifetime`
llucax 5c729ba
Add safe accessors for operational lifetimes
llucax dc6a541
Stop reporting lifetime issues via strings
llucax b888962
Split `Lifetime` tests into per-type files
llucax a985835
Move `Lifetime` from `types` to `microgrid`
llucax 496edc6
Add `__str__` to `Lifetime` and `InvalidLifetime`
llucax 4751d9f
Update release notes
llucax File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| # License: MIT | ||
| # Copyright © 2025 Frequenz Energy-as-a-Service GmbH | ||
|
|
||
| """Lifetime of an asset.""" | ||
|
|
||
| from dataclasses import dataclass | ||
| from datetime import datetime, timezone | ||
| from typing import Any, Self | ||
|
|
||
| from .._exception import InvalidAttributeError | ||
|
|
||
|
|
||
| @dataclass(frozen=True, kw_only=True) | ||
| class BaseLifetime: | ||
| """A base class for well-formed and malformed operational lifetimes. | ||
|
|
||
| This class cannot be instantiated directly. Use [`Lifetime`][..Lifetime] | ||
| for a valid period or [`InvalidLifetime`][..InvalidLifetime] to preserve | ||
| malformed wire data. | ||
| """ | ||
|
|
||
| start_time: datetime | None = None | ||
| """The moment when the asset became operationally active. | ||
|
|
||
| If `None`, the asset is considered to be active in any past moment previous to the | ||
| [`end_time`][..end_time]. | ||
| """ | ||
|
|
||
| end_time: datetime | None = None | ||
| """The moment when the asset's operational activity ceased. | ||
|
|
||
| If `None`, the asset is considered to be active with no plans to be deactivated. | ||
| """ | ||
|
|
||
| # pylint: disable-next=unused-argument | ||
| def __new__(cls, *args: Any, **kwargs: Any) -> Self: | ||
| """Prevent instantiation of this class.""" | ||
| if cls is BaseLifetime: | ||
| raise TypeError(f"Cannot instantiate {cls.__name__} directly") | ||
| return super().__new__(cls) | ||
|
|
||
|
|
||
| @dataclass(frozen=True, kw_only=True) | ||
| class Lifetime(BaseLifetime): | ||
| """An active operational period of an asset. | ||
|
|
||
| When both [`start_time`][.start_time] and [`end_time`][.end_time] are | ||
| `None`, the lifetime is unbounded and the asset is considered operational | ||
| at every timestamp. | ||
|
|
||
| Warning: | ||
| The [`end_time`][.end_time] timestamp indicates that the asset has been | ||
| permanently removed from service. | ||
|
|
||
| Note: | ||
| Raises a `ValueError` if [`start_time`][.start_time] is later than the | ||
| [`end_time`][.end_time] timestamp. Use | ||
| [`InvalidLifetime`][..InvalidLifetime] to represent malformed lifetime | ||
| data received from the wire. | ||
| """ | ||
|
|
||
| def __post_init__(self) -> None: | ||
| """Validate this lifetime.""" | ||
| if ( | ||
| self.start_time is not None | ||
| and self.end_time is not None | ||
| and self.start_time > self.end_time | ||
| ): | ||
| raise ValueError( | ||
| f"Start ({self.start_time}) must be before or equal to end " | ||
| f"({self.end_time})" | ||
| ) | ||
|
|
||
| def __str__(self) -> str: | ||
| """Return a compact string representation of this lifetime.""" | ||
| start_str = ( | ||
| self.start_time.isoformat() if self.start_time is not None else "-inf" | ||
| ) | ||
| end_str = self.end_time.isoformat() if self.end_time is not None else "+inf" | ||
| return f"({start_str},{end_str}]" | ||
|
|
||
| def is_operational_at(self, timestamp: datetime) -> bool: | ||
| """Check whether this lifetime is active at a specific timestamp.""" | ||
| # Handle start time - it's not active if start_time is in the future | ||
| if self.start_time is not None and self.start_time > timestamp: | ||
| return False | ||
| # Handle end time - active up to and including end_time | ||
| if self.end_time is not None: | ||
| return self.end_time >= timestamp | ||
| # self.end_time is None, and either self.start_time is None or | ||
| # self.start_time <= timestamp, so it is active at this timestamp | ||
| return True | ||
|
|
||
| def is_operational_now(self) -> bool: | ||
| """Whether this lifetime is currently active.""" | ||
| return self.is_operational_at(datetime.now(timezone.utc)) | ||
|
|
||
|
|
||
| @dataclass(frozen=True, kw_only=True) | ||
| class InvalidLifetime(BaseLifetime): | ||
| """An operational lifetime with malformed data received from the wire. | ||
|
|
||
| This class preserves lifetime data that fails the invariants required for | ||
| a well-formed [`Lifetime`][..Lifetime], allowing callers to inspect the raw | ||
| timestamps without accidentally using them for operational checks. Use a | ||
| semantic accessor, such as `ElectricalComponent.get_operational_lifetime()`, | ||
| to receive a clear [`InvalidLifetimeError`][..InvalidLifetimeError]. | ||
| """ | ||
|
|
||
| def __str__(self) -> str: | ||
| """Return a compact string representation of this invalid lifetime.""" | ||
| start_str = ( | ||
| self.start_time.isoformat() if self.start_time is not None else "-inf" | ||
| ) | ||
| end_str = self.end_time.isoformat() if self.end_time is not None else "+inf" | ||
| return f"<invalid:({start_str},{end_str}]>" | ||
|
|
||
|
|
||
| class InvalidLifetimeError(InvalidAttributeError): | ||
| """Raised when a semantic accessor sees an invalid lifetime. | ||
|
|
||
| The offending [`InvalidLifetime`][..InvalidLifetime] is available as the | ||
| [`lifetime`][.lifetime] attribute so callers can inspect the raw wire data. | ||
|
|
||
| This is also a [`ValueError`][] for convenience. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| instance: object, | ||
| attr_name: str, | ||
| lifetime: InvalidLifetime, | ||
| message: str | None = None, | ||
| ) -> None: | ||
| """Initialize this error. | ||
|
|
||
| Args: | ||
| instance: The instance that was being accessed when this error was raised. | ||
| attr_name: The name of the attribute that was being accessed. | ||
| lifetime: The invalid lifetime instance. | ||
| message: A custom error message. If `None`, a default message mentioning | ||
| the invalid lifetime is used. | ||
| """ | ||
| self.lifetime: InvalidLifetime = lifetime | ||
| """The invalid lifetime that caused this error.""" | ||
|
|
||
| super().__init__( | ||
| instance, | ||
| attr_name, | ||
| ( | ||
| message | ||
| if message is not None | ||
| else f"invalid lifetime {lifetime!r} for attribute {attr_name!r} in {instance}" | ||
| ), | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.