diff --git a/monticore-grammar/src/main/grammars/de/monticore/temporal/TimeTriggerConditions.mc4 b/monticore-grammar/src/main/grammars/de/monticore/temporal/TimeTriggerConditions.mc4
new file mode 100644
index 0000000000..59259e4d70
--- /dev/null
+++ b/monticore-grammar/src/main/grammars/de/monticore/temporal/TimeTriggerConditions.mc4
@@ -0,0 +1,53 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal;
+
+/**
+ * This grammar defines time trigger conditions (such as 'after', 'on', 'every', 'cron')
+ * using SI unit literals and ISO 8601 temporals.
+ */
+component grammar TimeTriggerConditions extends de.monticore.siunit.SIUnitLiterals,
+ de.monticore.temporal.ISOTemporals {
+
+ interface TimeTriggerCondition;
+
+ AfterCondition implements TimeTriggerCondition =
+ "after" (duration:SIUnitLiteral | period:ISOPeriod);
+
+ OnCondition implements TimeTriggerCondition =
+ "on" (isoDateTime:ISODateTime | dateOnly:CalendarDate | timeOnly:ISOTime);
+
+ EveryTimeCondition implements TimeTriggerCondition =
+ "every" period:SIUnitLiteral (times:NatLiteral "times")?
+ | "from" start:ISODateTime "every" period:SIUnitLiteral
+ (times:NatLiteral "times")?;
+
+ CronCondition implements TimeTriggerCondition =
+ "cron" "[" expression:CronExpression "]";
+
+ CronExpression =
+ minute:CronField
+ hour:CronField
+ dayOfMonth:CronField
+ month:CronField
+ dayOfWeek:CronField;
+
+ CronField =
+ elements:(CronElement || ",")+;
+
+ interface CronElement;
+
+ CronValue implements CronElement =
+ value:CronAtom ("/" step:NatLiteral)?;
+
+ CronRange implements CronElement =
+ start:CronAtom "-" end:CronAtom ("/" step:NatLiteral)?;
+
+ interface CronAtom;
+
+ CronWildcard implements CronAtom = "*";
+
+ CronNumber implements CronAtom = value:NatLiteral;
+
+ CronName implements CronAtom = value:Name;
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java
index 59e8ce0353..02b0bf1f62 100644
--- a/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/_ast/ASTISOTime.java
@@ -119,8 +119,12 @@ public long getLong(TemporalField field) {
case NANO_OF_SECOND:
return getNanoOfSecond();
case OFFSET_SECONDS:
- // Time shift is in hours but result has to be in seconds
- return 3600L * getTimeShiftHour() + 60L * getTimeShiftMinute();
+ long offsetSeconds = 3600L * Math.abs(getTimeShiftHour()) + 60L * getTimeShiftMinute();
+ if (isPresentTimeShiftSource() && getTimeShiftSource().isPresentSign()
+ && getTimeShiftSource().getSign() == ASTSign.MINUS) {
+ return -offsetSeconds;
+ }
+ return offsetSeconds;
}
}
throw new UnsupportedTemporalTypeException(field.toString());
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/util/ISOTemporalsConversions.java b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/util/ISOTemporalsConversions.java
new file mode 100644
index 0000000000..908bfdd1bd
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/isotemporals/util/ISOTemporalsConversions.java
@@ -0,0 +1,79 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.isotemporals.util;
+
+import de.monticore.temporal.isotemporals._ast.ASTCalendarDate;
+import de.monticore.temporal.isotemporals._ast.ASTISODateTime;
+import de.monticore.temporal.isotemporals._ast.ASTISOTime;
+
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.OffsetTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Objects;
+
+/**
+ * Java-time conversion helpers for {@link de.monticore.temporal.isotemporals._ast}
+ * AST nodes.
+ *
+ *
All methods require an explicit UTC offset in the source value.
+ * Named time-zone identifiers are not supported; only {@code Z} and numeric
+ * offsets such as {@code +02:00} or {@code -05:30} are accepted.
+ */
+public final class ISOTemporalsConversions {
+
+ private ISOTemporalsConversions() {
+ }
+
+ /**
+ * Converts an ISO 8601 date-time node to an {@link OffsetDateTime}.
+ *
+ * Hour and minute components of the UTC offset are preserved
+ * separately, so offsets such as {@code +05:45} and {@code -00:30}
+ * are represented correctly.
+ *
+ * @param value the parsed ISO date-time node (must not be {@code null})
+ * @return the corresponding {@link OffsetDateTime}
+ * @throws java.time.format.DateTimeParseException if the value cannot be parsed
+ */
+ public static OffsetDateTime toOffsetDateTime(ASTISODateTime value) {
+ Objects.requireNonNull(value, "value");
+ return OffsetDateTime.parse(value.toRawString(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
+ }
+
+ /**
+ * Converts an ISO 8601 calendar-date node to a {@link LocalDate}.
+ *
+ * Date-only values carry no time-zone information. Callers that need
+ * an instant must supply a time zone and time-of-day themselves.
+ *
+ * @param value the parsed ISO calendar-date node (must not be {@code null})
+ * @return the corresponding {@link LocalDate}
+ * @throws java.time.format.DateTimeParseException if the value cannot be parsed
+ */
+ public static LocalDate toLocalDate(ASTCalendarDate value) {
+ Objects.requireNonNull(value, "value");
+ return LocalDate.parse(value.toRawString(), DateTimeFormatter.ISO_LOCAL_DATE);
+ }
+
+ /**
+ * Converts an ISO 8601 time node to an {@link OffsetTime}.
+ *
+ * An explicit UTC offset is required. The optional leading {@code T}
+ * designator is stripped before parsing so that both {@code T12:00:00Z}
+ * and {@code 12:00:00Z} are accepted.
+ *
+ * @param value the parsed ISO time node (must not be {@code null})
+ * @return the corresponding {@link OffsetTime}
+ * @throws java.time.format.DateTimeParseException if the value cannot be parsed
+ * or lacks a UTC offset
+ */
+ public static OffsetTime toOffsetTime(ASTISOTime value) {
+ Objects.requireNonNull(value, "value");
+ String source = value.toRawString();
+ if (source.startsWith("T")) {
+ source = source.substring(1);
+ }
+ return OffsetTime.parse(source, DateTimeFormatter.ISO_OFFSET_TIME);
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidDuration.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidDuration.java
new file mode 100644
index 0000000000..9d48da693f
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidDuration.java
@@ -0,0 +1,59 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions._cocos;
+
+import de.monticore.siunit.siunitliterals._ast.ASTSIUnitLiteral;
+import de.monticore.temporal.timetriggerconditions._ast.ASTAfterCondition;
+import de.monticore.types.check.SymTypeExpression;
+import de.monticore.types.check.SymTypeExpressionFactory;
+import de.monticore.types.check.SymTypeOfSIUnit;
+import de.monticore.types3.TypeCheck3;
+import de.monticore.types3.util.SIUnitTypeRelations;
+import de.se_rwth.commons.logging.Log;
+
+import java.util.List;
+
+/**
+ * Checks that an after timer uses a time unit.
+ *
+ * Requires an initialized {@link TypeCheck3}.
+ */
+public class AfterConditionHasValidDuration implements
+ TimeTriggerConditionsASTAfterConditionCoCo {
+
+ /** Error code for a unit that is not of dimension time. */
+ public static final String UNSUPPORTED_UNIT = "0xF0002";
+
+ @Override
+ public void check(ASTAfterCondition node) {
+ if (!node.isPresentDuration()) {
+ return;
+ }
+ ASTSIUnitLiteral duration = node.getDuration();
+ SymTypeExpression type = TypeCheck3.typeOf(duration);
+ if (!type.isObscureType() && !isTimeDuration(type)) {
+ Log.error(UNSUPPORTED_UNIT + " After timer unit must be a unit of time, e.g., ms, s, min, h,"
+ + " or d, but got " + type.printFullName() + ".", node.get_SourcePositionStart());
+ }
+ }
+
+ /**
+ * @return whether the type is an SI unit (with or without a numeric)
+ * of dimension time, e.g., {@code [ms]}, {@code [h]}, or {@code [s]}
+ */
+ protected boolean isTimeDuration(SymTypeExpression type) {
+ SymTypeOfSIUnit siUnit;
+ if (type.isNumericWithSIUnitType()) {
+ siUnit = type.asNumericWithSIUnitType().getSIUnitType();
+ }
+ else if (type.isSIUnitType()) {
+ siUnit = type.asSIUnitType();
+ }
+ else {
+ return false;
+ }
+ SymTypeOfSIUnit second = SymTypeExpressionFactory.createSIUnit(
+ List.of(SymTypeExpressionFactory.createSIUnitBasic("s")), List.of());
+ return SIUnitTypeRelations.isOfSameDimension(siUnit, second);
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidPeriod.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidPeriod.java
new file mode 100644
index 0000000000..78a7381502
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/AfterConditionHasValidPeriod.java
@@ -0,0 +1,56 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions._cocos;
+
+import de.monticore.temporal.isotemporals._ast.ASTFullPeriod;
+import de.monticore.temporal.isotemporals._ast.ASTISOPeriod;
+import de.monticore.temporal.isotemporals._ast.ASTWeekPeriod;
+import de.monticore.temporal.timetriggerconditions._ast.ASTAfterCondition;
+import de.se_rwth.commons.logging.Log;
+
+/**
+ * Checks that an ISO 8601 after timer represents a positive period.
+ */
+public class AfterConditionHasValidPeriod implements
+ TimeTriggerConditionsASTAfterConditionCoCo {
+
+ /** Error code for an ISO period that does not denote a positive duration. */
+ public static final String NON_POSITIVE_PERIOD = "0xF0008";
+
+ /** Creates the stateless ISO-period CoCo. */
+ public AfterConditionHasValidPeriod() {
+ }
+
+ @Override
+ public void check(ASTAfterCondition node) {
+ if (!node.isPresentPeriod()) {
+ return;
+ }
+ if (!isPositive(node.getPeriod())) {
+ Log.error(NON_POSITIVE_PERIOD + " ISO after timer period must be greater than 0.", node
+ .get_SourcePositionStart());
+ }
+ }
+
+ /** Returns whether at least one period component is positive. */
+ protected boolean isPositive(ASTISOPeriod period) {
+ if (period instanceof ASTWeekPeriod weekPeriod) {
+ return weekPeriod.getWeeks() > 0;
+ }
+ if (period instanceof ASTFullPeriod fullPeriod) {
+ return (fullPeriod.isPresentYears() && fullPeriod.getYears() > 0) || (fullPeriod
+ .isPresentMonths() && fullPeriod.getMonths() > 0) || (fullPeriod.isPresentDays()
+ && fullPeriod.getDays() > 0) || (fullPeriod.isPresentHours() && fullPeriod.getHours()
+ > 0) || (fullPeriod.isPresentMinutes() && fullPeriod.getMinutes() > 0)
+ || (fullPeriod.isPresentSeconds() && fullPeriod.getSeconds() > 0) || hasPositiveFraction(
+ fullPeriod);
+ }
+ return false;
+ }
+
+ /** Returns whether a parsed fractional component contains a non-zero digit. */
+ protected boolean hasPositiveFraction(ASTFullPeriod period) {
+ return period.isPresentDecimalDigits() && period.getDecimalDigits().chars().anyMatch(
+ character -> character != '0');
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/CronExpressionIsValid.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/CronExpressionIsValid.java
new file mode 100644
index 0000000000..e12c9d247b
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/CronExpressionIsValid.java
@@ -0,0 +1,237 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions._cocos;
+
+import de.se_rwth.commons.logging.Log;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronAtom;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronCondition;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronElement;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronExpression;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronField;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronName;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronNumber;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronRange;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronValue;
+import de.monticore.temporal.timetriggerconditions._ast.ASTCronWildcard;
+
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.OptionalInt;
+import java.util.Set;
+
+/**
+ * Checks that a Cron condition uses the supported Crontab.guru five-field syntax.
+ */
+public class CronExpressionIsValid implements TimeTriggerConditionsASTCronConditionCoCo {
+
+ /** Error code for invalid field values, names, or ranges. */
+ public static final String INVALID_FIELD_VALUE = "0xF0012";
+ /** Error code for a zero or unrepresentable step. */
+ public static final String INVALID_STEP = "0xF0013";
+ /** Warning code for month/day combinations without a Gregorian occurrence. */
+ public static final String IMPOSSIBLE_DATE = "0xF0014";
+
+ /** Maximum possible Gregorian day for each month, including leap-day February. */
+ private static final int[] MAXIMUM_DAY_BY_MONTH = { 0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30,
+ 31 };
+
+ /** Case-insensitive Cron month names mapped to their numeric values. */
+ protected static final Map MONTH_NAMES = Map.ofEntries(Map.entry("JAN", 1), Map
+ .entry("FEB", 2), Map.entry("MAR", 3), Map.entry("APR", 4), Map.entry("MAY", 5), Map.entry(
+ "JUN", 6), Map.entry("JUL", 7), Map.entry("AUG", 8), Map.entry("SEP", 9), Map.entry("OCT",
+ 10), Map.entry("NOV", 11), Map.entry("DEC", 12));
+
+ /** Case-insensitive Cron weekday names mapped to Sunday-based numeric values. */
+ protected static final Map WEEKDAY_NAMES = Map.of("SUN", 0, "MON", 1, "TUE", 2,
+ "WED", 3, "THU", 4, "FRI", 5, "SAT", 6);
+
+ /** Creates the stateless Cron validation CoCo. */
+ public CronExpressionIsValid() {
+ }
+
+ @Override
+ public void check(ASTCronCondition node) {
+ ASTCronExpression expression = node.getExpression();
+
+ boolean valid = checkField(expression.getMinute(), 0, 59, "minute", Map.of());
+ valid &= checkField(expression.getHour(), 0, 23, "hour", Map.of());
+ valid &= checkField(expression.getDayOfMonth(), 1, 31, "day-of-month", Map.of());
+ valid &= checkField(expression.getMonth(), 1, 12, "month", MONTH_NAMES);
+ valid &= checkField(expression.getDayOfWeek(), 0, 7, "day-of-week", WEEKDAY_NAMES);
+
+ if (valid) {
+ warnIfCalendarImpossible(expression);
+ }
+ }
+
+ /** Validates every value, range, name, and step in one Cron field. */
+ protected boolean checkField(ASTCronField field, int minimum, int maximum, String fieldName,
+ Map names) {
+ for (ASTCronElement element : field.getElementsList()) {
+ if (!hasValidValue(element, minimum, maximum, names)) {
+ Log.error(INVALID_FIELD_VALUE + " Cron " + fieldName + " must use values from " + minimum
+ + " to " + maximum + ", valid names, and ascending ranges.", element
+ .get_SourcePositionStart());
+ return false;
+ }
+ if (!hasValidStep(element)) {
+ Log.error(INVALID_STEP + " Cron " + fieldName
+ + " step must be a positive representable integer.", element.get_SourcePositionStart());
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * Reports schedules whose selected months cannot contain any selected day-of-month.
+ * February is evaluated with 29 days because the dialect has no year field and leap
+ * years therefore provide valid occurrences. A restricted day-of-week can rescue a
+ * restricted day-of-month through the POSIX OR rule; in that case no warning is issued.
+ */
+ protected void warnIfCalendarImpossible(ASTCronExpression expression) {
+ if (!startsWithWildcard(expression.getDayOfMonth()) && !startsWithWildcard(expression
+ .getDayOfWeek())) {
+ return;
+ }
+
+ Set days = expand(expression.getDayOfMonth(), 1, 31, Map.of());
+ Set months = expand(expression.getMonth(), 1, 12, MONTH_NAMES);
+ boolean possible = months.stream().anyMatch(month -> days.stream().anyMatch(day -> day
+ <= MAXIMUM_DAY_BY_MONTH[month]));
+
+ if (!possible) {
+ Log.warn(IMPOSSIBLE_DATE
+ + " Cron day-of-month and month fields cannot match a Gregorian date.", expression
+ .getDayOfMonth().get_SourcePositionStart());
+ }
+ }
+
+ /**
+ * Returns whether a field begins with a wildcard value. Vixie Cron decides between the
+ * intersection and the union of the two day fields by looking at the first element only,
+ * so {@code *,MON} forms an intersection while {@code MON,*} forms a union. This test is
+ * textual on purpose and must not be widened to the whole field.
+ */
+ protected boolean startsWithWildcard(ASTCronField field) {
+ if (field.getElementsList().isEmpty()) {
+ return false;
+ }
+ ASTCronElement first = field.getElements(0);
+ return first instanceof ASTCronValue value && value.getValue() instanceof ASTCronWildcard;
+ }
+
+ /** Expands a previously validated field to its selected numeric set. */
+ protected Set expand(ASTCronField field, int minimum, int maximum,
+ Map names) {
+ Set values = new LinkedHashSet<>();
+ for (ASTCronElement element : field.getElementsList()) {
+ int start;
+ int end;
+ int step = 1;
+ if (element instanceof ASTCronValue value) {
+ if (value.getValue() instanceof ASTCronWildcard) {
+ start = minimum;
+ end = maximum;
+ }
+ else {
+ start = resolve(value.getValue(), names).orElseThrow();
+ end = value.isPresentStep() ? maximum : start;
+ }
+ if (value.isPresentStep()) {
+ step = value.getStep().getValue();
+ }
+ }
+ else if (element instanceof ASTCronRange range) {
+ start = resolve(range.getStart(), names).orElseThrow();
+ end = resolve(range.getEnd(), names).orElseThrow();
+ if (range.isPresentStep()) {
+ step = range.getStep().getValue();
+ }
+ }
+ else {
+ throw new IllegalArgumentException("Unsupported Cron element AST type: " + element
+ .getClass().getName());
+ }
+
+ for (int value = start; value <= end; value += step) {
+ values.add(value);
+ if (value > end - step) {
+ break;
+ }
+ }
+ }
+ return values;
+ }
+
+ /** Validates the atom or inclusive range represented by one Cron element. */
+ protected boolean hasValidValue(ASTCronElement element, int minimum, int maximum,
+ Map names) {
+ if (element instanceof ASTCronValue) {
+ ASTCronAtom atom = ((ASTCronValue) element).getValue();
+ if (atom instanceof ASTCronWildcard) {
+ return true;
+ }
+ OptionalInt value = resolve(atom, names);
+ return value.isPresent() && isInRange(value.getAsInt(), minimum, maximum);
+ }
+ if (element instanceof ASTCronRange) {
+ ASTCronRange range = (ASTCronRange) element;
+ if (range.getStart() instanceof ASTCronWildcard || range
+ .getEnd() instanceof ASTCronWildcard) {
+ return false;
+ }
+ OptionalInt start = resolve(range.getStart(), names);
+ OptionalInt end = resolve(range.getEnd(), names);
+ return start.isPresent() && end.isPresent() && isInRange(start.getAsInt(), minimum, maximum)
+ && isInRange(end.getAsInt(), minimum, maximum) && start.getAsInt() <= end.getAsInt();
+ }
+ return false;
+ }
+
+ /** Validates an optional step attached to a value or range. */
+ protected boolean hasValidStep(ASTCronElement element) {
+ if (element instanceof ASTCronValue && ((ASTCronValue) element).isPresentStep()) {
+ return isPositive(((ASTCronValue) element).getStep());
+ }
+ if (element instanceof ASTCronRange && ((ASTCronRange) element).isPresentStep()) {
+ return isPositive(((ASTCronRange) element).getStep());
+ }
+ return true;
+ }
+
+ /** Resolves a numeric or named atom to its field-specific integer value. */
+ protected OptionalInt resolve(ASTCronAtom atom, Map names) {
+ if (atom instanceof ASTCronNumber) {
+ return getValue(((ASTCronNumber) atom).getValue());
+ }
+ if (atom instanceof ASTCronName) {
+ Integer value = names.get(((ASTCronName) atom).getValue().toUpperCase(Locale.ROOT));
+ return value == null ? OptionalInt.empty() : OptionalInt.of(value);
+ }
+ return OptionalInt.empty();
+ }
+
+ /** Returns whether a step literal is representable and strictly positive. */
+ protected boolean isPositive(de.monticore.literals.mccommonliterals._ast.ASTNatLiteral literal) {
+ OptionalInt value = getValue(literal);
+ return value.isPresent() && value.getAsInt() > 0;
+ }
+
+ /** Safely reads a natural-number literal without propagating overflow errors. */
+ protected OptionalInt getValue(
+ de.monticore.literals.mccommonliterals._ast.ASTNatLiteral literal) {
+ try {
+ return OptionalInt.of(literal.getValue());
+ }
+ catch (NumberFormatException exception) {
+ return OptionalInt.empty();
+ }
+ }
+
+ /** Returns whether a value lies inside an inclusive field range. */
+ protected boolean isInRange(int value, int minimum, int maximum) {
+ return value >= minimum && value <= maximum;
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/EveryTimeConditionHasValidRepetitions.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/EveryTimeConditionHasValidRepetitions.java
new file mode 100644
index 0000000000..09944703e0
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/EveryTimeConditionHasValidRepetitions.java
@@ -0,0 +1,89 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions._cocos;
+
+import de.monticore.literals.mccommonliterals._ast.ASTNatLiteral;
+import de.monticore.siunit.siunitliterals._ast.ASTSIUnitLiteral;
+import de.monticore.temporal.isotemporals._ast.ASTISODateTime;
+import de.monticore.temporal.isotemporals.util.ISOTemporalsConversions;
+import de.monticore.temporal.timetriggerconditions._ast.ASTEveryTimeCondition;
+import de.monticore.types.check.SymTypeExpression;
+import de.monticore.types.check.SymTypeExpressionFactory;
+import de.monticore.types.check.SymTypeOfSIUnit;
+import de.monticore.types3.TypeCheck3;
+import de.monticore.types3.util.SIUnitTypeRelations;
+import de.se_rwth.commons.logging.Log;
+
+import java.util.List;
+
+/**
+ * Ensures that every-time intervals and repetition counts are valid.
+ *
+ * Requires an initialized {@link TypeCheck3}.
+ */
+public class EveryTimeConditionHasValidRepetitions implements
+ TimeTriggerConditionsASTEveryTimeConditionCoCo {
+
+ public static final String INVALID_INTERVAL = "0xF0009";
+ public static final String INVALID_START = "0xF000A";
+ public static final String NON_POSITIVE_REPETITIONS = "0xF000B";
+
+ @Override
+ public void check(ASTEveryTimeCondition node) {
+ validateInterval(node.getPeriod(), node);
+ if (node.isPresentStart()) {
+ validateStart(node.getStart(), node);
+ }
+ if (node.isPresentTimes()) {
+ validateRepetitions(node.getTimes(), node);
+ }
+ }
+
+ protected void validateInterval(ASTSIUnitLiteral duration, ASTEveryTimeCondition node) {
+ SymTypeExpression type = TypeCheck3.typeOf(duration);
+ if (!type.isObscureType() && !isTimeDuration(type)) {
+ report(node, INVALID_INTERVAL, "Every timer interval unit must be a unit of time,"
+ + " e.g., ms, s, min, h, or d, but got " + type.printFullName());
+ }
+ }
+
+ protected void validateStart(ASTISODateTime start, ASTEveryTimeCondition node) {
+ try {
+ ISOTemporalsConversions.toOffsetDateTime(start);
+ }
+ catch (java.time.format.DateTimeParseException exception) {
+ report(node, INVALID_START,
+ "Every timer start must be a valid ISO date-time with an explicit UTC offset");
+ }
+ }
+
+ protected void validateRepetitions(ASTNatLiteral literal, ASTEveryTimeCondition node) {
+ if (literal.getDigits().chars().allMatch(c -> c == '0')) {
+ report(node, NON_POSITIVE_REPETITIONS, "Every timer repetition count must be greater than 0");
+ }
+ }
+
+ /**
+ * @return whether the type is an SI unit (with or without a numeric)
+ * of dimension time, e.g., {@code [ms]}, {@code [h]}, or {@code [s]}
+ */
+ protected boolean isTimeDuration(SymTypeExpression type) {
+ SymTypeOfSIUnit siUnit;
+ if (type.isNumericWithSIUnitType()) {
+ siUnit = type.asNumericWithSIUnitType().getSIUnitType();
+ }
+ else if (type.isSIUnitType()) {
+ siUnit = type.asSIUnitType();
+ }
+ else {
+ return false;
+ }
+ SymTypeOfSIUnit second = SymTypeExpressionFactory.createSIUnit(
+ List.of(SymTypeExpressionFactory.createSIUnitBasic("s")), List.of());
+ return SIUnitTypeRelations.isOfSameDimension(siUnit, second);
+ }
+
+ protected void report(ASTEveryTimeCondition node, String code, String requirement) {
+ Log.error(code + " " + requirement + ".", node.get_SourcePositionStart());
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/OnConditionHasValidValue.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/OnConditionHasValidValue.java
new file mode 100644
index 0000000000..4e14ec8a88
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/OnConditionHasValidValue.java
@@ -0,0 +1,81 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions._cocos;
+
+import de.monticore.temporal.isotemporals._ast.ASTCalendarDate;
+import de.monticore.temporal.isotemporals._ast.ASTISODateTime;
+import de.monticore.temporal.isotemporals._ast.ASTISOTime;
+import de.se_rwth.commons.logging.Log;
+
+import java.time.format.DateTimeParseException;
+
+import de.monticore.temporal.timetriggerconditions._ast.ASTOnCondition;
+import de.monticore.temporal.isotemporals.util.ISOTemporalsConversions;
+
+/**
+ * Checks that an on condition denotes a valid date-time, date, or time.
+ * Date-times and times require an explicit UTC offset; dates denote midnight UTC.
+ */
+public class OnConditionHasValidValue implements TimeTriggerConditionsASTOnConditionCoCo {
+
+ public static final String INVALID_DATE_TIME = "0xF0004";
+ public static final String INVALID_DATE = "0xF0006";
+ public static final String INVALID_TIME = "0xF0007";
+
+ @Override
+ public void check(ASTOnCondition node) {
+ if (node.isPresentIsoDateTime()) {
+ checkDateTime(node.getIsoDateTime());
+ return;
+ }
+ if (node.isPresentDateOnly()) {
+ checkDate(node.getDateOnly());
+ return;
+ }
+ checkTime(node.getTimeOnly());
+ }
+
+ private void checkDateTime(ASTISODateTime node) {
+ String dateTime = node.toRawString();
+
+ try {
+ ISOTemporalsConversions.toOffsetDateTime(node);
+ }
+ catch (DateTimeParseException exception) {
+ Log.error(INVALID_DATE_TIME + " ISO 8601 date-time '" + dateTime
+ + "' is not a valid UTC instant: " + exception.getMessage() + ".", node
+ .get_SourcePositionStart());
+ }
+ }
+
+ private void checkDate(ASTCalendarDate node) {
+ String date = node.toRawString();
+
+ try {
+ ISOTemporalsConversions.toLocalDate(node);
+ }
+ catch (DateTimeParseException exception) {
+ Log.error(INVALID_DATE + " ISO 8601 date '" + date + "' is not a valid date: " + exception
+ .getMessage() + ".", node.get_SourcePositionStart());
+ }
+ }
+
+ private void checkTime(ASTISOTime node) {
+ String time = node.toRawString();
+
+ if (!node.isPresentTimeShift()) {
+ Log.error(INVALID_TIME + " ISO 8601 time '" + time
+ + "' requires an explicit UTC offset ('Z' or a numeric offset).", node
+ .get_SourcePositionStart());
+ return;
+ }
+
+ try {
+ ISOTemporalsConversions.toOffsetTime(node);
+ }
+ catch (DateTimeParseException exception) {
+ Log.error(INVALID_TIME + " ISO 8601 time '" + time + "' is not a valid UTC time: " + exception
+ .getMessage() + ".", node.get_SourcePositionStart());
+ }
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/TimeTriggerConditionsCoCos.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/TimeTriggerConditionsCoCos.java
new file mode 100644
index 0000000000..a931feb17b
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/_cocos/TimeTriggerConditionsCoCos.java
@@ -0,0 +1,25 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions._cocos;
+
+/** Creates fully configured CoCo checkers for the TimeTriggerConditions grammar. */
+public final class TimeTriggerConditionsCoCos {
+
+ private TimeTriggerConditionsCoCos() {
+ }
+
+ /**
+ * Creates a new checker containing every TimeTrigger condition validation.
+ *
+ * @return a newly configured checker
+ */
+ public static TimeTriggerConditionsCoCoChecker createChecker() {
+ TimeTriggerConditionsCoCoChecker checker = new TimeTriggerConditionsCoCoChecker();
+ checker.addCoCo(new AfterConditionHasValidDuration());
+ checker.addCoCo(new AfterConditionHasValidPeriod());
+ checker.addCoCo(new OnConditionHasValidValue());
+ checker.addCoCo(new EveryTimeConditionHasValidRepetitions());
+ checker.addCoCo(new CronExpressionIsValid());
+ return checker;
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/semantics/AfterPeriodSemantics.java b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/semantics/AfterPeriodSemantics.java
new file mode 100644
index 0000000000..1ab4954d96
--- /dev/null
+++ b/monticore-grammar/src/main/java/de/monticore/temporal/timetriggerconditions/semantics/AfterPeriodSemantics.java
@@ -0,0 +1,59 @@
+/* (c) https://github.com/MontiCore/monticore */
+package de.monticore.temporal.timetriggerconditions.semantics;
+
+import de.monticore.temporal.isotemporals._ast.ASTFullPeriod;
+import de.monticore.temporal.isotemporals._ast.ASTISOPeriod;
+import de.monticore.temporal.isotemporals._ast.ASTWeekPeriod;
+
+import java.util.Objects;
+
+/** Static semantic classification for ISO periods used by {@code after}. */
+public final class AfterPeriodSemantics {
+
+ /** The two duration classes exposed to TimeTrigger consumers. */
+ public enum DurationKind {
+ /** A duration consisting only of weeks, days, hours, minutes, and seconds. */
+ FIXED,
+ /** A duration containing a year or month component. */
+ CALENDAR_RELATIVE
+ }
+
+ private AfterPeriodSemantics() {
+ }
+
+ /**
+ * Classifies a structured ISO period without choosing a runtime calendar policy.
+ *
+ * @param period the parsed ISO period
+ * @return {@link DurationKind#CALENDAR_RELATIVE} if the period contains a year or
+ * month component, otherwise {@link DurationKind#FIXED}
+ * @throws NullPointerException if {@code period} is {@code null}
+ * @throws IllegalArgumentException if an unknown period implementation is supplied
+ */
+ public static DurationKind classify(ASTISOPeriod period) {
+ Objects.requireNonNull(period, "period");
+ if (period instanceof ASTWeekPeriod) {
+ return DurationKind.FIXED;
+ }
+ if (period instanceof ASTFullPeriod fullPeriod) {
+ if ((fullPeriod.isPresentYears() && fullPeriod.getYears() > 0) || (fullPeriod
+ .isPresentMonths() && fullPeriod.getMonths() > 0)) {
+ return DurationKind.CALENDAR_RELATIVE;
+ }
+ return DurationKind.FIXED;
+ }
+ throw new IllegalArgumentException("Unsupported ISO period AST type: " + period.getClass()
+ .getName());
+ }
+
+ /**
+ * Determines whether a period requires a calendar, timezone, and end-of-month policy.
+ *
+ * @param period the parsed ISO period
+ * @return whether the period is calendar-relative
+ */
+ public static boolean isCalendarRelative(ASTISOPeriod period) {
+ return classify(period) == DurationKind.CALENDAR_RELATIVE;
+ }
+
+}
diff --git a/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java b/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java
index df504d6bc6..2e0769d20b 100644
--- a/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java
+++ b/monticore-grammar/src/main/java/de/monticore/types3/util/SIUnitTypeRelations.java
@@ -190,6 +190,19 @@ protected boolean _isOfDimensionOne(SymTypeOfSIUnit siUnit) {
return siUnitNormalized.getNumerator().isEmpty() && siUnitNormalized.getDenominator().isEmpty();
}
+ /**
+ * whether both SIUnits are of the same dimension,
+ * i.e., they only differ in prefixes and factors,
+ * e.g.: ms and h, km and m, J and eV
+ */
+ public static boolean isOfSameDimension(SymTypeOfSIUnit siUnit1, SymTypeOfSIUnit siUnit2) {
+ return getDelegate()._isOfSameDimension(siUnit1, siUnit2);
+ }
+
+ protected boolean _isOfSameDimension(SymTypeOfSIUnit siUnit1, SymTypeOfSIUnit siUnit2) {
+ return isOfDimensionOne(multiply(siUnit1, invert(siUnit2)));
+ }
+
/**
* returns a SymTypeOfSIUnit only consisting of the seven SI base units
* (s, m, kg, A, K, mol, cd)