diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2Test.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2Test.java new file mode 100644 index 0000000000..c77bb601c8 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/core/CellRefV2Test.java @@ -0,0 +1,157 @@ +package gov.nasa.jpl.aerie.contrib.streamline.core; + +import gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.Discrete; +import gov.nasa.jpl.aerie.merlin.framework.Registrar; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.CellRefV2.autoEffects; +import static gov.nasa.jpl.aerie.contrib.streamline.core.CellRefV2.commutingEffects; +import static gov.nasa.jpl.aerie.contrib.streamline.core.CellRefV2.noncommutingEffects; +import static gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource.resource; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentValue; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.Discrete.discrete; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.monads.DiscreteDynamicsMonad.effect; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.delay; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.spawn; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.ZERO; +import static org.junit.jupiter.api.Assertions.*; + +class MutableResourceTest { + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class NonCommutingEffects { + public NonCommutingEffects(final Registrar registrar) { + Resources.init(); + } + + private final MutableResource> cell = MutableResource.resource(discrete(42), noncommutingEffects()); + + @Test + void gets_initial_value_if_no_effects_are_emitted() { + assertEquals(42, currentValue(cell)); + } + + @Test + void applies_singleton_effect() { + int initialValue = currentValue(cell); + cell.emit(effect(n -> 3 * n)); + assertEquals(3 * initialValue, currentValue(cell)); + } + + @Test + void applies_sequential_effects_in_order() { + int initialValue = currentValue(cell); + cell.emit(effect(n -> 3 * n)); + cell.emit(effect(n -> n + 1)); + assertEquals(3 * initialValue + 1, currentValue(cell)); + } + + @Test + void throws_exception_when_concurrent_effects_are_applied() { + spawn(() -> cell.emit(effect(n -> 3 * n))); + spawn(() -> cell.emit(effect(n -> 3 * n))); + delay(ZERO); + assertInstanceOf(ErrorCatching.Failure.class, cell.getDynamics()); + } + } + + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class CommutingEffects { + public CommutingEffects(final Registrar registrar) { + Resources.init(); + } + + private final MutableResource> cell = MutableResource.resource(discrete(42), commutingEffects()); + + @Test + void gets_initial_value_if_no_effects_are_emitted() { + assertEquals(42, currentValue(cell)); + } + + @Test + void applies_singleton_effect() { + int initialValue = currentValue(cell); + cell.emit(effect(n -> 3 * n)); + assertEquals(3 * initialValue, currentValue(cell)); + } + + @Test + void applies_sequential_effects_in_order() { + int initialValue = currentValue(cell); + cell.emit(effect(n -> 3 * n)); + cell.emit(effect(n -> n + 1)); + assertEquals(3 * initialValue + 1, currentValue(cell)); + } + + @Test + void applies_concurrent_effects_in_any_order() { + int initialValue = currentValue(cell); + // These effects do not in fact commute, + // but the point of the commutingEffects is that it *doesn't* check. + spawn(() -> cell.emit(effect(n -> 3 * n))); + spawn(() -> cell.emit(effect(n -> n + 1))); + delay(ZERO); + int result = currentValue(cell); + assertTrue(result == 3*initialValue + 1 || result == 3 * (initialValue + 1)); + } + } + + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class AutoEffects { + public AutoEffects(final Registrar registrar) { + Resources.init(); + } + + private final MutableResource> cell = MutableResource.resource(discrete(42), autoEffects()); + + @Test + void gets_initial_value_if_no_effects_are_emitted() { + assertEquals(42, currentValue(cell)); + } + + @Test + void applies_singleton_effect() { + int initialValue = currentValue(cell); + cell.emit(effect(n -> 3 * n)); + assertEquals(3 * initialValue, currentValue(cell)); + } + + @Test + void applies_sequential_effects_in_order() { + int initialValue = currentValue(cell); + cell.emit(effect(n -> 3 * n)); + cell.emit(effect(n -> n + 1)); + assertEquals(3 * initialValue + 1, currentValue(cell)); + } + + @Test + void applies_commuting_concurrent_effects() { + int initialValue = currentValue(cell); + // These effects do not in fact commute, + // but the point of the commutingEffects is that it *doesn't* check. + spawn(() -> cell.emit(effect(n -> 3 * n))); + spawn(() -> cell.emit(effect(n -> 4 * n))); + delay(ZERO); + int result = currentValue(cell); + assertEquals(12 * initialValue, result); + } + + @Test + void throws_exception_when_non_commuting_concurrent_effects_are_applied() { + spawn(() -> cell.emit(effect(n -> 3 * n))); + spawn(() -> cell.emit(effect(n -> n + 1))); + delay(ZERO); + assertInstanceOf(ErrorCatching.Failure.class, cell.getDynamics()); + } + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/core/ExpiryTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/core/ExpiryTest.java new file mode 100644 index 0000000000..1c91e03da7 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/core/ExpiryTest.java @@ -0,0 +1,153 @@ +package gov.nasa.jpl.aerie.contrib.streamline.core; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.Expiry.NEVER; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Expiry.at; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Expiry.expiry; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.HOUR; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.MINUTE; +import static org.junit.jupiter.api.Assertions.*; + +class ExpiryTest { + @Nested + class Value { + @Test + void never_expiring_has_empty_value() { + assertEquals(Optional.empty(), NEVER.value()); + } + + @Test + void expiring_at_t_has_value_of_t() { + assertEquals(Optional.of(MINUTE), Expiry.at(MINUTE).value()); + } + } + + @Nested + class Equals { + @Test + void never_equals_never() { + assertEquals(NEVER, NEVER); + assertEquals(NEVER, expiry(Optional.empty())); + assertEquals(expiry(Optional.empty()), NEVER); + assertEquals(expiry(Optional.empty()), expiry(Optional.empty())); + } + + @Test + void at_t_equals_at_t() { + assertEquals(at(MINUTE), at(MINUTE)); + } + + @Test + void at_t_does_not_equal_never() { + assertNotEquals(at(MINUTE), NEVER); + assertNotEquals(NEVER, at(MINUTE)); + } + + @Test + void at_t_does_not_equal_at_s() { + assertNotEquals(at(MINUTE), at(HOUR)); + assertNotEquals(at(HOUR), at(MINUTE)); + } + } + + @Nested + class IsNever { + @Test + void never_is_never() { + assertTrue(NEVER.isNever()); + } + + @Test + void at_t_is_not_never() { + assertFalse(at(MINUTE).isNever()); + } + } + + @Nested + class Or { + @Test + void never_or_never_returns_never() { + assertEquals(NEVER, NEVER.or(NEVER)); + } + + @Test + void never_or_at_t_returns_at_t() { + assertEquals(at(MINUTE), NEVER.or(at(MINUTE))); + } + + @Test + void at_t_or_never_returns_at_t() { + assertEquals(at(MINUTE), at(MINUTE).or(NEVER)); + } + + @Test + void at_t_or_at_greater_than_t_returns_at_t() { + assertEquals(at(MINUTE), at(MINUTE).or(at(HOUR))); + } + + @Test + void at_greater_than_t_or_at_t_returns_at_t() { + assertEquals(at(MINUTE), at(HOUR).or(at(MINUTE))); + } + } + + @Nested + class Minus { + @Test + void never_minus_t_returns_never() { + assertEquals(NEVER, NEVER.minus(MINUTE)); + } + + @Test + void at_t_minus_s_returns_at_difference() { + assertEquals(at(HOUR.minus(MINUTE)), at(HOUR).minus(MINUTE)); + } + } + + @Nested + class Comparisons { + @Test + void never_equals_never() { + assertFalse(NEVER.shorterThan(NEVER)); + assertTrue(NEVER.noShorterThan(NEVER)); + assertFalse(NEVER.longerThan(NEVER)); + assertTrue(NEVER.noLongerThan(NEVER)); + } + + @Test + void at_t_equals_at_t() { + assertFalse(at(MINUTE).shorterThan(at(MINUTE))); + assertTrue(at(MINUTE).noShorterThan(at(MINUTE))); + assertFalse(at(MINUTE).longerThan(at(MINUTE))); + assertTrue(at(MINUTE).noLongerThan(at(MINUTE))); + } + + @Test + void at_t_shorter_than_never() { + assertTrue(at(MINUTE).shorterThan(NEVER)); + assertFalse(at(MINUTE).noShorterThan(NEVER)); + assertFalse(at(MINUTE).longerThan(NEVER)); + assertTrue(at(MINUTE).noLongerThan(NEVER)); + } + + @Test + void never_longer_than_at_t() { + assertFalse(NEVER.shorterThan(at(MINUTE))); + assertTrue(NEVER.noShorterThan(at(MINUTE))); + assertTrue(NEVER.longerThan(at(MINUTE))); + assertFalse(NEVER.noLongerThan(at(MINUTE))); + } + + @Test + void at_t_shorter_than_at_greater_than_t() { + assertTrue(at(MINUTE).shorterThan(at(HOUR))); + assertFalse(at(MINUTE).noShorterThan(at(HOUR))); + assertFalse(at(MINUTE).longerThan(at(HOUR))); + assertTrue(at(MINUTE).noLongerThan(at(HOUR))); + } + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/debugging/DependenciesTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/debugging/DependenciesTest.java new file mode 100644 index 0000000000..8014216f63 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/debugging/DependenciesTest.java @@ -0,0 +1,67 @@ +package gov.nasa.jpl.aerie.contrib.streamline.debugging; + +import gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource; +import gov.nasa.jpl.aerie.contrib.streamline.core.Resource; +import gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.Discrete; +import gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.DiscreteResources; +import gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.Polynomial; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource.resource; +import static gov.nasa.jpl.aerie.contrib.streamline.core.monads.ResourceMonad.*; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.Polynomial.polynomial; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.PolynomialResources.constant; +import static org.junit.jupiter.api.Assertions.*; + +@Nested +@ExtendWith(MerlinExtension.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class DependenciesTest { + Resource> constantTrue = DiscreteResources.constant(true); + Resource constant1234 = constant(1234); + Resource constant5678 = constant(5678); + Resource polynomialCell = resource(polynomial(1)); + Resource derived = map(constantTrue, constant1234, constant5678, + (b, x, y) -> b.extract() ? x : y); + + @Test + void constants_are_named_by_their_value() { + assertTrue(Naming.getName(constantTrue).get().contains("true")); + assertTrue(Naming.getName(constant1234).get().contains("1234")); + assertTrue(Naming.getName(constant5678).get().contains("5678")); + } + + @Test + void cell_resources_are_not_inherently_named() { + assertTrue(Naming.getName(polynomialCell).isEmpty()); + } + + @Test + void derived_resources_are_not_inherently_named() { + assertTrue(Naming.getName(derived).isEmpty()); + } + + @Test + void constants_have_no_dependencies() { + assertTrue(Dependencies.getDependencies(constantTrue).isEmpty()); + assertTrue(Dependencies.getDependencies(constant1234).isEmpty()); + assertTrue(Dependencies.getDependencies(constant5678).isEmpty()); + } + + @Test + void cell_resources_have_no_dependencies() { + assertTrue(Dependencies.getDependencies(polynomialCell).isEmpty()); + } + + @Test + void derived_resources_have_their_sources_as_dependencies() { + var graphDescription = Dependencies.describeDependencyGraph(derived, true); + assertTrue(graphDescription.contains("true")); + assertTrue(graphDescription.contains("1234")); + assertTrue(graphDescription.contains("5678")); + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/black_box/SecantApproximationTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/black_box/SecantApproximationTest.java new file mode 100644 index 0000000000..37cb673129 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/black_box/SecantApproximationTest.java @@ -0,0 +1,48 @@ +package gov.nasa.jpl.aerie.contrib.streamline.modeling.black_box; + +import gov.nasa.jpl.aerie.contrib.streamline.modeling.black_box.IntervalFunctions.ErrorEstimateInput; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.black_box.DifferentiableResources.asDifferentiable; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.black_box.SecantApproximation.ErrorEstimates.*; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.Polynomial.polynomial; +import static org.junit.jupiter.api.Assertions.*; + +class SecantApproximationTest { + @Nested + class ErrorEstimatesTest { + @Test + void quadratic_error_estimate_for_constant_is_zero() { + var dynamics = asDifferentiable(polynomial(5)); + var result = errorByQuadraticApproximation().apply( + new ErrorEstimateInput<>( + dynamics, + 10.0, + 1e-6)); + assertEquals(0.0, result); + } + + @Test + void quadratic_error_estimate_for_linear_is_zero() { + var dynamics = asDifferentiable(polynomial(5, -3)); + var result = errorByQuadraticApproximation().apply( + new ErrorEstimateInput<>( + dynamics, + 10.0, + 1e-6)); + assertEquals(0.0, result); + } + + @Test + void quadratic_error_estimate_for_quadratic_is_exact() { + var dynamics = asDifferentiable(polynomial(1, -1, 0.5)); + var result = errorByQuadraticApproximation().apply( + new ErrorEstimateInput<>( + dynamics, + 2.0, + 1e-6)); + assertEquals(0.5, result); + } + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/discrete/DiscreteEffectsTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/discrete/DiscreteEffectsTest.java new file mode 100644 index 0000000000..49d4a953e8 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/discrete/DiscreteEffectsTest.java @@ -0,0 +1,242 @@ +package gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete; + +import gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource; +import gov.nasa.jpl.aerie.contrib.streamline.core.ErrorCatching; +import gov.nasa.jpl.aerie.contrib.streamline.core.Resources; +import gov.nasa.jpl.aerie.contrib.streamline.modeling.clocks.Clock; +import gov.nasa.jpl.aerie.contrib.streamline.unit_aware.UnitAware; +import gov.nasa.jpl.aerie.merlin.framework.Registrar; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource.resource; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentTime; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentValue; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.Discrete.discrete; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.DiscreteEffects.*; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.DiscreteResources.unitAware; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.Quantities.add; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.Quantities.quantity; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.Quantities.subtract; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.StandardUnits.*; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.StandardUnits.BIT; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.StandardUnits.METER; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.UnitAwareResources.currentValue; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.delay; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.spawn; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.MINUTE; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.SECONDS; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.ZERO; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MerlinExtension.class) +@TestInstance(Lifecycle.PER_CLASS) +class DiscreteEffectsTest { + public DiscreteEffectsTest(final Registrar registrar) { + Resources.init(); + } + + private final MutableResource> settable = resource(discrete(42)); + + @Test + void set_effect_changes_to_new_value() { + set(settable, 123); + assertEquals(123, currentValue(settable)); + } + + @Test + void conflicting_concurrent_set_effects_throw_exception() { + spawn(() -> set(settable, 123)); + spawn(() -> set(settable, 456)); + delay(ZERO); + assertInstanceOf(ErrorCatching.Failure.class, settable.getDynamics()); + } + + @Test + void agreeing_concurrent_set_effects_set_new_value() { + spawn(() -> set(settable, 789)); + spawn(() -> set(settable, 789)); + delay(ZERO); + assertEquals(789, currentValue(settable)); + } + + private final MutableResource> flag = resource(discrete(false)); + + @Test + void flag_set_makes_value_true() { + turnOn(flag); + assertTrue(currentValue(flag)); + } + + @Test + void flag_unset_makes_value_false() { + turnOff(flag); + assertFalse(currentValue(flag)); + } + + @Test + void flag_toggle_changes_value() { + turnOn(flag); + toggle(flag); + assertFalse(currentValue(flag)); + + toggle(flag); + assertTrue(currentValue(flag)); + } + + private final MutableResource> counter = resource(discrete(0)); + + @Test + void increment_increases_value_by_1() { + int initialValue = currentValue(counter); + increment(counter); + assertEquals(initialValue + 1, currentValue(counter)); + } + + @Test + void increment_by_n_increases_value_by_n() { + int initialValue = currentValue(counter); + increment(counter, 3); + assertEquals(initialValue + 3, currentValue(counter)); + } + + @Test + void decrement_decreases_value_by_1() { + int initialValue = currentValue(counter); + decrement(counter); + assertEquals(initialValue - 1, currentValue(counter)); + } + + @Test + void decrement_by_n_decreases_value_by_n() { + int initialValue = currentValue(counter); + decrement(counter, 3); + assertEquals(initialValue - 3, currentValue(counter)); + } + + private final MutableResource> consumable = resource(discrete(10.0)); + + @Test + void consume_decreases_value_by_amount() { + double initialValue = currentValue(consumable); + consume(consumable, 3.14); + assertEquals(initialValue - 3.14, currentValue(consumable)); + } + + @Test + void restore_increases_value_by_amount() { + double initialValue = currentValue(consumable); + restore(consumable, 3.14); + assertEquals(initialValue + 3.14, currentValue(consumable)); + } + + @Test + void consume_and_restore_effects_commute() { + double initialValue = currentValue(consumable); + spawn(() -> consume(consumable, 2.7)); + spawn(() -> restore(consumable, 5.6)); + delay(ZERO); + assertEquals(initialValue - 2.7 + 5.6, currentValue(consumable)); + } + + private final MutableResource> nonconsumable = resource(discrete(10.0)); + + @Test + void using_decreases_value_while_action_is_running() { + double initialValue = currentValue(nonconsumable); + using(nonconsumable, 3.14, () -> { + assertEquals(initialValue - 3.14, currentValue(nonconsumable)); + }); + assertEquals(initialValue, currentValue(nonconsumable)); + } + + MutableResource DEBUG_clock = resource(new Clock(ZERO)); + + @Test + void using_runs_synchronously() { + Duration start = currentTime(); + using(nonconsumable, 3.14, () -> { + assertEquals(start, currentTime()); + delay(MINUTE); + }); + assertEquals(start.plus(MINUTE), currentTime()); + } + + @Test + void tasks_in_parallel_with_using_observe_decreased_value() { + double initialValue = currentValue(nonconsumable); + spawn(() -> using(nonconsumable, 3.14, () -> { + delay(MINUTE); + })); + // Allow one tick for effects to be observable from child task + delay(ZERO); + assertEquals(initialValue - 3.14, currentValue(nonconsumable)); + delay(30, SECONDS); + assertEquals(initialValue - 3.14, currentValue(nonconsumable)); + delay(30, SECONDS); + // Allow one tick for effects to be observable from child task + delay(ZERO); + assertEquals(initialValue, currentValue(nonconsumable)); + } + + UnitAware>> settableDataVolume = unitAware(resource(discrete(10.0)), BIT); + + @Test + void unit_aware_set_converts_to_resource_unit() { + set(settableDataVolume, quantity(2, BYTE)); + assertEquals(quantity(16.0, BIT), currentValue(settableDataVolume)); + } + + @Test + void unit_aware_set_throws_exception_if_wrong_dimension_is_used() { + assertThrows(IllegalArgumentException.class, () -> set(settableDataVolume, quantity(2, METER))); + } + + UnitAware>> consumableDataVolume = unitAware(resource(discrete(10.0)), BIT); + + @Test + void unit_aware_consume_converts_to_resource_unit() { + var initialDataVolume = currentValue(consumableDataVolume); + var oneByte = quantity(1, BYTE); + consume(consumableDataVolume, oneByte); + assertEquals(subtract(initialDataVolume, oneByte), currentValue(consumableDataVolume)); + } + + @Test + void unit_aware_consume_throws_exception_if_wrong_dimension_is_used() { + assertThrows(IllegalArgumentException.class, () -> consume(consumableDataVolume, quantity(1, METER))); + } + + @Test + void unit_aware_restore_converts_to_resource_unit() { + var initialDataVolume = currentValue(consumableDataVolume); + var oneByte = quantity(1, BYTE); + restore(consumableDataVolume, oneByte); + assertEquals(add(initialDataVolume, oneByte), currentValue(consumableDataVolume)); + } + + @Test + void unit_aware_restore_throws_exception_if_wrong_dimension_is_used() { + assertThrows(IllegalArgumentException.class, () -> restore(consumableDataVolume, quantity(1, METER))); + } + + UnitAware>> nonconsumableDataVolume = unitAware(resource(discrete(10.0)), BIT); + + @Test + void unit_aware_using_converts_to_resource_unit() { + var initialDataVolume = currentValue(nonconsumableDataVolume); + var oneByte = quantity(1, BYTE); + using(nonconsumableDataVolume, oneByte, () -> { + assertEquals(subtract(initialDataVolume, oneByte), currentValue(nonconsumableDataVolume)); + }); + assertEquals(initialDataVolume, currentValue(nonconsumableDataVolume)); + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/discrete/PrecomputedTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/discrete/PrecomputedTest.java new file mode 100644 index 0000000000..3849203bf5 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/discrete/PrecomputedTest.java @@ -0,0 +1,140 @@ +package gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete; + +import gov.nasa.jpl.aerie.contrib.streamline.core.Resource; +import gov.nasa.jpl.aerie.contrib.streamline.core.Resources; +import gov.nasa.jpl.aerie.merlin.framework.Registrar; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.time.Instant; +import java.util.Map; +import java.util.TreeMap; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentValue; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.DiscreteResources.precomputed; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.delay; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link DiscreteResources#precomputed} + */ +@ExtendWith(MerlinExtension.class) +@TestInstance(Lifecycle.PER_CLASS) +public class PrecomputedTest { + public PrecomputedTest(final Registrar registrar) { + Resources.init(); + } + + final Resource> precomputedAsAConstant = + precomputed(4, new TreeMap<>()); + @Test + void precomputed_with_no_transitions_uses_default_value_forever() { + assertEquals(4, currentValue(precomputedAsAConstant)); + delay(HOUR); + assertEquals(4, currentValue(precomputedAsAConstant)); + delay(HOUR); + assertEquals(4, currentValue(precomputedAsAConstant)); + } + + final Resource> precomputedWithOneTransitionInFuture = + precomputed(0, new TreeMap<>(Map.of(MINUTE, 10))); + @Test + void precomputed_with_transition_in_future_changes_at_that_time() { + assertEquals(0, currentValue(precomputedWithOneTransitionInFuture)); + assertTransition(precomputedWithOneTransitionInFuture, MINUTE, 10); + delay(HOUR); + assertEquals(10, currentValue(precomputedWithOneTransitionInFuture)); + delay(HOUR); + assertEquals(10, currentValue(precomputedWithOneTransitionInFuture)); + } + + final Resource> precomputedWithOneTransitionInPast = + precomputed(0, new TreeMap<>(Map.of(duration(-1, MINUTE), 10))); + @Test + void precomputed_with_transition_in_past_uses_that_value_forever() { + assertEquals(10, currentValue(precomputedWithOneTransitionInPast)); + delay(HOUR); + assertEquals(10, currentValue(precomputedWithOneTransitionInPast)); + delay(HOUR); + assertEquals(10, currentValue(precomputedWithOneTransitionInPast)); + } + + final Resource> precomputedWithMultipleTransitionsInFuture = + precomputed(0, new TreeMap<>(Map.of( + duration(2, MINUTE), 5, + duration(5, MINUTE), 10, + duration(6, MINUTE), 15))); + @Test + void precomputed_with_multiple_transitions_in_future_goes_through_each_in_turn() { + assertEquals(0, currentValue(precomputedWithMultipleTransitionsInFuture)); + assertTransition(precomputedWithMultipleTransitionsInFuture, duration(2, MINUTE), 5); + assertTransition(precomputedWithMultipleTransitionsInFuture, duration(3, MINUTE), 10); + assertTransition(precomputedWithMultipleTransitionsInFuture, duration(1, MINUTE), 15); + delay(HOUR); + assertEquals(15, currentValue(precomputedWithMultipleTransitionsInFuture)); + delay(HOUR); + assertEquals(15, currentValue(precomputedWithMultipleTransitionsInFuture)); + } + + final Resource> precomputedWithMultipleTransitionsInPast = + precomputed(0, new TreeMap<>(Map.of( + duration(-2, MINUTE), 5, + duration(-5, MINUTE), 10, + duration(-6, MINUTE), 15))); + @Test + void precomputed_with_multiple_transition_in_past_uses_last_value_forever() { + assertEquals(5, currentValue(precomputedWithMultipleTransitionsInPast)); + delay(HOUR); + assertEquals(5, currentValue(precomputedWithMultipleTransitionsInPast)); + delay(HOUR); + assertEquals(5, currentValue(precomputedWithMultipleTransitionsInPast)); + } + + final Resource> precomputedWithTransitionsInPastAndFuture = + precomputed(0, new TreeMap<>(Map.of( + duration(-5, MINUTE), 25, + duration(-2, MINUTE), 5, + duration(5, MINUTE), 10, + duration(6, MINUTE), 15))); + @Test + void precomputed_with_transitions_in_past_and_future_chooses_starting_value_and_changes_later() { + assertEquals(5, currentValue(precomputedWithTransitionsInPastAndFuture)); + assertTransition(precomputedWithTransitionsInPastAndFuture, duration(5, MINUTE), 10); + assertTransition(precomputedWithTransitionsInPastAndFuture, duration(1, MINUTE), 15); + delay(HOUR); + assertEquals(15, currentValue(precomputedWithTransitionsInPastAndFuture)); + delay(HOUR); + assertEquals(15, currentValue(precomputedWithTransitionsInPastAndFuture)); + } + + final Resource> precomputedWithInstantKeys = + precomputed(0, new TreeMap<>(Map.of( + Instant.parse("2023-10-17T23:55:00Z"), 25, + Instant.parse("2023-10-17T23:58:00Z"), 5, + Instant.parse("2023-10-18T00:05:00Z"), 10, + Instant.parse("2023-10-18T00:06:00Z"), 15)), + Instant.parse("2023-10-18T00:00:00Z")); + @Test + void precomputed_with_instant_keys_behaves_identically_to_equivalent_duration_offsets() { + assertEquals(5, currentValue(precomputedWithInstantKeys)); + assertTransition(precomputedWithInstantKeys, duration(5, MINUTE), 10); + assertTransition(precomputedWithInstantKeys, duration(1, MINUTE), 15); + delay(HOUR); + assertEquals(15, currentValue(precomputedWithInstantKeys)); + delay(HOUR); + assertEquals(15, currentValue(precomputedWithInstantKeys)); + } + + private void assertTransition(Resource> resource, Duration transitionDelay, A expectedValue) { + A startValue = currentValue(resource); + delay(transitionDelay.minus(EPSILON)); + assertEquals(startValue, currentValue(resource)); + delay(EPSILON); + assertEquals(expectedValue, currentValue(resource)); + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/ComparisonsTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/ComparisonsTest.java new file mode 100644 index 0000000000..f707482e72 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/ComparisonsTest.java @@ -0,0 +1,365 @@ +package gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial; + +import gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource; +import gov.nasa.jpl.aerie.contrib.streamline.core.Expiry; +import gov.nasa.jpl.aerie.contrib.streamline.core.Resource; +import gov.nasa.jpl.aerie.contrib.streamline.core.Resources; +import gov.nasa.jpl.aerie.contrib.streamline.modeling.discrete.Discrete; +import gov.nasa.jpl.aerie.merlin.framework.Registrar; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource.resource; +import static gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource.set; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentData; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentValue; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.Polynomial.polynomial; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.PolynomialResources.*; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.delay; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.EPSILON; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.SECOND; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.ZERO; +import static org.junit.jupiter.api.Assertions.*; + +@ExtendWith(MerlinExtension.class) +@TestInstance(Lifecycle.PER_CLASS) +public class ComparisonsTest { + public ComparisonsTest(final Registrar registrar) { + Resources.init(); + } + + private final MutableResource p = resource(polynomial(0)); + private final MutableResource q = resource(polynomial(0)); + + private final Resource> p_lt_q = lessThan(p, q); + private final Resource> p_lte_q = lessThanOrEquals(p, q); + private final Resource> p_gt_q = greaterThan(p, q); + private final Resource> p_gte_q = greaterThanOrEquals(p, q); + + private final Resource min_p_q = min(p, q); + private final Resource min_q_p = min(q, p); + private final Resource max_p_q = max(p, q); + private final Resource max_q_p = max(q, p); + + @Test + void comparing_distinct_constants() { + setup(() -> { + set(p, polynomial(0)); + set(q, polynomial(1)); + }); + + check_comparison(p_lt_q, true, false); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + check_comparison(p_gte_q, false, false); + } + + @Test + void comparing_equal_constants() { + setup(() -> { + set(p, polynomial(1)); + set(q, polynomial(1)); + }); + + check_comparison(p_lt_q, false, false); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + check_comparison(p_gte_q, true, false); + } + + @Test + void comparing_diverging_linear_terms() { + setup(() -> { + set(p, polynomial(0, 1)); + set(q, polynomial(1, 2)); + }); + + check_comparison(p_lt_q, true, false); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + check_comparison(p_gte_q, false, false); + } + + @Test + void comparing_converging_linear_terms() { + setup(() -> { + set(p, polynomial(0, 1)); + set(q, polynomial(2, -1)); + }); + check_comparison(p_lt_q, true, true); + check_comparison(p_lte_q, true, true); + check_comparison(p_gt_q, false, true); + check_comparison(p_gte_q, false, true); + } + + @Test + void comparing_equal_linear_terms() { + setup(() -> { + set(p, polynomial(0, 1)); + set(q, polynomial(0, 1)); + }); + check_comparison(p_lt_q, false, false); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + check_comparison(p_gte_q, true, false); + } + + @Test + void comparing_equal_then_diverging_linear_terms() { + setup(() -> { + set(p, polynomial(0, 1)); + set(q, polynomial(0, 2)); + }); + // Notice that LT is initially false, but will immediately cross over + check_comparison(p_lt_q, false, true); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + // Notice that GTE is initially true, but will immediately cross over + check_comparison(p_gte_q, true, true); + } + + @Test + void comparing_diverging_nonlinear_terms() { + setup(() -> { + set(p, polynomial(0, 1, 1)); + set(q, polynomial(1, 2, 2)); + }); + check_comparison(p_lt_q, true, false); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + check_comparison(p_gte_q, false, false); + } + + @Test + void comparing_converging_nonlinear_terms() { + setup(() -> { + set(p, polynomial(0, 1, 1)); + set(q, polynomial(1, 2, -1)); + }); + check_comparison(p_lt_q, true, true); + check_comparison(p_lte_q, true, true); + check_comparison(p_gt_q, false, true); + check_comparison(p_gte_q, false, true); + } + + @Test + void comparing_equal_nonlinear_terms() { + setup(() -> { + set(p, polynomial(1, 2, -1)); + set(q, polynomial(1, 2, -1)); + }); + check_comparison(p_lt_q, false, false); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + check_comparison(p_gte_q, true, false); + } + + @Test + void comparing_equal_then_diverging_nonlinear_terms() { + setup(() -> { + set(p, polynomial(1, 2, -1)); + set(q, polynomial(1, 2, 1)); + }); + // Notice that LT is initially false, but will immediately cross over + check_comparison(p_lt_q, false, true); + check_comparison(p_lte_q, true, false); + check_comparison(p_gt_q, false, false); + // Notice that GTE is initially true, but will immediately cross over + check_comparison(p_gte_q, true, true); + } + + @Test + void extrema_of_equal_resources() { + setup(() -> { + set(p, polynomial(1, 2, -1)); + set(q, polynomial(1, 2, -1)); + }); + + check_extrema(false, false); + } + + @Test + void extrema_of_diverging_resources() { + setup(() -> { + set(p, polynomial(0, 1, -1)); + set(q, polynomial(1, 2, 1)); + }); + + check_extrema(false, false); + } + + @Test + void extrema_of_converging_resources() { + setup(() -> { + set(p, polynomial(0, 1, 1)); + set(q, polynomial(1, 2, -1)); + }); + + check_extrema(false, true); + } + + @Test + void extrema_of_equal_then_diverging_resources() { + setup(() -> { + set(p, polynomial(1, 1, -1)); + set(q, polynomial(1, 2, 1)); + }); + + check_extrema(false, false); + } + + @Test + void extrema_of_first_order_equal_then_diverging_resources() { + setup(() -> { + set(p, polynomial(1, 2, -1)); + set(q, polynomial(1, 2, 1)); + }); + + check_extrema(false, false); + } + + @Test + void extrema_of_tangent_resources() { + setup(() -> { + set(p, polynomial(0, 2, -1)); + set(q, polynomial(2, -2, 1)); + }); + + // No crossover because curves are tangent at t = 1, but q still dominates p + check_extrema(false, false); + // Explicitly check answer at t = 1, just to be sure: + reset(); + delay(SECOND); + check_extrema(false, false); + } + + // "Fine precision": + // Due to floating-point precision, it can take more than 1 microsecond + // to actually change the value of a polynomial if the rates are sufficiently small. + // Implementations of the comparisons and extrema must account for this + // for simulations to be fast and stable. + + @Test + void comparing_equal_then_diverging_linear_terms_with_fine_precision() { + setup(() -> { + set(p, polynomial(1000)); + set(q, polynomial(1000, -1e-20)); + }); + + check_comparison(p_lt_q, false, false); + check_comparison(p_lte_q, true, true); + check_comparison(p_gt_q, false, true); + check_comparison(p_gte_q, true, false); + check_extrema(true, false); + } + + @Test + void comparing_converging_linear_terms_with_fine_precision() { + setup(() -> { + set(p, polynomial(1000 - 1e-6, 1e-14)); + set(q, polynomial(1000, -1e-12)); + }); + + check_comparison(p_lt_q, true, true); + check_comparison(p_lte_q, true, true); + check_comparison(p_gt_q, false, true); + check_comparison(p_gte_q, false, true); + check_extrema(false, true); + } + + @Test + void comparing_equal_then_diverging_nonlinear_terms_with_fine_precision() { + setup(() -> { + set(p, polynomial(1000, -1e-20, 2e-22)); + set(q, polynomial(1000, -1e-20, 1e-22)); + }); + + check_comparison(p_lt_q, false, false); + check_comparison(p_lte_q, true, true); + check_comparison(p_gt_q, false, true); + check_comparison(p_gte_q, true, false); + check_extrema(true, false); + } + + @Test + void comparing_converging_nonlinear_terms_with_fine_precision() { + setup(() -> { + set(p, polynomial(1000 - 1e-6, 1e-14, 1e20)); + set(q, polynomial(1000, -1e-12, -1e20)); + }); + + check_comparison(p_lt_q, true, true); + check_comparison(p_lte_q, true, true); + check_comparison(p_gt_q, false, true); + check_comparison(p_gte_q, false, true); + check_extrema(false, true); + } + + private void check_comparison(Resource> result, boolean expectedValue, boolean expectCrossover) { + reset(); + var resultDynamics = result.getDynamics().getOrThrow(); + assertEquals(expectedValue, resultDynamics.data().extract()); + assertEquals(expectCrossover, !resultDynamics.expiry().isNever()); + if (expectCrossover) { + Duration crossover = resultDynamics.expiry().value().get(); + delay(crossover.minus(EPSILON)); + assertEquals(expectedValue, currentValue(result)); + delay(EPSILON); + assertEquals(!expectedValue, currentValue(result)); + } + } + + private void check_extrema(boolean expect_p_dominates_q, boolean expectCrossover) { + reset(); + + var minPQDynamics = min_p_q.getDynamics(); + var minQPDynamics = min_q_p.getDynamics(); + var maxPQDynamics = max_p_q.getDynamics(); + var maxQPDynamics = max_q_p.getDynamics(); + + // min and max are exactly symmetric + assertEquals(minPQDynamics, minQPDynamics); + assertEquals(maxPQDynamics, maxQPDynamics); + + // Expiry for min and max are exactly the same + Expiry expiry = minPQDynamics.getOrThrow().expiry(); + assertEquals(expiry, maxPQDynamics.getOrThrow().expiry()); + // Expiry is finite iff we expect a crossover + assertEquals(expectCrossover, !expiry.isNever()); + + var expectedMax = expect_p_dominates_q ? p : q; + var expectedMin = expect_p_dominates_q ? q : p; + + // Data for min and max match their corresponding arguments + assertEquals(currentData(expectedMin), currentData(min_p_q)); + assertEquals(currentData(expectedMax), currentData(max_p_q)); + + if (expectCrossover) { + // Just before crossover, min and max still match their originally stated arguments + delay(expiry.value().get().minus(EPSILON)); + assertEquals(currentData(expectedMin), currentData(min_p_q)); + assertEquals(currentData(expectedMax), currentData(max_p_q)); + // At crossover, min and max swap + delay(EPSILON); + assertEquals(currentData(expectedMax), currentData(min_p_q)); + assertEquals(currentData(expectedMin), currentData(max_p_q)); + } + } + + // Helper utilities to reset the simulation during a test. + // This is helpful to group similar test cases within a single method, + // even though the simulation can advance while running assertions. + private Runnable setupFunction = () -> {}; + private void setup(Runnable setupFunction) { + this.setupFunction = setupFunction; + reset(); + } + private void reset() { + setupFunction.run(); + delay(ZERO); + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/LinearBoundaryConsistencySolverTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/LinearBoundaryConsistencySolverTest.java new file mode 100644 index 0000000000..eceec5b70e --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/LinearBoundaryConsistencySolverTest.java @@ -0,0 +1,235 @@ +package gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial; + +import gov.nasa.jpl.aerie.contrib.streamline.core.*; +import gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.LinearBoundaryConsistencySolver.Domain; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import gov.nasa.jpl.aerie.merlin.protocol.types.Duration; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.MutableResource.*; +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentData; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.LinearBoundaryConsistencySolver.Comparison.*; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.LinearBoundaryConsistencySolver.LinearExpression.*; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.Polynomial.polynomial; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.*; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.SECONDS; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.ZERO; +import static org.junit.jupiter.api.Assertions.*; + +class LinearBoundaryConsistencySolverTest { + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class SingleVariableSingleConstraint { + MutableResource driver = resource(polynomial(10)); + Resource result; + + public SingleVariableSingleConstraint() { + Resources.init(); + + var solver = new LinearBoundaryConsistencySolver("SingleVariableSingleConstraint"); + var v = solver.variable("v", Domain::upperBound); + result = v.resource(); + solver.declare(lx(v), LessThanOrEquals, lx(driver)); + } + + @Test + void initial_results_are_ready_after_settling() { + settle(); + assertEquals(polynomial(10), currentData(result)); + } + + @Test + void solver_reacts_to_driving_resource() { + set(driver, polynomial(20, -1, 3)); + settle(); + assertEquals(polynomial(20, -1, 3), currentData(result)); + } + + @Test + void results_evolve_with_time() { + set(driver, polynomial(20, -1, 3)); + settle(); + assertEquals(polynomial(20, -1, 3), currentData(result)); + delay(10, SECONDS); + // new dynamics = 20 - 1 (x + 10) + 3 (x + 10)^2 = 310 + 59x + 3x^2 + assertEquals(polynomial(310, 59, 3), currentData(result)); + } + } + + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class SingleVariableMultipleConstraint { + MutableResource lowerBound1 = resource(polynomial(10)); + MutableResource lowerBound2 = resource(polynomial(20)); + MutableResource upperBound = resource(polynomial(30)); + Resource result; + + public SingleVariableMultipleConstraint() { + Resources.init(); + + var solver = new LinearBoundaryConsistencySolver("SingleVariableMultipleConstraint"); + var v = solver.variable("v", Domain::lowerBound); + result = v.resource(); + solver.declare(lx(v), GreaterThanOrEquals, lx(lowerBound1)); + solver.declare(lx(v), GreaterThanOrEquals, lx(lowerBound2)); + solver.declare(lx(v), LessThanOrEquals, lx(upperBound)); + } + + @Test + void initial_results_use_selection_policy() { + settle(); + assertEquals(polynomial(20), currentData(result)); + } + + @Test + void fully_determined_bounds_are_allowed() { + set(lowerBound1, polynomial(10, 5)); + set(lowerBound2, polynomial(12, 3)); + set(upperBound, polynomial(12, 3)); + settle(); + assertEquals(polynomial(12, 3), currentData(result)); + } + + @Test + void tangent_bounds_use_dominant_behavior() { + // Although lb1 == lb2 now, lb2 has a greater slope, so it dominates + set(lowerBound1, polynomial(12, 3, 5)); + set(lowerBound2, polynomial(12, 4, -1)); + set(upperBound, polynomial(12, 5)); + settle(); + assertEquals(polynomial(12, 4, -1), currentData(result)); + } + + @Test + void infeasible_bounds_result_in_failure() { + set(lowerBound1, polynomial(12, 3, 5)); + set(lowerBound2, polynomial(12, 4, -1)); + set(upperBound, polynomial(11, 7)); + settle(); + assertInstanceOf(ErrorCatching.Failure.class, result.getDynamics()); + } + + /** + * Clearing failures when upstream conditions improve mirrors + * the logic of derived resources, where downstream resources fail + * only when upstream resources fail; downstream resources clear + * when upstream resources clear and derivation succeeds. + */ + @Test + void failures_are_cleared_if_problem_becomes_feasible_again() { + set(lowerBound1, polynomial(12, 3, 5)); + set(lowerBound2, polynomial(12, 4, -1)); + set(upperBound, polynomial(11, 7)); + settle(); + assertInstanceOf(ErrorCatching.Failure.class, result.getDynamics()); + + set(lowerBound1, polynomial(10, 5)); + set(lowerBound2, polynomial(12, 3)); + set(upperBound, polynomial(12, 3)); + settle(); + assertEquals(polynomial(12, 3), currentData(result)); + } + } + + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class ScalingConstraint { + MutableResource driver = resource(polynomial(10)); + Resource result; + + public ScalingConstraint() { + Resources.init(); + + var solver = new LinearBoundaryConsistencySolver("ScalingConstraint"); + var v = solver.variable("v", Domain::upperBound); + result = v.resource(); + solver.declare(lx(v).multiply(4), LessThanOrEquals, lx(driver)); + } + + @Test + void scaling_can_be_inverted_when_solving() { + settle(); + assertEquals(polynomial(2.5), currentData(result)); + } + + @Test + void scaling_is_respected_for_later_solutions() { + set(driver, polynomial(20, 4, -8)); + settle(); + assertEquals(polynomial(5, 1, -2), currentData(result)); + } + } + + @Nested + @ExtendWith(MerlinExtension.class) + @TestInstance(Lifecycle.PER_CLASS) + class MultipleVariables { + MutableResource upperBound = resource(polynomial(10)); + MutableResource upperBoundOnC = resource(polynomial(5)); + Resource a, b, c; + + public MultipleVariables() { + Resources.init(); + + var solver = new LinearBoundaryConsistencySolver("MultipleVariablesSingleConstraint"); + var a = solver.variable("a", Domain::upperBound); + var b = solver.variable("b", Domain::upperBound); + var c = solver.variable("c", Domain::upperBound); + this.a = a.resource(); + this.b = b.resource(); + this.c = c.resource(); + solver.declare(lx(a).add(lx(b).multiply(2)).subtract(lx(c)), LessThanOrEquals, lx(upperBound)); + solver.declare(lx(c), LessThanOrEquals, lx(upperBoundOnC)); + solver.declare(lx(a), GreaterThanOrEquals, lx(0)); + solver.declare(lx(b), GreaterThanOrEquals, lx(0)); + solver.declare(lx(c), GreaterThanOrEquals, lx(0)); + } + + @Test + void when_problem_is_underconstrained_variables_are_resolved_in_declaration_order() { + settle(); + // Since a is resolved first, it chooses the greatest value it can + assertEquals(polynomial(15), currentData(a)); + // b and c are determined as a result of this. Notice b is constrained all the way down to 0. + assertEquals(polynomial(0), currentData(b)); + assertEquals(polynomial(5), currentData(c)); + } + + @Test + void when_problem_is_fully_determined_the_solution_is_reached() { + set(upperBoundOnC, polynomial(0)); + set(upperBound, polynomial(0)); + // Forces a = b = c = 0 + settle(); + assertEquals(polynomial(0), currentData(a)); + assertEquals(polynomial(0), currentData(b)); + assertEquals(polynomial(0), currentData(c)); + } + + @Test + void solving_works_on_higher_coefficients_too() { + set(upperBoundOnC, polynomial(5, -1)); + set(upperBound, polynomial(10, -2)); + settle(); + assertEquals(polynomial(15, -3), currentData(a)); + assertEquals(polynomial(0), currentData(b)); + assertEquals(polynomial(5, -1), currentData(c)); + // Since the problem will be infeasible at t = 5s, that should be the expiry on everything: + assertEquals(Expiry.at(Duration.of(5, SECONDS)), a.getDynamics().getOrThrow().expiry()); + assertEquals(Expiry.at(Duration.of(5, SECONDS)), b.getDynamics().getOrThrow().expiry()); + assertEquals(Expiry.at(Duration.of(5, SECONDS)), c.getDynamics().getOrThrow().expiry()); + } + } + + static void settle() { + delay(ZERO); + delay(ZERO); + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/PrecomputedTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/PrecomputedTest.java new file mode 100644 index 0000000000..47427baf17 --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/modeling/polynomial/PrecomputedTest.java @@ -0,0 +1,144 @@ +package gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial; + +import gov.nasa.jpl.aerie.contrib.streamline.core.Resource; +import gov.nasa.jpl.aerie.contrib.streamline.core.Resources; +import gov.nasa.jpl.aerie.merlin.framework.Registrar; +import gov.nasa.jpl.aerie.merlin.framework.junit.MerlinExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; +import java.util.TreeMap; + +import static gov.nasa.jpl.aerie.contrib.streamline.core.Resources.currentValue; +import static gov.nasa.jpl.aerie.contrib.streamline.modeling.polynomial.PolynomialResources.precomputed; +import static gov.nasa.jpl.aerie.merlin.framework.ModelActions.delay; +import static gov.nasa.jpl.aerie.merlin.protocol.types.Duration.*; +import static org.junit.jupiter.api.Assertions.*; + +/** + * Tests for {@link PolynomialResources#precomputed} + */ +@ExtendWith(MerlinExtension.class) +@TestInstance(Lifecycle.PER_CLASS) +public class PrecomputedTest { + public PrecomputedTest(final Registrar registrar) { + Resources.init(); + } + + final Resource precomputedAsConstantInPast = + precomputed(new TreeMap<>(Map.of(duration(-1, MINUTE), 4.0))); + @Test + void precomputed_with_single_point_in_past_extrapolates_that_value_forever() { + assertValueEquals(4.0, precomputedAsConstantInPast); + delay(HOUR); + assertValueEquals(4.0, precomputedAsConstantInPast); + delay(HOUR); + assertValueEquals(4.0, precomputedAsConstantInPast); + } + + final Resource precomputedAsConstantInFuture = + precomputed(new TreeMap<>(Map.of(duration(2, HOUR), 4.0))); + @Test + void precomputed_with_single_point_in_future_extrapolates_that_value_forever() { + assertValueEquals(4.0, precomputedAsConstantInFuture); + delay(HOUR); + assertValueEquals(4.0, precomputedAsConstantInFuture); + delay(HOUR); + assertValueEquals(4.0, precomputedAsConstantInFuture); + delay(HOUR); + assertValueEquals(4.0, precomputedAsConstantInFuture); + delay(HOUR); + assertValueEquals(4.0, precomputedAsConstantInFuture); + } + + final Resource precomputedWithSingleInteriorSegmentInPast = + precomputed(new TreeMap<>(Map.of( + duration(-100, SECOND), 0.0, + duration(-50, SECOND), 5.0))); + @Test + void precomputed_with_single_interior_segment_in_past_extrapolates_final_value() { + assertValueEquals(5.0, precomputedWithSingleInteriorSegmentInPast); + delay(HOUR); + assertValueEquals(5.0, precomputedWithSingleInteriorSegmentInPast); + delay(HOUR); + assertValueEquals(5.0, precomputedWithSingleInteriorSegmentInPast); + } + + final Resource precomputedWithSingleInteriorSegmentInFuture = + precomputed(new TreeMap<>(Map.of( + duration(50, SECOND), 0.0, + duration(100, SECOND), 5.0))); + @Test + void precomputed_with_single_interior_segment_in_future_interpolates_that_segment() { + assertValueEquals(0.0, precomputedWithSingleInteriorSegmentInFuture); + delay(50, SECOND); + assertValueEquals(0.0, precomputedWithSingleInteriorSegmentInFuture); + delay(10, SECOND); + assertValueEquals(1.0, precomputedWithSingleInteriorSegmentInFuture); + delay(10, SECOND); + assertValueEquals(2.0, precomputedWithSingleInteriorSegmentInFuture); + delay(10, SECOND); + assertValueEquals(3.0, precomputedWithSingleInteriorSegmentInFuture); + delay(10, SECOND); + assertValueEquals(4.0, precomputedWithSingleInteriorSegmentInFuture); + delay(10, SECOND); + assertValueEquals(5.0, precomputedWithSingleInteriorSegmentInFuture); + delay(HOUR); + assertValueEquals(5.0, precomputedWithSingleInteriorSegmentInFuture); + } + + final Resource precomputedStartingInInterior = + precomputed(new TreeMap<>(Map.of( + duration(-50, SECOND), 0.0, + duration(50, SECOND), 10.0))); + void precomputed_starting_in_interior_interpolates_over_full_segment() { + assertValueEquals(5.0, precomputedStartingInInterior); + delay(10, SECOND); + assertValueEquals(6.0, precomputedStartingInInterior); + delay(10, SECOND); + assertValueEquals(7.0, precomputedStartingInInterior); + delay(10, SECOND); + assertValueEquals(8.0, precomputedStartingInInterior); + delay(10, SECOND); + assertValueEquals(9.0, precomputedStartingInInterior); + delay(10, SECOND); + assertValueEquals(10.0, precomputedStartingInInterior); + delay(HOUR); + assertValueEquals(10.0, precomputedStartingInInterior); + } + + final Resource precomputedWithMultipleSegments = + precomputed(new TreeMap<>(Map.of( + duration(-50, SECOND), 0.0, + duration(50, SECOND), 10.0, + duration(60, SECOND), 30.0, + duration(90, SECOND), -30.0))); + @Test + void precomputed_with_multiple_segments_interpolates_each_segment_independently() { + assertValueEquals(5.0, precomputedWithMultipleSegments); + delay(25, SECOND); + assertValueEquals(7.5, precomputedWithMultipleSegments); + delay(25, SECOND); + assertValueEquals(10.0, precomputedWithMultipleSegments); + delay(5, SECOND); + assertValueEquals(20.0, precomputedWithMultipleSegments); + delay(5, SECOND); + assertValueEquals(30.0, precomputedWithMultipleSegments); + delay(10, SECOND); + assertValueEquals(10.0, precomputedWithMultipleSegments); + delay(10, SECOND); + assertValueEquals(-10.0, precomputedWithMultipleSegments); + delay(10, SECOND); + assertValueEquals(-30.0, precomputedWithMultipleSegments); + } + + private static final double TOLERANCE = 1e-13; + private static final double EPSILON = 1e-10; + private void assertValueEquals(double expected, Resource resource) { + assertTrue(Math.abs(expected - currentValue(resource)) / (expected + EPSILON) < TOLERANCE, + "Resource value equals " + expected); + } +} diff --git a/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/unit_aware/DimensionTest.java b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/unit_aware/DimensionTest.java new file mode 100644 index 0000000000..8376b89eef --- /dev/null +++ b/contrib/src/test/java/gov/nasa/jpl/aerie/contrib/streamline/unit_aware/DimensionTest.java @@ -0,0 +1,192 @@ +package gov.nasa.jpl.aerie.contrib.streamline.unit_aware; + +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.Dimension.SCALAR; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.Rational.*; +import static gov.nasa.jpl.aerie.contrib.streamline.unit_aware.StandardDimensions.*; +import static org.junit.jupiter.api.Assertions.*; + +class DimensionTest { + @Nested + class BaseDimensions { + @Test + void equal_themselves() { + assertEquals(LENGTH, LENGTH); + } + + @Test + void are_distinct() { + assertNotEquals(LENGTH, TIME); + } + + @Test + void satisfy_is_base_check() { + assertTrue(LENGTH.isBase()); + } + } + + @Nested + class DimensionProducts { + @Test + void are_distinct_from_both_factors() { + Dimension MASS_LENGTH = MASS.multiply(LENGTH); + assertNotEquals(MASS, MASS_LENGTH); + assertNotEquals(LENGTH, MASS_LENGTH); + } + + @Test + void are_equal_to_identically_derived_dimensions() { + Dimension MASS_LENGTH_1 = MASS.multiply(LENGTH); + Dimension MASS_LENGTH_2 = MASS.multiply(LENGTH); + assertEquals(MASS_LENGTH_1, MASS_LENGTH_2); + } + + @Test + void are_not_equal_to_different_products() { + Dimension MASS_LENGTH = MASS.multiply(LENGTH); + Dimension MASS_TIME = MASS.multiply(TIME); + assertNotEquals(MASS_LENGTH, MASS_TIME); + } + + @Test + void fail_is_base_check() { + Dimension MASS_LENGTH = MASS.multiply(LENGTH); + assertFalse(MASS_LENGTH.isBase()); + } + + @Test + void commute() { + Dimension MASS_LENGTH = MASS.multiply(LENGTH); + Dimension LENGTH_MASS = LENGTH.multiply(MASS); + assertEquals(MASS_LENGTH, LENGTH_MASS); + } + + @Test + void associate() { + Dimension MASS_LENGTH_TIME = MASS.multiply(LENGTH).multiply(TIME); + Dimension MASS_LENGTH_TIME_2 = MASS.multiply(LENGTH.multiply(TIME)); + assertEquals(MASS_LENGTH_TIME, MASS_LENGTH_TIME_2); + } + + @Test + void have_scalar_as_left_identity() { + Dimension MASS_2 = SCALAR.multiply(MASS); + assertEquals(MASS, MASS_2); + } + + @Test + void have_scalar_as_right_identity() { + Dimension MASS_2 = MASS.multiply(SCALAR); + assertEquals(MASS, MASS_2); + } + } + + @Nested + class DimensionQuotients { + @Test + void are_distinct_from_both_factors() { + assertNotEquals(LENGTH, LENGTH.divide(TIME)); + assertNotEquals(TIME, LENGTH.divide(TIME)); + } + + @Test + void are_equal_to_identically_derived_dimensions() { + assertEquals(LENGTH.divide(TIME), LENGTH.divide(TIME)); + } + + @Test + void are_not_equal_to_different_quotients() { + assertNotEquals(LENGTH.divide(TIME), MASS.divide(TIME)); + } + + @Test + void fail_is_base_check() { + assertFalse(LENGTH.divide(TIME).isBase()); + } + + @Test + void do_not_commute() { + assertNotEquals(LENGTH.divide(TIME), TIME.divide(LENGTH)); + } + + @Test + void do_not_have_scalar_as_left_identity() { + assertNotEquals(MASS, SCALAR.divide(MASS)); + } + + @Test + void have_scalar_as_right_identity() { + assertEquals(MASS, MASS.divide(SCALAR)); + } + + @Test + void invert_dimension_products() { + assertEquals(MASS, MASS.multiply(LENGTH).divide(LENGTH)); + } + + @Test + void are_inverted_by_dimension_products() { + assertEquals(MASS, MASS.divide(LENGTH).multiply(LENGTH)); + } + } + + @Nested + class DimensionPowers { + @Test + void have_one_as_right_identity() { + assertEquals(MASS, MASS.power(ONE)); + } + + @Test + void have_zero_as_right_annihilator() { + assertEquals(SCALAR, MASS.power(ZERO)); + } + + @Test + void have_scalar_as_left_annihilator() { + assertEquals(SCALAR, SCALAR.power(rational(2))); + assertEquals(SCALAR, SCALAR.power(rational(-2))); + assertEquals(SCALAR, SCALAR.power(rational(0))); + } + + @Test + void are_equal_to_repeated_multiplication_for_positive_integer_powers() { + assertEquals(MASS.multiply(MASS), MASS.power(rational(2))); + assertEquals(MASS.multiply(MASS).multiply(MASS), MASS.power(rational(3))); + } + + @Test + void are_equal_to_repeated_division_for_negative_integer_powers() { + assertEquals(SCALAR.divide(MASS), MASS.power(rational(-1))); + assertEquals(SCALAR.divide(MASS).divide(MASS), MASS.power(rational(-2))); + } + + @Test + void distribute_over_products() { + var p = rational(2, 3); + assertEquals(MASS.power(p).multiply(LENGTH.power(p)), MASS.multiply(LENGTH).power(p)); + } + + @Test + void distribute_over_quotients() { + var p = rational(2, 3); + assertEquals(MASS.power(p).divide(LENGTH.power(p)), MASS.divide(LENGTH).power(p)); + } + + @Test + void add_exactly_when_multiplying_common_bases() { + var p = rational(2, 3); + var q = rational(1, 3); + assertEquals(MASS, MASS.power(p).multiply(MASS.power(q))); + } + + @Test + void subtract_exactly_when_multiplying_common_bases() { + var p = rational(4, 3); + var q = rational(1, 3); + assertEquals(MASS, MASS.power(p).divide(MASS.power(q))); + } + } +}