Skip to content

Commit

Permalink
Controller IO Heating Room (OpenEMS#2950)
Browse files Browse the repository at this point in the history
Controls electric floor and infrared heating in a room.

The Controller activates electric floor and infrared heating in a room in order to keep a configured LOW or HIGH temperature. The timings of LOW or HIGH are configured via a `schedule` configuration parameter.

Additionally:
- Improvements to JSCalendar (this is the first Controller that uses a JSCalendar schedule configuration... more to come!)
- Improvements to core components (JsonUtils, Thermometer, DummyThermometer, DummyInputOutput, etc.)
  • Loading branch information
sfeilmeier authored Jan 6, 2025
1 parent 2574b22 commit 3f12b22
Show file tree
Hide file tree
Showing 28 changed files with 1,849 additions and 105 deletions.
211 changes: 151 additions & 60 deletions io.openems.common/src/io/openems/common/jscalendar/JSCalendar.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.collect.ImmutableSortedSet.toImmutableSortedSet;
import static io.openems.common.jscalendar.JSCalendar.RecurrenceFrequency.WEEKLY;
import static io.openems.common.utils.JsonUtils.getAsEnum;
import static io.openems.common.utils.JsonUtils.getAsJsonArray;
import static io.openems.common.utils.JsonUtils.getAsJsonObject;
import static io.openems.common.utils.JsonUtils.getAsLocalDateTime;
import static io.openems.common.utils.JsonUtils.getAsOptionalJsonArray;
import static io.openems.common.utils.JsonUtils.getAsOptionalJsonObject;
import static io.openems.common.utils.JsonUtils.getAsOptionalString;
import static io.openems.common.utils.JsonUtils.getAsOptionalUUID;
import static io.openems.common.utils.JsonUtils.getAsOptionalZonedDateTime;
import static io.openems.common.utils.JsonUtils.getAsString;
import static io.openems.common.utils.JsonUtils.getAsUUID;
import static io.openems.common.utils.JsonUtils.getAsZonedDateTime;
import static io.openems.common.utils.JsonUtils.parseToJsonArray;
import static io.openems.common.utils.JsonUtils.stream;
import static io.openems.common.utils.JsonUtils.toJsonArray;
import static java.time.DayOfWeek.FRIDAY;
Expand All @@ -19,16 +20,22 @@
import static java.time.DayOfWeek.THURSDAY;
import static java.time.DayOfWeek.TUESDAY;
import static java.time.DayOfWeek.WEDNESDAY;
import static java.time.LocalDate.EPOCH;
import static java.time.format.DateTimeFormatter.ISO_INSTANT;
import static java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME;
import static java.time.temporal.ChronoField.NANO_OF_DAY;
import static java.time.temporal.TemporalAdjusters.nextOrSame;
import static java.util.Arrays.stream;
import static java.util.UUID.randomUUID;

import java.time.DayOfWeek;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.NoSuchElementException;
import java.util.UUID;
import java.util.function.Consumer;
Expand Down Expand Up @@ -58,9 +65,33 @@
public class JSCalendar<PAYLOAD> {
// CHECKSTYLE:ON

public static record Task<PAYLOAD>(UUID uid, ZonedDateTime updated, LocalDateTime start,
private static final String PROPERTY_PAYLOAD = "openems.io:payload";

public static record Task<PAYLOAD>(UUID uid, ZonedDateTime updated, LocalDateTime start, Duration duration,
ImmutableList<RecurrenceRule> recurrenceRules, PAYLOAD payload) {

/**
* Parse a List of {@link Task}s from a String representing a {@link JsonArray}
* - includes checks for null and empty.
*
* @param <PAYLOAD> the type of the Payload
* @param string the {@link JsonArray} string
* @param payloadParser a parser for a Payload
* @return the List of {@link Task}s
*/
public static <PAYLOAD> ImmutableList<Task<PAYLOAD>> fromStringOrEmpty(String string,
ThrowingFunction<JsonObject, PAYLOAD, OpenemsNamedException> payloadParser) {
if (string == null || string.isBlank()) {
return ImmutableList.of();
}
try {
return fromJson(parseToJsonArray(string), payloadParser);
} catch (OpenemsNamedException e) {
e.printStackTrace();
return ImmutableList.of();
}
}

/**
* Parse a List of {@link Task}s from a {@link JsonArray}.
*
Expand All @@ -76,7 +107,7 @@ public static <PAYLOAD> ImmutableList<Task<PAYLOAD>> fromJson(JsonArray json,
return stream(json) //
.map(j -> {
try {
return fromJson(JsonUtils.getAsJsonObject(j), payloadParser);
return fromJson(getAsJsonObject(j), payloadParser);
} catch (OpenemsNamedException e) {
e.printStackTrace();
throw new NoSuchElementException(e.getMessage());
Expand Down Expand Up @@ -121,55 +152,68 @@ public static <PAYLOAD> Task<PAYLOAD> fromJson(JsonObject json,
if (!type.equalsIgnoreCase("Task")) {
throw new OpenemsException("This is not a 'Task': " + type);
}
try {
var uid = getAsUUID(json, "uid");
var updated = getAsZonedDateTime(json, "updated");
var start = getAsLocalDateTime(json, "start");
var recurrenceRules = stream(getAsJsonArray(json, "recurrenceRules")) //
.map(r -> {
try {
return RecurrenceRule.fromJson(r);
} catch (OpenemsNamedException e) {
e.printStackTrace();
throw new NoSuchElementException(e.getMessage());
}
}) //
.collect(toImmutableList());
var payload = payloadParser.apply(getAsJsonObject(json, "payload"));
return new Task<PAYLOAD>(uid, updated, start, recurrenceRules, payload);

} catch (NoSuchElementException e) {
throw new OpenemsException("NoSuchElementException: " + e.getMessage());
}
var b = Task.<PAYLOAD>create() //
.setUid(getAsOptionalUUID(json, "uid").orElse(null)) //
.setUpdated(getAsOptionalZonedDateTime(json, "updated").orElse(null)) //
.setStart(getAsString(json, "start")) //
.setDuration(getAsOptionalString(json, "duration").orElse(null)); //
getAsOptionalJsonArray(json, "recurrenceRules") //
.ifPresent(j -> stream(j) //
.forEach(r -> b.addRecurrenceRule(r)));
var rawPayload = getAsOptionalJsonObject(json, PROPERTY_PAYLOAD);
b.setPayload(rawPayload.isPresent() //
? payloadParser.apply(rawPayload.get()) //
: null);
return b.build();
}

public static class Builder<PAYLOAD> {
private final UUID uid;
private final ZonedDateTime updated;

private UUID uid = null;
private ZonedDateTime updated = null;
private LocalDateTime start = null;
private Duration duration = null;
private ImmutableList.Builder<RecurrenceRule> recurrenceRules = ImmutableList.builder();
private PAYLOAD payload = null;

protected Builder() {
this(randomUUID(), ZonedDateTime.now());
}

protected Builder(UUID uid, ZonedDateTime updated) {
public Builder<PAYLOAD> setUid(UUID uid) {
this.uid = uid;
return this;
}

public Builder<PAYLOAD> setUpdated(ZonedDateTime updated) {
this.updated = updated;
return this;
}

public Builder<PAYLOAD> setStart(LocalDateTime start) {
this.start = start;
return this;
}

protected Builder<PAYLOAD> setStart(String start) {
this.setStart(LocalDateTime.parse(start));
public Builder<PAYLOAD> setStart(LocalTime start) {
return this.setStart(LocalDateTime.of(EPOCH, start));
}

protected Builder<PAYLOAD> setStart(String start) throws DateTimeParseException {
try {
return this.setStart(LocalDateTime.parse(start));
} catch (DateTimeParseException e) {
return this.setStart(LocalTime.parse(start));
}
}

public Builder<PAYLOAD> setDuration(Duration duration) {
this.duration = duration;
return this;
}

protected Builder<PAYLOAD> setDuration(String duration) {
return this.setDuration(duration == null ? null : Duration.parse(duration));
}

/**
* Adds a {@link RecurrenceRule}.
*
Expand All @@ -181,6 +225,21 @@ public Builder<PAYLOAD> addRecurrenceRule(RecurrenceRule recurrenceRule) {
return this;
}

/**
* Adds a {@link RecurrenceRule}.
*
* @param json the {@link RecurrenceRule} as {@link JsonObject}
* @return myself
*/
public Builder<PAYLOAD> addRecurrenceRule(JsonElement json) throws NoSuchElementException {
try {
return this.addRecurrenceRule(RecurrenceRule.fromJson(json));
} catch (OpenemsNamedException e) {
e.printStackTrace();
throw new NoSuchElementException(e.getMessage());
}
}

/**
* Adds a {@link RecurrenceRule}.
*
Expand All @@ -200,8 +259,8 @@ public Builder<PAYLOAD> setPayload(PAYLOAD payload) {
}

public Task<PAYLOAD> build() {
return new Task<PAYLOAD>(this.uid, this.updated, this.start, this.recurrenceRules.build(),
this.payload);
return new Task<PAYLOAD>(this.uid, this.updated, this.start, this.duration,
this.recurrenceRules.build(), this.payload);
}
}

Expand All @@ -223,33 +282,48 @@ public static <PAYLOAD> Builder<PAYLOAD> create() {
*/
public JsonObject toJson(Function<PAYLOAD, JsonObject> payloadConverter) {
var j = JsonUtils.buildJsonObject() //
.addProperty("@type", "Task") //
.addProperty("uid", this.uid.toString()) //
.addProperty("updated", this.updated.format(ISO_INSTANT));
.addProperty("@type", "Task");
if (this.uid != null) {
j.addProperty("uid", this.uid.toString());
}
if (this.updated != null) {
j.addProperty("updated", this.updated.format(ISO_INSTANT));
}
if (this.start != null) {
j.addProperty("start", this.start.format(ISO_LOCAL_DATE_TIME));
if (LocalDate.from(this.start).equals(EPOCH)) {
j.addProperty("start", this.start.format(DateTimeFormatter.ISO_LOCAL_TIME));
} else {
j.addProperty("start", this.start.format(ISO_LOCAL_DATE_TIME));
}
}
if (this.duration != null) {
j.addProperty("duration", this.duration.toString());
}
if (!this.recurrenceRules.isEmpty()) {
j.add("recurrenceRules", this.recurrenceRules.stream() //
.map(RecurrenceRule::toJson) //
.collect(toJsonArray()));
}
if (this.payload != null) {
j.add("payload", payloadConverter.apply(this.payload));
j.add(PROPERTY_PAYLOAD, payloadConverter.apply(this.payload));
}
return j.build();
}

/**
* Gets the next occurence of the {@link Task} at or after a date.
* Gets the next occurence of the {@link Task} (including duration) at or after
* a date.
*
* @param from the from timestamp
* @return a {@link ZonedDateTime}
*/
public ZonedDateTime getNextOccurence(ZonedDateTime from) {
var f = this.duration == null //
? from //
: from.minus(this.duration); // query active tasks
var start = this.start.atZone(from.getZone());
return this.recurrenceRules.stream() //
.map(rr -> rr.getNextOccurence(from.isBefore(start) ? start : from, start)) //
.map(rr -> rr.getNextOccurence(f.isBefore(start) ? start : f, start)) //
.min((o1, o2) -> o1.toInstant().compareTo(o2.toInstant())) //
.orElse(null);
}
Expand Down Expand Up @@ -282,18 +356,20 @@ public record RecurrenceRule(RecurrenceFrequency frequency, ImmutableSortedSet<D
*/
public static RecurrenceRule fromJson(JsonElement json) throws OpenemsNamedException, NoSuchElementException {
var frequency = getAsEnum(RecurrenceFrequency.class, json, "frequency");
var byDay = stream(getAsJsonArray(json, "byDay")) //
.map(j -> switch (JsonUtils.getAsOptionalString(j).orElseThrow()) {
case "mo" -> MONDAY;
case "tu" -> TUESDAY;
case "we" -> WEDNESDAY;
case "th" -> THURSDAY;
case "fr" -> FRIDAY;
case "sa" -> SATURDAY;
case "su" -> SUNDAY;
default -> throw new NoSuchElementException("");
}) //
.collect(toImmutableSortedSet(Ordering.natural()));
var byDay = getAsOptionalJsonArray(json, "byDay") //
.map(arr -> stream(arr) //
.map(j -> switch (getAsOptionalString(j).orElseThrow()) {
case "mo" -> MONDAY;
case "tu" -> TUESDAY;
case "we" -> WEDNESDAY;
case "th" -> THURSDAY;
case "fr" -> FRIDAY;
case "sa" -> SATURDAY;
case "su" -> SUNDAY;
default -> throw new NoSuchElementException("");
}) //
.collect(toImmutableSortedSet(Ordering.natural()))) //
.orElse(ImmutableSortedSet.of());
return new RecurrenceRule(frequency, byDay);
}

Expand Down Expand Up @@ -342,21 +418,36 @@ public static Builder create() {
* @return a {@link ZonedDateTime}
*/
public ZonedDateTime getNextOccurence(ZonedDateTime from, ZonedDateTime start) {
if (this.frequency == WEEKLY) {
final var startTime = start.toLocalTime();

return switch (this.frequency) {
case DAILY -> {
var resultDay = from.truncatedTo(ChronoUnit.DAYS);
if (from.toLocalTime().isAfter(startTime)) {
resultDay = from.plusDays(1);
}
yield resultDay.with(NANO_OF_DAY, startTime.toNanoOfDay());
}
case WEEKLY -> {
if (!this.byDay.isEmpty()) {
var startTime = start.toLocalTime();
var nextByDay = this.byDay.ceiling(from.toLocalTime().isAfter(startTime) //
? from.getDayOfWeek().plus(1) // next day
: from.getDayOfWeek()); // same day
if (nextByDay == null) {
nextByDay = this.byDay.first();
}
return from //
yield from //
.with(nextOrSame(nextByDay)) //
.with(NANO_OF_DAY, startTime.toNanoOfDay());
}
// TODO: If frequency is weekly and there is no byDay property, add a byDay
// property with the sole value being the day of the week of the initial
// date-time.
yield null; // not implemented
}
return null;
case MONTHLY -> null; // not implemented
case YEARLY -> null; // not implemented
};
}

/**
Expand Down
13 changes: 13 additions & 0 deletions io.openems.common/src/io/openems/common/utils/JsonUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -1759,6 +1759,19 @@ public static LocalDateTime getAsLocalDateTime(JsonElement jElement, String memb
return DateUtils.parseLocalDateTimeOrError(toString(toPrimitive(toSubElement(jElement, memberName))));
}

/**
* Takes a JSON in the form '2020-01-01T00:00:00' and converts it to a
* {@link LocalDateTime}.
*
* @param jElement the {@link JsonElement}
* @param memberName the name of the member of the JsonObject
* @return the {@link ZonedDateTime}
*/
public static Optional<LocalDateTime> getAsOptionalLocalDateTime(JsonElement jElement, String memberName) {
return JsonUtils.getAsOptionalString(jElement, memberName)//
.map(DateUtils::parseLocalDateTimeOrNull);
}

/**
* Takes a JSON in the form '2020-01-01T00:00:00Z' and converts it to a
* {@link ZonedDateTime}.
Expand Down
Loading

0 comments on commit 3f12b22

Please sign in to comment.