Skip to content

Commit ece85b9

Browse files
Merge branch 'feature/keycloak-middleware-clean' into enhancement/openremote-client-service-register
2 parents bd2bb47 + 3819b3d commit ece85b9

File tree

10 files changed

+273
-74
lines changed

10 files changed

+273
-74
lines changed

.github/workflows/ci.yml

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@ on:
66
pull_request:
77
branches: [ main ]
88

9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
12+
913
jobs:
1014

1115
quality-checks:
1216
runs-on: ubuntu-latest
1317
timeout-minutes: 5
1418

1519
steps:
16-
- name: Cancel previous runs
17-
uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1
18-
with:
19-
access_token: ${{ github.token }}
20-
2120
- name: Checkout
2221
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
2322

frontend/src/components/custom-duration-input.ts

Lines changed: 61 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,37 @@ export enum DurationInputType {
2626
}
2727

2828
// ISO 8601
29-
enum TimeDurationUnit {
30-
MINUTE = 'M',
31-
HOUR = 'H'
29+
export enum TimeDurationUnit {
30+
MINUTE = 'PT%M',
31+
HOUR = 'PT%H',
32+
DAY = 'P%D',
33+
WEEK = 'P%W',
34+
MONTH = 'P%M',
35+
YEAR = 'P%Y'
3236
}
3337

38+
// Display name for the ISO 8601 units
39+
const TimeDurationUnitDisplay: Record<TimeDurationUnit, string> = {
40+
[TimeDurationUnit.MINUTE]: 'Minutes',
41+
[TimeDurationUnit.HOUR]: 'Hours',
42+
[TimeDurationUnit.DAY]: 'Days',
43+
[TimeDurationUnit.WEEK]: 'Weeks',
44+
[TimeDurationUnit.MONTH]: 'Months',
45+
[TimeDurationUnit.YEAR]: 'Years'
46+
};
47+
3448
// Pandas Frequency
35-
enum PandasTimeUnit {
36-
MINUTE = 'min',
37-
HOUR = 'h'
49+
export enum PandasTimeUnit {
50+
MINUTE = '%min',
51+
HOUR = '%h'
3852
}
3953

54+
// Display name for the Pandas units
55+
const PandasTimeUnitDisplay: Record<PandasTimeUnit, string> = {
56+
[PandasTimeUnit.MINUTE]: 'Minutes',
57+
[PandasTimeUnit.HOUR]: 'Hours'
58+
};
59+
4060
// This is a input component that renders both a number input and a dropdown for the unit of the duration
4161
@customElement('custom-duration-input')
4262
export class CustomDurationInput extends LitElement {
@@ -79,12 +99,17 @@ export class CustomDurationInput extends LitElement {
7999
break;
80100
}
81101

102+
if (!this.unit || !this.number) {
103+
return;
104+
}
105+
106+
// Replace the % in the unit with the number and set the value
82107
switch (this.type) {
83108
case DurationInputType.ISO_8601:
84-
this.value = `PT${this.number}${this.unit}`;
109+
this.value = `${this.unit.replace('%', this.number.toString())}`;
85110
break;
86111
case DurationInputType.PANDAS_FREQ:
87-
this.value = `${this.number}${this.unit}`;
112+
this.value = `${this.unit.replace('%', this.number.toString())}`;
88113
}
89114

90115
// Fire event to notify parent component that the value has changed
@@ -105,20 +130,29 @@ export class CustomDurationInput extends LitElement {
105130
@property({ type: String })
106131
public value: string = '';
107132

133+
@property({ type: Array })
134+
public iso_units: TimeDurationUnit[] = [TimeDurationUnit.MINUTE, TimeDurationUnit.HOUR];
135+
136+
@property({ type: Array })
137+
public pandas_units: PandasTimeUnit[] = [PandasTimeUnit.MINUTE, PandasTimeUnit.HOUR];
138+
108139
protected number: number | null = null;
109140

110141
protected unit: TimeDurationUnit | PandasTimeUnit | null = null;
111142

112143
// Extract the number from the ISO 8601 Duration string
113144
getNumberFromDuration(duration: string): number | null {
114-
const match = /PT(\d+)([HM])/.exec(duration);
145+
const match = /P(?:T)?(\d+)([HMDWMOY]+)/.exec(duration);
115146
return match ? parseInt(match[1], 10) : null;
116147
}
117148

118149
// Extract the unit from the ISO 8601 Duration string
119150
getUnitFromDuration(duration: string): TimeDurationUnit | null {
120-
const match = /PT(\d+)([HM])/.exec(duration);
121-
return match ? (match[2] as TimeDurationUnit) : null;
151+
const match = /P(?:T)?(\d+)([HMDWMOY]+)/.exec(duration);
152+
153+
// replace the number in the full match with % to get the unit
154+
const unit = match?.[0]?.replace(match?.[1], '%');
155+
return unit as TimeDurationUnit;
122156
}
123157

124158
// Extract the number from the Pandas Frequency string
@@ -130,7 +164,21 @@ export class CustomDurationInput extends LitElement {
130164
// Extract the unit from the Pandas Frequency string
131165
getUnitFromPandasFrequency(freq: string): PandasTimeUnit | null {
132166
const match = /(\d+)(min|h)/.exec(freq);
133-
return match ? (match[2] as PandasTimeUnit) : null;
167+
168+
// replace the number in the full match with % to get the unit
169+
const unit = match?.[0]?.replace(match?.[1], '%');
170+
return unit as PandasTimeUnit;
171+
}
172+
173+
// Get available unit options based on the units property or defaults
174+
getUnitOptions(): [string, string][] {
175+
if (this.type === DurationInputType.ISO_8601) {
176+
return this.iso_units.map((unit) => [unit, TimeDurationUnitDisplay[unit]]);
177+
} else if (this.type === DurationInputType.PANDAS_FREQ) {
178+
return this.pandas_units.map((unit) => [unit, PandasTimeUnitDisplay[unit]]);
179+
}
180+
181+
return [];
134182
}
135183

136184
render() {
@@ -153,15 +201,7 @@ export class CustomDurationInput extends LitElement {
153201
label="Unit"
154202
.value=${this.unit}
155203
@or-mwc-input-changed=${this.onInput}
156-
.options="${this.type === DurationInputType.ISO_8601
157-
? [
158-
[TimeDurationUnit.MINUTE, 'Minutes'],
159-
[TimeDurationUnit.HOUR, 'Hours']
160-
]
161-
: [
162-
[PandasTimeUnit.MINUTE, 'Minutes'],
163-
[PandasTimeUnit.HOUR, 'Hours']
164-
]}"
204+
.options="${this.getUnitOptions()}"
165205
></or-mwc-input>
166206
`;
167207
}

frontend/src/pages/pages-config-editor.ts

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import { Router, RouterLocation } from '@vaadin/router';
2626
import { InputType, OrInputChangedEvent } from '@openremote/or-mwc-components/or-mwc-input';
2727
import { showSnackbar } from '@openremote/or-mwc-components/or-mwc-snackbar';
2828
import { getRootPath } from '../common/util';
29-
import { DurationInputType } from '../components/custom-duration-input';
29+
import { DurationInputType, TimeDurationUnit } from '../components/custom-duration-input';
3030
import { consume } from '@lit/context';
3131
import { realmContext } from './app-layout';
3232

@@ -137,7 +137,7 @@ export class PageConfigEditor extends LitElement {
137137
target: {
138138
asset_id: '',
139139
attribute_name: '',
140-
cutoff_timestamp: new Date().getTime()
140+
training_data_period: 'P6M'
141141
},
142142
regressors: null,
143143
forecast_interval: 'PT1H',
@@ -342,7 +342,7 @@ export class PageConfigEditor extends LitElement {
342342
this.formData.regressors.push({
343343
asset_id: '',
344344
attribute_name: '',
345-
cutoff_timestamp: new Date().getTime()
345+
training_data_period: 'P6M'
346346
});
347347
this.requestUpdate();
348348
}
@@ -402,14 +402,14 @@ export class PageConfigEditor extends LitElement {
402402
`
403403
)}
404404
405-
<or-mwc-input
406-
type="${InputType.DATETIME}"
407-
name="cutoff_timestamp"
408-
@or-mwc-input-changed="${(e: OrInputChangedEvent) => this.handleRegressorInput(e, index)}"
409-
label="Use datapoints since"
410-
.value="${regressor.cutoff_timestamp}"
411-
required
412-
></or-mwc-input>
405+
<custom-duration-input
406+
name="training_data_period"
407+
.type="${DurationInputType.ISO_8601}"
408+
@value-changed="${(e: OrInputChangedEvent) => this.handleRegressorInput(e, index)}"
409+
label="Training data period"
410+
.iso_units="${[TimeDurationUnit.DAY, TimeDurationUnit.WEEK, TimeDurationUnit.MONTH, TimeDurationUnit.YEAR]}"
411+
.value="${regressor.training_data_period}"
412+
></custom-duration-input>
413413
414414
<or-mwc-input
415415
style="max-width: 48px;"
@@ -607,14 +607,15 @@ export class PageConfigEditor extends LitElement {
607607
`
608608
)}
609609
610-
<or-mwc-input
611-
type="${InputType.DATETIME}"
612-
name="target.cutoff_timestamp"
613-
@or-mwc-input-changed="${this.handleTargetInput}"
614-
label="Use datapoints since"
615-
.value="${this.formData.target.cutoff_timestamp}"
616-
required
617-
></or-mwc-input>
610+
<!-- target.training_data_period -->
611+
<custom-duration-input
612+
name="target.training_data_period"
613+
.type="${DurationInputType.ISO_8601}"
614+
@value-changed="${this.handleTargetInput}"
615+
label="Training data period"
616+
.iso_units="${[TimeDurationUnit.DAY, TimeDurationUnit.WEEK, TimeDurationUnit.MONTH, TimeDurationUnit.YEAR]}"
617+
.value="${this.formData.target.training_data_period}"
618+
></custom-duration-input>
618619
</div>
619620
</div>
620621
</or-panel>

frontend/src/services/models.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -41,10 +41,10 @@ export interface TargetFeature {
4141
*/
4242
attribute_name: string;
4343
/**
44-
* Timestamp in milliseconds since epoch. All data after this timestamp will be used for forecasting.
45-
* Constraints: gt=0
44+
* ISO 8601 duration string, this duration period will be used for retrieving training data.
45+
* E.g. 'P6M' for data from the last 6 months.
4646
*/
47-
cutoff_timestamp: number;
47+
training_data_period: string;
4848
}
4949

5050
/**
@@ -62,10 +62,10 @@ export interface RegressorFeature {
6262
*/
6363
attribute_name: string;
6464
/**
65-
* Timestamp in milliseconds since epoch. All data after this timestamp will be used for forecasting.
66-
* Constraints: gt=0
65+
* ISO 8601 duration string, this duration period will be used for retrieving training data.
66+
* E.g. 'P6M' for data from the last 6 months.
6767
*/
68-
cutoff_timestamp: number;
68+
training_data_period: string;
6969
}
7070

7171
/**

src/service_ml_forecast/common/time_util.py

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import logging
1919
import time
20+
from datetime import timedelta
2021

2122
import pandas as pd
2223
from isodate import Duration, parse_duration
@@ -35,6 +36,12 @@ def get_timestamp_ms() -> int:
3536
millis = TimeUtil.sec_to_ms(timestamp)
3637
return millis
3738

39+
@staticmethod
40+
def get_timestamp_sec() -> int:
41+
"""Get the current timestamp in seconds."""
42+
timestamp = int(time.time())
43+
return timestamp
44+
3845
@staticmethod
3946
def parse_iso_duration(duration: str) -> int:
4047
"""Parse the given time duration String and returns the corresponding number of seconds.
@@ -46,11 +53,22 @@ def parse_iso_duration(duration: str) -> int:
4653
The number of seconds.
4754
"""
4855
# Parse the ISO 8601 duration string to a timedelta object
49-
duration_obj: Duration = parse_duration(duration, as_timedelta_if_possible=False)
56+
parsed: timedelta | Duration = parse_duration(duration)
57+
58+
seconds = 0
59+
60+
# Handle ISO 8601 strings that do not contain MONTH or YEAR (Timedelta)
61+
if isinstance(parsed, timedelta):
62+
seconds = int(parsed.total_seconds())
5063

51-
# Convert the duration object to seconds
52-
duration_seconds = int(duration_obj.total_seconds())
53-
return duration_seconds
64+
# Edge case, the library returns a Duration object for ISO 8601 strings that contain MONTH or YEAR
65+
# So we need to handle the conversion to seconds manually
66+
if isinstance(parsed, Duration):
67+
seconds = int(parsed.months * 30 * 24 * 60 * 60)
68+
seconds += int(parsed.years * 365 * 24 * 60 * 60) # Not taking into account leap years is OK
69+
seconds += int(parsed.total_seconds())
70+
71+
return seconds
5472

5573
@staticmethod
5674
def pd_future_timestamp(periods: int, frequency: str) -> int:
@@ -69,6 +87,31 @@ def pd_future_timestamp(periods: int, frequency: str) -> int:
6987

7088
return millis
7189

90+
@staticmethod
91+
def get_period_start_timestamp(period: str) -> int:
92+
"""Get the start timestamp for the period based on the provided ISO 8601 duration string.
93+
94+
Args:
95+
period: The ISO 8601 duration string.
96+
97+
Returns:
98+
The start timestamp in seconds.
99+
"""
100+
start_timestamp = TimeUtil.get_timestamp_sec() - TimeUtil.parse_iso_duration(period)
101+
return start_timestamp
102+
103+
@staticmethod
104+
def get_period_start_timestamp_ms(period: str) -> int:
105+
"""Get the start timestamp for the period based on the provided ISO 8601 duration string.
106+
107+
Args:
108+
period: The ISO 8601 duration string.
109+
110+
Returns:
111+
"""
112+
start_timestamp = TimeUtil.get_period_start_timestamp(period)
113+
return TimeUtil.sec_to_ms(start_timestamp)
114+
72115
@staticmethod
73116
def sec_to_ms(timestamp: int) -> int:
74117
"""Convert the epoch timestamp in seconds to milliseconds.
@@ -80,3 +123,42 @@ def sec_to_ms(timestamp: int) -> int:
80123
The epoch timestamp in milliseconds. (last 3 digits will be 000)
81124
"""
82125
return int(timestamp * 1000)
126+
127+
@staticmethod
128+
def months_between_timestamps(from_timestamp_ms: int, to_timestamp_ms: int) -> int:
129+
"""Calculate the number of months between two epoch millisecond timestamps.
130+
131+
Args:
132+
from_timestamp_ms: The start timestamp in milliseconds.
133+
to_timestamp_ms: The end timestamp in milliseconds.
134+
135+
Returns:
136+
The number of months between the timestamps.
137+
"""
138+
# Convert milliseconds to pandas timestamps
139+
from_dt = pd.to_datetime(from_timestamp_ms, unit="ms")
140+
to_dt = pd.to_datetime(to_timestamp_ms, unit="ms")
141+
142+
# Calculate the difference in months
143+
months_diff = (to_dt.year - from_dt.year) * 12 + (to_dt.month - from_dt.month)
144+
return months_diff
145+
146+
@staticmethod
147+
def add_months_to_timestamp(timestamp_ms: int, months: int) -> int:
148+
"""Add a specified number of months to a timestamp in milliseconds.
149+
150+
Args:
151+
timestamp_ms: The timestamp in milliseconds.
152+
months: The number of months to add.
153+
154+
Returns:
155+
The new timestamp in milliseconds.
156+
"""
157+
# Convert milliseconds to pandas timestamp
158+
dt = pd.to_datetime(timestamp_ms, unit="ms")
159+
160+
# Add months
161+
new_dt = dt + pd.DateOffset(months=months)
162+
163+
# Convert back to milliseconds
164+
return int(new_dt.timestamp() * 1000)

0 commit comments

Comments
 (0)