Skip to content

Commit c5b53dc

Browse files
Added ML Model Handling and Prophet model support (#13)
1 parent 4f96a9f commit c5b53dc

37 files changed

+2814
-288
lines changed

.gitignore

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -176,15 +176,13 @@ cython_debug/
176176

177177
# IDE - VSCode
178178
.vscode/*
179-
!.vscode/settings.json
180-
!.vscode/tasks.json
181-
!.vscode/launch.json
182-
!.vscode/extensions.json
179+
183180

184181
# IDE - PyCharm (additional entries)
185182
*.iws
186183
*.iml
187184
*.ipr
185+
.idea/*
188186

189187
# OS generated files
190188
.DS_Store
@@ -203,5 +201,7 @@ logs/
203201
*.h5
204202
*.model
205203
*.joblib
206-
.vscode/
207-
.idea/
204+
205+
# Application specific
206+
deployment/data/**
207+
**/**/.tmp

pyproject.toml

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,22 @@ name = "service-ml-forecast"
77
version = "0.1.0"
88
description = "Machine Learning Forecast Service"
99
readme = "README.md"
10-
requires-python = ">=3.10"
10+
requires-python = ">=3.13"
1111
dependencies = [
1212
"fastapi>=0.115.11",
1313
"uvicorn>=0.34.0",
1414
"pyyaml>=6.0.2",
1515
"tomli>=2.2.1",
1616
"httpx>=0.28.1",
17+
"apscheduler>=3.11.0",
1718
"pydantic>=2.10.6",
1819
"pydantic-settings>=2.8.1",
20+
"prophet>=1.1.6",
21+
"plotly>=6.0.1",
22+
"pandas>=2.2.3",
23+
"numpy>=2.2.4",
24+
"pyzmq>=26.3.0",
25+
"isodate>=0.7.2",
1926
]
2027

2128
[dependency-groups]
@@ -26,6 +33,7 @@ dev = [
2633
"ruff>=0.11.0",
2734
"build>=1.2.2",
2835
"respx>=0.22.0",
36+
"pandas-stubs>=2.2.3",
2937
]
3038

3139
[project.urls]
@@ -38,9 +46,10 @@ package-dir = {"service_ml_forecast" = "src/service_ml_forecast", "scripts" = "s
3846

3947
[tool.ruff]
4048
line-length = 120
41-
target-version = "py310"
49+
target-version = "py313"
4250
fix = true
4351
unsafe-fixes = false
52+
indent-width = 4
4453

4554
[tool.ruff.lint]
4655
select = [
@@ -58,14 +67,19 @@ select = [
5867
"PL", # pylint
5968
"RUF", # ruff-specific rules
6069
]
70+
ignore = ["PLR0913"]
6171
fixable = ["ALL"]
6272

6373
[tool.ruff.format]
6474
line-ending = "auto"
6575
quote-style = "double"
76+
docstring-code-format = true
77+
docstring-code-line-length = "dynamic"
78+
indent-style = "space"
79+
6680

6781
[tool.mypy]
68-
python_version = "3.10"
82+
python_version = "3.13"
6983
warn_return_any = true
7084
warn_unused_configs = true
7185
disallow_untyped_defs = true
@@ -74,10 +88,11 @@ check_untyped_defs = true
7488
disallow_untyped_decorators = true
7589
no_implicit_optional = true
7690
warn_redundant_casts = true
77-
warn_unused_ignores = true
91+
warn_unused_ignores = false
7892
warn_no_return = true
7993
warn_unreachable = true
8094
strict_optional = true
95+
strict = true
8196
ignore_missing_imports = true
8297

8398
[tool.ruff.lint.isort]
@@ -87,6 +102,7 @@ combine-as-imports = true
87102
[tool.pytest.ini_options]
88103
testpaths = ["tests"]
89104
python_files = "test_*.py"
105+
filterwarnings = ["ignore:.*"]
90106

91107
[project.scripts]
92108
service_ml_forecast = "service_ml_forecast.main:app"

scripts/tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,12 @@ def format() -> None:
4545

4646
def test() -> None:
4747
"""Run tests."""
48-
step(f"uv run pytest {TEST_DIR} -v --cache-clear", "tests")
48+
step(f"uv run pytest {TEST_DIR} -vv --cache-clear", "tests")
4949

5050

5151
def test_coverage() -> None:
5252
"""Run tests with coverage."""
53-
step(f"uv run pytest {TEST_DIR} -v --cache-clear --cov {SRC_DIR}", "tests with coverage")
53+
step(f"uv run pytest {TEST_DIR} -vv --cache-clear --cov {SRC_DIR}", "tests with coverage")
5454

5555

5656
def build() -> None:

src/service_ml_forecast/__init__.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,16 @@
2121
from pydantic import BaseModel
2222

2323

24+
def find_project_root(start_path: Path = Path(__file__)) -> Path:
25+
"""Find the project root by looking for marker files."""
26+
current = start_path.parent
27+
while current != current.parent:
28+
if any((current / marker).exists() for marker in ["pyproject.toml", ".env"]):
29+
return current
30+
current = current.parent
31+
raise RuntimeError("Could not find project root")
32+
33+
2434
class AppInfo(BaseModel):
2535
"""Application information."""
2636

@@ -29,17 +39,14 @@ class AppInfo(BaseModel):
2939
version: str
3040

3141

32-
def get_app_info() -> AppInfo | None:
42+
def get_app_info() -> AppInfo:
3343
"""Read app info (name, description, version) from pyproject.toml file."""
34-
try:
35-
pyproject_path = Path(__file__).parents[2] / "pyproject.toml"
44+
pyproject_path = find_project_root() / "pyproject.toml"
3645

37-
with open(pyproject_path, "rb") as f:
38-
pyproject_data = tomli.load(f)
46+
with open(pyproject_path, "rb") as f:
47+
pyproject_data = tomli.load(f)
3948

40-
return AppInfo(**pyproject_data["project"])
41-
except (FileNotFoundError, KeyError, tomli.TOMLDecodeError):
42-
return None
49+
return AppInfo(**pyproject_data["project"])
4350

4451

4552
__app_info__ = get_app_info()

src/service_ml_forecast/clients/openremote/models.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ class Asset(BaseModel):
3838

3939
def get_attribute_value(self, attribute_name: str) -> Any | None:
4040
"""Helper method to get an attribute value."""
41+
4142
if attribute_name in self.attributes:
4243
return self.attributes[attribute_name].value
4344
return None
@@ -58,18 +59,16 @@ class AssetDatapoint(BaseModel):
5859
Args:
5960
x: The timestamp of the data point.
6061
y: The value of the data point.
61-
6262
"""
6363

6464
x: int
6565
y: Any
6666

6767

68-
class DatapointsRequestBody(BaseModel):
69-
"""Request body for retrieving either historical or predicted data points of an asset attribute."""
68+
class AssetDatapointQuery(BaseModel):
69+
"""Request body for querying asset datapoints."""
7070

7171
fromTimestamp: int
7272
toTimestamp: int
7373
fromTime: str = ""
7474
toTime: str = ""
75-
type: str = "string"

src/service_ml_forecast/clients/openremote/openremote_client.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
Asset,
2828
AssetDatapoint,
2929
AssetDatapointPeriod,
30-
DatapointsRequestBody,
30+
AssetDatapointQuery,
3131
)
3232

3333

@@ -56,6 +56,8 @@ class OpenRemoteClient:
5656
service_user: The service user for the OpenRemote API.
5757
service_user_secret: The service user secret for the OpenRemote API.
5858
59+
Raises:
60+
Exception: If the authentication fails
5961
"""
6062

6163
logger = logging.getLogger(__name__)
@@ -121,6 +123,7 @@ def health_check(self) -> bool:
121123
Returns:
122124
bool: True if healthy, False if not.
123125
"""
126+
124127
url = f"{self.openremote_url}/api/master/health"
125128

126129
request = self.__build_request("GET", url)
@@ -142,6 +145,7 @@ def retrieve_assets(self, realm: str) -> list[Asset] | None:
142145
Returns:
143146
list[Asset] | None: List of assets or None
144147
"""
148+
145149
url = f"{self.openremote_url}/api/master/asset/query"
146150
asset_query = {"recursive": True, "realm": {"name": realm}}
147151

@@ -167,6 +171,7 @@ def retrieve_asset_datapoint_period(self, asset_id: str, attribute_name: str) ->
167171
Returns:
168172
AssetDatapointPeriod | None: The datapoints timestamp period of the asset attribute
169173
"""
174+
170175
query = f"?assetId={asset_id}&attributeName={attribute_name}"
171176
url = f"{self.openremote_url}/api/master/asset/datapoint/periods{query}"
172177

@@ -176,30 +181,34 @@ def retrieve_asset_datapoint_period(self, asset_id: str, attribute_name: str) ->
176181
try:
177182
response = client.send(request)
178183
response.raise_for_status()
179-
datapoint_period = AssetDatapointPeriod(**response.json())
180-
return datapoint_period
184+
return AssetDatapointPeriod(**response.json())
181185
except (httpx.HTTPStatusError, httpx.ConnectError) as e:
182186
self.logger.error(f"Error retrieving asset datapoint period: {e}")
183187
return None
184188

185189
def retrieve_historical_datapoints(
186-
self, asset_id: str, attribute_name: str, from_timestamp: int, to_timestamp: int
190+
self,
191+
asset_id: str,
192+
attribute_name: str,
193+
from_timestamp: int,
194+
to_timestamp: int,
187195
) -> list[AssetDatapoint] | None:
188196
"""Retrieve the historical data points of a given asset attribute.
189197
190198
Args:
191199
asset_id: The ID of the asset.
192200
attribute_name: The name of the attribute.
193-
from_timestamp: The start timestamp.
194-
to_timestamp: The end timestamp.
201+
from_timestamp: Epoch timestamp in milliseconds.
202+
to_timestamp: Epoch timestamp in milliseconds.
195203
196204
Returns:
197205
list[AssetDatapoint] | None: List of historical data points or None
198206
"""
207+
199208
params = f"{asset_id}/{attribute_name}"
200209
url = f"{self.openremote_url}/api/master/asset/datapoint/{params}"
201210

202-
request_body = DatapointsRequestBody(
211+
request_body = AssetDatapointQuery(
203212
fromTimestamp=from_timestamp,
204213
toTimestamp=to_timestamp,
205214
)
@@ -227,6 +236,7 @@ def write_predicted_datapoints(self, asset_id: str, attribute_name: str, datapoi
227236
Returns:
228237
bool: True if successful
229238
"""
239+
230240
params = f"{asset_id}/{attribute_name}"
231241
url = f"{self.openremote_url}/api/master/asset/predicted/{params}"
232242

@@ -244,23 +254,28 @@ def write_predicted_datapoints(self, asset_id: str, attribute_name: str, datapoi
244254
return False
245255

246256
def retrieve_predicted_datapoints(
247-
self, asset_id: str, attribute_name: str, from_timestamp: int, to_timestamp: int
257+
self,
258+
asset_id: str,
259+
attribute_name: str,
260+
from_timestamp: int,
261+
to_timestamp: int,
248262
) -> list[AssetDatapoint] | None:
249263
"""Retrieve the predicted data points of a given asset attribute.
250264
251265
Args:
252266
asset_id: The ID of the asset.
253267
attribute_name: The name of the attribute.
254-
from_timestamp: The start timestamp.
255-
to_timestamp: The end timestamp.
268+
from_timestamp: Epoch timestamp in milliseconds.
269+
to_timestamp: Epoch timestamp in milliseconds.
256270
257271
Returns:
258272
list[AssetDatapoint] | None: List of predicted data points or None
259273
"""
274+
260275
params = f"{asset_id}/{attribute_name}"
261276
url = f"{self.openremote_url}/api/master/asset/predicted/{params}"
262277

263-
request_body = DatapointsRequestBody(
278+
request_body = AssetDatapointQuery(
264279
fromTimestamp=from_timestamp,
265280
toTimestamp=to_timestamp,
266281
)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright 2025, OpenRemote Inc.
2+
#
3+
# This program is free software: you can redistribute it and/or modify
4+
# it under the terms of the GNU Affero General Public License as
5+
# published by the Free Software Foundation, either version 3 of the
6+
# License, or (at your option) any later version.
7+
#
8+
# This program is distributed in the hope that it will be useful,
9+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11+
# GNU Affero General Public License for more details.
12+
#
13+
# You should have received a copy of the GNU Affero General Public License
14+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
15+
#
16+
# SPDX-License-Identifier: AGPL-3.0-or-later
17+
18+
"""Common exceptions."""
19+
20+
21+
class ResourceNotFoundError(Exception):
22+
"""Exception raised when a resource is not found."""
23+
24+
pass
25+
26+
27+
class ResourceAlreadyExistsError(Exception):
28+
"""Exception raised when a resource already exists."""
29+
30+
pass
31+
32+
33+
class ExternalApiError(Exception):
34+
"""Exception raised when an external API call fails."""
35+
36+
pass

0 commit comments

Comments
 (0)