From e50efeba9055f0a62f5dfc5127deebf5f5a23ad4 Mon Sep 17 00:00:00 2001 From: Tommy Lau <8563294+tommylau523@users.noreply.github.com> Date: Fri, 13 Dec 2024 14:51:38 -0800 Subject: [PATCH 1/2] Combined functionality to schedule and complete ad-hoc observation into one endpoint --- .../backend/tracking/ObservationService.kt | 46 +++-- .../tracking/api/ObservationsController.kt | 60 +++--- .../backend/tracking/db/ObservationStore.kt | 24 ++- .../tracking/ObservationServiceTest.kt | 176 ++++++++++++++++-- .../backend/tracking/PlotAssignmentTest.kt | 5 +- 5 files changed, 247 insertions(+), 64 deletions(-) diff --git a/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt b/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt index 91b26cec4e7..5792bb8ee85 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt @@ -1,11 +1,13 @@ package com.terraformation.backend.tracking import com.terraformation.backend.customer.db.ParentStore +import com.terraformation.backend.customer.model.SystemUser import com.terraformation.backend.customer.model.requirePermissions import com.terraformation.backend.db.FileNotFoundException import com.terraformation.backend.db.asNonNullable import com.terraformation.backend.db.default_schema.FileId import com.terraformation.backend.db.tracking.MonitoringPlotId +import com.terraformation.backend.db.tracking.ObservableCondition import com.terraformation.backend.db.tracking.ObservationId import com.terraformation.backend.db.tracking.ObservationPlotPosition import com.terraformation.backend.db.tracking.ObservationState @@ -14,6 +16,7 @@ import com.terraformation.backend.db.tracking.PlantingSiteId import com.terraformation.backend.db.tracking.tables.daos.MonitoringPlotsDao import com.terraformation.backend.db.tracking.tables.daos.ObservationPhotosDao import com.terraformation.backend.db.tracking.tables.pojos.ObservationPhotosRow +import com.terraformation.backend.db.tracking.tables.pojos.RecordedPlantsRow import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_PHOTOS import com.terraformation.backend.file.FileService import com.terraformation.backend.file.SizedInputStream @@ -67,6 +70,7 @@ class ObservationService( private val observationStore: ObservationStore, private val plantingSiteStore: PlantingSiteStore, private val parentStore: ParentStore, + private val systemUser: SystemUser, ) { private val log = perClassLogger() @@ -408,38 +412,56 @@ class ObservationService( } /** - * Schedule an ad-hoc observation. This creates an ad-hoc observation, creates an ad-hoc - * monitoring plot, and adds the plot to the observation. + * Record an ad-hoc observation. This creates an ad-hoc observation, creates an ad-hoc monitoring + * plot, adds the plot to the observation, and complete the observation with plants. */ - fun scheduleAdHocObservation( - endDate: LocalDate, + fun completeAdHocObservation( + conditions: Set, + notes: String?, + observedTime: Instant, observationType: ObservationType, plantingSiteId: PlantingSiteId, + plants: Collection, plotName: String, - startDate: LocalDate, swCorner: Point, ): Pair { requirePermissions { scheduleAdHocObservation(plantingSiteId) } - validateSchedule(plantingSiteId, startDate, endDate) + if (observedTime.isAfter(clock.instant())) { + throw IllegalArgumentException("Observed time is in the future") + } + + val effectiveTimeZone = parentStore.getEffectiveTimeZone(plantingSiteId) + val date = LocalDate.ofInstant(observedTime, effectiveTimeZone) return dslContext.transactionResult { _ -> val observationId = observationStore.createObservation( NewObservationModel( - endDate = endDate, + endDate = date, id = null, isAdHoc = true, observationType = observationType, plantingSiteId = plantingSiteId, requestedSubzoneIds = emptySet(), - startDate = startDate, + startDate = date, state = ObservationState.Upcoming)) val plotId = plantingSiteStore.createAdHocMonitoringPlot(plotName, plantingSiteId, swCorner) - observationStore.addAdHocPlotToObservation(observationId, plotId) + systemUser.run { observationStore.recordObservationStart(observationId) } + + observationStore.claimPlot(observationId, plotId) + observationStore.completePlot( + observationId, + plotId, + conditions, + notes, + observedTime, + plants, + ) + observationId to plotId } } @@ -527,9 +549,9 @@ class ObservationService( } /** - * Validation rules: - * 1. start date can be up to one year from today and not earlier than today - * 2. end date should be after the start date but no more than 2 months from the start date + * Validation rules: 1a. for non-ad-hoc, start date can be up to one year from today and not + * earlier than today. 1b. for ad-hoc, start date can be on or before today. + * 2. end date should be after the start date but no more than 2 months from the start date. */ private fun validateSchedule( plantingSiteId: PlantingSiteId, diff --git a/src/main/kotlin/com/terraformation/backend/tracking/api/ObservationsController.kt b/src/main/kotlin/com/terraformation/backend/tracking/api/ObservationsController.kt index 467de4b673c..bad10d04590 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/api/ObservationsController.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/api/ObservationsController.kt @@ -395,22 +395,24 @@ class ObservationsController( return SimpleSuccessResponsePayload() } - @Operation(summary = "Schedules a new ad-hoc observation.") + @Operation(summary = "Records a new completed ad-hoc observation.") @PostMapping("/adHoc") - fun scheduleAdHocObservation( - @RequestBody payload: ScheduleAdHocObservationRequestPayload - ): ScheduleAdHocObservationResponsePayload { - val (observationId) = - observationService.scheduleAdHocObservation( - payload.endDate, - payload.type, + fun completeAdHocObservation( + @RequestBody payload: CompleteAdHocObservationRequestPayload + ): CompleteAdHocObservationResponsePayload { + val (observationId, plotId) = + observationService.completeAdHocObservation( + payload.conditions, + payload.notes, + payload.observedTime, + payload.observationType, payload.plantingSiteId, + payload.plants.map { it.toRow() }, payload.plotName, - payload.startDate, payload.swCorner, ) - return ScheduleAdHocObservationResponsePayload(observationId) + return CompleteAdHocObservationResponsePayload(observationId, plotId) } @Operation(summary = "Schedules a new observation.") @@ -938,6 +940,21 @@ data class PlantingSiteObservationSummaryPayload( plantingZones = model.plantingZones.map { PlantingZoneObservationSummaryPayload(it) }) } +data class CompleteAdHocObservationRequestPayload( + val conditions: Set, + val notes: String?, + @Schema(description = "Date and time the observation was performed in the field.") + val observedTime: Instant, + @Schema(description = "Observation type for this observation.") + val observationType: ObservationType, + @Schema(description = "The plot name for the ad-hoc plot.") val plotName: String, + val plants: List, + @Schema(description = "Which planting site this observation needs to be scheduled for.") + val plantingSiteId: PlantingSiteId, + @Schema(description = "GPS coordinates for the South West corner of the ad-hoc plot.") + val swCorner: Point, +) + data class CompletePlotObservationRequestPayload( val conditions: Set, val notes: String?, @@ -1011,24 +1028,6 @@ data class GetPlantingSiteObservationSummariesPayload( val summaries: List, ) : SuccessResponsePayload -data class ScheduleAdHocObservationRequestPayload( - @Schema( - description = - "The end date for this observation, should be limited to 2 months from the start date.") - val endDate: LocalDate, - @Schema(description = "The plot name for the ad-hoc plot.") val plotName: String, - @Schema(description = "Which planting site this observation needs to be scheduled for.") - val plantingSiteId: PlantingSiteId, - @Schema( - description = - "The start date for this observation, can be up to a year from the date this " + - "schedule request occurs on.") - val startDate: LocalDate, - @Schema(description = "GPS coordinates for the South West corner of the ad-hoc plot.") - val swCorner: Point, - @Schema(description = "Observation type for this observation.") val type: ObservationType, -) - data class ScheduleObservationRequestPayload( @Schema( description = @@ -1059,7 +1058,10 @@ data class ScheduleObservationRequestPayload( state = ObservationState.Upcoming) } -data class ScheduleAdHocObservationResponsePayload(val id: ObservationId) : SuccessResponsePayload +data class CompleteAdHocObservationResponsePayload( + val observationId: ObservationId, + val plotId: MonitoringPlotId +) : SuccessResponsePayload data class ScheduleObservationResponsePayload(val id: ObservationId) : SuccessResponsePayload diff --git a/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationStore.kt b/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationStore.kt index 6483351c3a9..25f03163748 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationStore.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationStore.kt @@ -719,8 +719,8 @@ class ObservationStore( val (plantingZoneId, plantingSiteId) = dslContext .select( - MONITORING_PLOTS.plantingSubzones.PLANTING_ZONE_ID.asNonNullable(), - MONITORING_PLOTS.plantingSubzones.PLANTING_SITE_ID.asNonNullable()) + MONITORING_PLOTS.plantingSubzones.PLANTING_ZONE_ID, + MONITORING_PLOTS.PLANTING_SITE_ID.asNonNullable()) .from(MONITORING_PLOTS) .where(MONITORING_PLOTS.ID.eq(monitoringPlotId)) .fetchOne()!! @@ -1359,7 +1359,7 @@ class ObservationStore( private fun updateSpeciesTotals( observationId: ObservationId, plantingSiteId: PlantingSiteId, - plantingZoneId: PlantingZoneId, + plantingZoneId: PlantingZoneId?, monitoringPlotId: MonitoringPlotId?, isPermanent: Boolean, plantCountsBySpecies: Map> @@ -1374,13 +1374,17 @@ class ObservationStore( plantCountsBySpecies, ) } - updateSpeciesTotalsTable( - OBSERVED_ZONE_SPECIES_TOTALS.PLANTING_ZONE_ID, - observationId, - plantingZoneId, - isPermanent, - plantCountsBySpecies, - ) + + if (plantingZoneId != null) { + updateSpeciesTotalsTable( + OBSERVED_ZONE_SPECIES_TOTALS.PLANTING_ZONE_ID, + observationId, + plantingZoneId, + isPermanent, + plantCountsBySpecies, + ) + } + updateSpeciesTotalsTable( OBSERVED_SITE_SPECIES_TOTALS.PLANTING_SITE_ID, observationId, diff --git a/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt b/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt index ae7fb148af1..8030adb52b1 100644 --- a/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt +++ b/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt @@ -6,8 +6,10 @@ import com.terraformation.backend.TestEventPublisher import com.terraformation.backend.TestSingletons import com.terraformation.backend.assertGeometryEquals import com.terraformation.backend.customer.db.ParentStore +import com.terraformation.backend.customer.model.SystemUser import com.terraformation.backend.customer.model.TerrawareUser import com.terraformation.backend.db.DatabaseTest +import com.terraformation.backend.db.EntityNotFoundException import com.terraformation.backend.db.FileNotFoundException import com.terraformation.backend.db.default_schema.FacilityType import com.terraformation.backend.db.default_schema.FileId @@ -23,7 +25,10 @@ import com.terraformation.backend.db.tracking.ObservationState import com.terraformation.backend.db.tracking.ObservationType import com.terraformation.backend.db.tracking.PlantingSiteId import com.terraformation.backend.db.tracking.PlantingSubzoneId +import com.terraformation.backend.db.tracking.RecordedPlantStatus import com.terraformation.backend.db.tracking.RecordedSpeciesCertainty.Known +import com.terraformation.backend.db.tracking.RecordedSpeciesCertainty.Other +import com.terraformation.backend.db.tracking.RecordedSpeciesCertainty.Unknown import com.terraformation.backend.db.tracking.embeddables.pojos.ObservationPlotId import com.terraformation.backend.db.tracking.tables.pojos.MonitoringPlotsRow import com.terraformation.backend.db.tracking.tables.pojos.ObservationPhotosRow @@ -32,12 +37,14 @@ import com.terraformation.backend.db.tracking.tables.pojos.ObservationsRow import com.terraformation.backend.db.tracking.tables.pojos.ObservedPlotSpeciesTotalsRow import com.terraformation.backend.db.tracking.tables.pojos.ObservedSiteSpeciesTotalsRow import com.terraformation.backend.db.tracking.tables.pojos.ObservedZoneSpeciesTotalsRow +import com.terraformation.backend.db.tracking.tables.pojos.RecordedPlantsRow import com.terraformation.backend.db.tracking.tables.references.OBSERVATION_PLOTS import com.terraformation.backend.file.FileService import com.terraformation.backend.file.InMemoryFileStore import com.terraformation.backend.file.SizedInputStream import com.terraformation.backend.file.ThumbnailStore import com.terraformation.backend.file.model.FileMetadata +import com.terraformation.backend.i18n.TimeZones import com.terraformation.backend.onePixelPng import com.terraformation.backend.point import com.terraformation.backend.rectangle @@ -154,7 +161,9 @@ class ObservationServiceTest : DatabaseTest(), RunsAsDatabaseUser { observationPhotosDao, observationStore, plantingSiteStore, - parentStore) + parentStore, + SystemUser(usersDao), + ) } private val helper: ObservationTestHelper by lazy { ObservationTestHelper(this, observationStore, user.userId) @@ -1852,37 +1861,92 @@ class ObservationServiceTest : DatabaseTest(), RunsAsDatabaseUser { } @Nested - inner class ScheduleAdHocObservation { + inner class CompleteAdHocObservation { @BeforeEach fun setUp() { - helper.insertPlantedSite(width = 2, height = 7, subzoneCompletedTime = Instant.EPOCH) + insertOrganizationUser(role = Role.Contributor) } @Test fun `creates new observation and monitoring plot`() { - val startDate = LocalDate.EPOCH - val endDate = startDate.plusDays(1) + val speciesId1 = insertSpecies() + val speciesId2 = insertSpecies() + + val observedTime = Instant.ofEpochSecond(1) + clock.instant = Instant.ofEpochSecond(123) + + val date = LocalDate.ofInstant(observedTime, TimeZones.UTC) + val recordedPlants = + listOf( + RecordedPlantsRow( + certaintyId = Known, + gpsCoordinates = point(1), + speciesId = speciesId1, + statusId = RecordedPlantStatus.Live), + RecordedPlantsRow( + certaintyId = Known, + gpsCoordinates = point(1), + speciesId = speciesId1, + statusId = RecordedPlantStatus.Live), + RecordedPlantsRow( + certaintyId = Known, + gpsCoordinates = point(1), + speciesId = speciesId1, + statusId = RecordedPlantStatus.Dead), + RecordedPlantsRow( + certaintyId = Known, + gpsCoordinates = point(1), + speciesId = speciesId1, + statusId = RecordedPlantStatus.Existing), + RecordedPlantsRow( + certaintyId = Known, + gpsCoordinates = point(1), + speciesId = speciesId2, + statusId = RecordedPlantStatus.Existing, + ), + RecordedPlantsRow( + certaintyId = Other, + gpsCoordinates = point(1), + speciesName = "Other 1", + statusId = RecordedPlantStatus.Existing, + ), + RecordedPlantsRow( + certaintyId = Other, + gpsCoordinates = point(1), + speciesName = "Other 2", + statusId = RecordedPlantStatus.Dead, + ), + RecordedPlantsRow( + certaintyId = Unknown, + gpsCoordinates = point(1), + statusId = RecordedPlantStatus.Live, + ), + ) val (observationId, plotId) = - service.scheduleAdHocObservation( - endDate, + service.completeAdHocObservation( + emptySet(), + "Notes", + observedTime, ObservationType.BiomassMeasurements, plantingSiteId, + recordedPlants, "Ad-hoc plot name", - startDate, point(1), ) assertEquals( ObservationsRow( + completedTime = clock.instant, createdTime = clock.instant, - endDate = endDate, + endDate = date, id = observationId, isAdHoc = true, observationTypeId = ObservationType.BiomassMeasurements, plantingSiteId = plantingSiteId, - startDate = startDate, - stateId = ObservationState.Upcoming, + plantingSiteHistoryId = inserted.plantingSiteHistoryId, + startDate = date, + stateId = ObservationState.Completed, ), observationsDao.fetchOneById(observationId), "Observation row") @@ -1915,18 +1979,106 @@ class ObservationServiceTest : DatabaseTest(), RunsAsDatabaseUser { ObservationPlotsRow( observationId = observationId, monitoringPlotId = plotId, + claimedBy = user.userId, + claimedTime = clock.instant, + completedBy = user.userId, + completedTime = clock.instant, createdBy = user.userId, createdTime = clock.instant, isPermanent = false, modifiedBy = user.userId, modifiedTime = clock.instant, - statusId = ObservationPlotStatus.Unclaimed, + observedTime = observedTime, + notes = "Notes", + statusId = ObservationPlotStatus.Completed, monitoringPlotHistoryId = latestPlotHistoryId, ), observationPlotsDao .fetchByObservationPlotId(ObservationPlotId(observationId, plotId)) .single(), "Observation plot row") + + val plotSpecies1Totals = + ObservedPlotSpeciesTotalsRow( + observationId, plotId, speciesId1, null, Known, 2, 1, 1, null, 0, 0) + val plotSpecies2Totals = + ObservedPlotSpeciesTotalsRow( + observationId, plotId, speciesId2, null, Known, 0, 0, 1, null, 0, 0) + val plotOther1Total = + ObservedPlotSpeciesTotalsRow( + observationId, plotId, null, "Other 1", Other, 0, 0, 1, null, 0, 0) + val plotOther2Total = + ObservedPlotSpeciesTotalsRow( + observationId, plotId, null, "Other 2", Other, 0, 1, 0, null, 0, 0) + val plotUnknownTotal = + ObservedPlotSpeciesTotalsRow( + observationId, plotId, null, null, Unknown, 1, 0, 0, null, 0, 0) + + val siteSpecies1Totals = + ObservedSiteSpeciesTotalsRow( + observationId, plantingSiteId, speciesId1, null, Known, 2, 1, 1, null, 0, 0) + val siteSpecies2Totals = + ObservedSiteSpeciesTotalsRow( + observationId, plantingSiteId, speciesId2, null, Known, 0, 0, 1, null, 0, 0) + val siteOther1Total = + ObservedSiteSpeciesTotalsRow( + observationId, plantingSiteId, null, "Other 1", Other, 0, 0, 1, null, 0, 0) + val siteOther2Total = + ObservedSiteSpeciesTotalsRow( + observationId, plantingSiteId, null, "Other 2", Other, 0, 1, 0, null, 0, 0) + val siteUnknownTotal = + ObservedSiteSpeciesTotalsRow( + observationId, plantingSiteId, null, null, Unknown, 1, 0, 0, null, 0, 0) + + helper.assertTotals( + setOf( + plotSpecies1Totals, + plotSpecies2Totals, + plotOther1Total, + plotOther2Total, + plotUnknownTotal, + siteSpecies1Totals, + siteSpecies2Totals, + siteOther1Total, + siteOther2Total, + siteUnknownTotal), + "Totals after observation") + } + + @Test + fun `throws exception if no permission`() { + deleteOrganizationUser() + + clock.instant = Instant.ofEpochSecond(500) + + assertThrows { + service.completeAdHocObservation( + emptySet(), + null, + clock.instant.minusSeconds(1), + ObservationType.BiomassMeasurements, + plantingSiteId, + emptySet(), + "Ad-hoc plot name", + point(1), + ) + } + } + + @Test + fun `throws exception if no observed time is in the future`() { + assertThrows { + service.completeAdHocObservation( + emptySet(), + null, + clock.instant.plusSeconds(1), + ObservationType.BiomassMeasurements, + plantingSiteId, + emptySet(), + "Ad-hoc plot name", + point(1), + ) + } } } diff --git a/src/test/kotlin/com/terraformation/backend/tracking/PlotAssignmentTest.kt b/src/test/kotlin/com/terraformation/backend/tracking/PlotAssignmentTest.kt index a7198c531cd..8f3f1d08568 100644 --- a/src/test/kotlin/com/terraformation/backend/tracking/PlotAssignmentTest.kt +++ b/src/test/kotlin/com/terraformation/backend/tracking/PlotAssignmentTest.kt @@ -5,6 +5,7 @@ import com.terraformation.backend.TestClock import com.terraformation.backend.TestEventPublisher import com.terraformation.backend.TestSingletons import com.terraformation.backend.customer.db.ParentStore +import com.terraformation.backend.customer.model.SystemUser import com.terraformation.backend.db.DatabaseTest import com.terraformation.backend.db.default_schema.FacilityType import com.terraformation.backend.db.default_schema.OrganizationId @@ -68,7 +69,9 @@ class PlotAssignmentTest : DatabaseTest(), RunsAsUser { observationPhotosDao, observationStore, plantingSiteStore, - parentStore) + parentStore, + SystemUser(usersDao), + ) } private val gen = ShapefileGenerator(defaultPermanentClusters = 1, defaultTemporaryPlots = 2) From 0db94d6bca1e2d3fa88a3b2ef18fa156f89bacbf Mon Sep 17 00:00:00 2001 From: Tommy Lau <8563294+tommylau523@users.noreply.github.com> Date: Mon, 16 Dec 2024 11:41:34 -0800 Subject: [PATCH 2/2] Updated styles and added tolerance for time checking --- .../backend/tracking/ObservationService.kt | 13 ++++++++----- .../backend/tracking/ObservationServiceTest.kt | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt b/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt index 5792bb8ee85..6be7aa91481 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/ObservationService.kt @@ -59,6 +59,9 @@ import org.locationtech.jts.geom.Point import org.springframework.context.ApplicationEventPublisher import org.springframework.context.event.EventListener +/** Number of seconds of tolerance when checking if observation time is before server clock time */ +const val CLOCK_TOLERANCE_SECONDS: Long = 3600 + @Named class ObservationService( private val clock: InstantSource, @@ -412,8 +415,8 @@ class ObservationService( } /** - * Record an ad-hoc observation. This creates an ad-hoc observation, creates an ad-hoc monitoring - * plot, adds the plot to the observation, and complete the observation with plants. + * Records an ad-hoc observation. This creates an ad-hoc observation, creates an ad-hoc monitoring + * plot, adds the plot to the observation, and completes the observation with plants. */ fun completeAdHocObservation( conditions: Set, @@ -427,7 +430,7 @@ class ObservationService( ): Pair { requirePermissions { scheduleAdHocObservation(plantingSiteId) } - if (observedTime.isAfter(clock.instant())) { + if (observedTime.isAfter(clock.instant().plusSeconds(CLOCK_TOLERANCE_SECONDS))) { throw IllegalArgumentException("Observed time is in the future") } @@ -549,8 +552,8 @@ class ObservationService( } /** - * Validation rules: 1a. for non-ad-hoc, start date can be up to one year from today and not - * earlier than today. 1b. for ad-hoc, start date can be on or before today. + * Validation rules: + * 1. start date can be up to one year from today and not earlier than today. * 2. end date should be after the start date but no more than 2 months from the start date. */ private fun validateSchedule( diff --git a/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt b/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt index 8030adb52b1..ec460d01d3c 100644 --- a/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt +++ b/src/test/kotlin/com/terraformation/backend/tracking/ObservationServiceTest.kt @@ -2066,12 +2066,12 @@ class ObservationServiceTest : DatabaseTest(), RunsAsDatabaseUser { } @Test - fun `throws exception if no observed time is in the future`() { + fun `throws exception if no observed time is in the future outside of tolerance`() { assertThrows { service.completeAdHocObservation( emptySet(), null, - clock.instant.plusSeconds(1), + clock.instant.plusSeconds(CLOCK_TOLERANCE_SECONDS + 1), ObservationType.BiomassMeasurements, plantingSiteId, emptySet(),