From a39c5fa57847239d37cf5a74c2dfbc824ba9d62f Mon Sep 17 00:00:00 2001 From: Tommy Lau <8563294+tommylau523@users.noreply.github.com> Date: Mon, 4 Nov 2024 08:50:09 -0800 Subject: [PATCH] SW-5880 Added observation aggregates to subzones (#2538) Implemented logic to fetch observation summary. Some changes as follow: 1) Added subzone stats in an observation by aggregating monitoring plot 2) Method to fetch latest observation per subzone 3) Methods to build up observation summary at zone/site level from the bottoms up. 4) Added test cases for summary calculations: https://docs.google.com/spreadsheets/d/17-9VlElRgnXU7rnCPsesw6bM5c1Du7Z_ZIvSq8yJPbI/edit?usp=sharing (blue highlighted rows under plot stats reference an older observation) 5) Added observations CSV as a way to bulk import plants within an observation --- .../tracking/api/ObservationsController.kt | 122 ++++++++ .../tracking/db/ObservationResultsStore.kt | 143 +++++++-- .../tracking/model/ObservationResultsModel.kt | 241 ++++++++++++--- .../db/ObservationResultsStoreTest.kt | 290 +++++++++++++++++- .../ObservationsSummary/Observation-1.csv | 20 ++ .../ObservationsSummary/Observation-2.csv | 20 ++ .../ObservationsSummary/Observation-3.csv | 20 ++ .../ObservationsSummary/PlotStats.csv | 20 ++ .../observation/ObservationsSummary/Plots.csv | 19 ++ .../ObservationsSummary/SiteStats.csv | 3 + .../ObservationsSummary/Subzones.csv | 7 + .../ObservationsSummary/ZoneStats.csv | 5 + .../observation/ObservationsSummary/Zones.csv | 5 + 13 files changed, 842 insertions(+), 73 deletions(-) create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/Observation-1.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/Observation-2.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/Observation-3.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/PlotStats.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/Plots.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/SiteStats.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/Subzones.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/ZoneStats.csv create mode 100644 src/test/resources/tracking/observation/ObservationsSummary/Zones.csv 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 6db46c233f6d..1757d2038757 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/api/ObservationsController.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/api/ObservationsController.kt @@ -49,8 +49,10 @@ import com.terraformation.backend.tracking.model.ObservationMonitoringPlotResult import com.terraformation.backend.tracking.model.ObservationMonitoringPlotStatus import com.terraformation.backend.tracking.model.ObservationPlantingSubzoneResultsModel import com.terraformation.backend.tracking.model.ObservationPlantingZoneResultsModel +import com.terraformation.backend.tracking.model.ObservationPlantingZoneRollupResultsModel import com.terraformation.backend.tracking.model.ObservationPlotCounts import com.terraformation.backend.tracking.model.ObservationResultsModel +import com.terraformation.backend.tracking.model.ObservationRollupResultsModel import com.terraformation.backend.tracking.model.ObservationSpeciesResultsModel import com.terraformation.backend.tracking.model.ObservedPlotCoordinatesModel import com.terraformation.backend.tracking.model.PlantingSiteDepth @@ -170,6 +172,16 @@ class ObservationsController( return ListObservationResultsResponsePayload(results.map { ObservationResultsPayload(it) }) } + @GetMapping("/results/summary") + @Operation(summary = "Gets the rollup observation summary of a planting site") + fun getPlantingSiteObservationSummary( + @RequestParam plantingSiteId: PlantingSiteId, + ): GetPlantingSiteObservationSummaryPayload { + val model = observationResultsStore.fetchSummaryForPlantingSite(plantingSiteId) + return GetPlantingSiteObservationSummaryPayload( + model?.let { PlantingSiteObservationSummaryPayload(model) }) + } + @GetMapping("/{observationId}") @Operation(summary = "Gets information about a single observation.") fun getObservation(@PathVariable observationId: ObservationId): GetObservationResponsePayload { @@ -649,14 +661,39 @@ data class ObservationMonitoringPlotResultsPayload( } data class ObservationPlantingSubzoneResultsPayload( + @Schema(description = "Area of this planting subzone in hectares.") // + val areaHa: BigDecimal, + val completedTime: Instant?, + @Schema( + description = + "Estimated number of plants in planting subzone based on estimated planting density " + + "and subzone area. Only present if the subzone has completed planting.") + val estimatedPlants: Int?, + @Schema( + description = + "Percentage of plants of all species that were dead in this subzone's permanent " + + "monitoring plots.") val monitoringPlots: List, + val mortalityRate: Int, + @Schema( + description = + "Estimated planting density for the subzone based on the observed planting densities " + + "of monitoring plots. Only present if the subzone has completed planting.") + val plantingDensity: Int?, val plantingSubzoneId: PlantingSubzoneId, + val totalPlants: Int, ) { constructor( model: ObservationPlantingSubzoneResultsModel ) : this( + areaHa = model.areaHa, + completedTime = model.completedTime, + estimatedPlants = model.estimatedPlants, monitoringPlots = model.monitoringPlots.map { ObservationMonitoringPlotResultsPayload(it) }, + mortalityRate = model.mortalityRate, + plantingDensity = model.plantingDensity, plantingSubzoneId = model.plantingSubzoneId, + totalPlants = model.totalPlants, ) } @@ -763,6 +800,84 @@ data class ObservationResultsPayload( ) } +data class PlantingZoneObservationSummaryPayload( + @Schema(description = "Area of this planting zone in hectares.") // + val areaHa: BigDecimal, + @Schema(description = "The earliest time of the observations used in this summary.") + val earliestObservationTime: Instant, + @Schema( + description = + "Estimated number of plants in planting zone based on estimated planting density and " + + "planting zone area. Only present if all the subzones in the zone have been " + + "marked as having completed planting.") + val estimatedPlants: Int?, + @Schema(description = "The latest time of the observations used in this summary.") + val latestObservationTime: Instant, + @Schema( + description = + "Percentage of plants of all species that were dead in this zone's permanent " + + "monitoring plots.") + val mortalityRate: Int, + @Schema( + description = + "Estimated planting density for the zone based on the observed planting densities " + + "of monitoring plots. Only present if all the subzones in the zone have been " + + "marked as having completed planting.") + val plantingDensity: Int?, + @Schema(description = "List of subzone observations used in this summary.") + val plantingSubzones: List, + val plantingZoneId: PlantingZoneId, +) { + constructor( + model: ObservationPlantingZoneRollupResultsModel + ) : this( + areaHa = model.areaHa, + earliestObservationTime = model.earliestCompletedTime, + estimatedPlants = model.estimatedPlants, + latestObservationTime = model.latestCompletedTime, + mortalityRate = model.mortalityRate, + plantingDensity = model.plantingDensity, + plantingSubzones = + model.plantingSubzones.map { ObservationPlantingSubzoneResultsPayload(it) }, + plantingZoneId = model.plantingZoneId, + ) +} + +data class PlantingSiteObservationSummaryPayload( + @Schema(description = "The earliest time of the observations used in this summary.") + val earliestObservationTime: Instant, + @Schema( + description = + "Estimated total number of live plants at the site, based on the estimated planting " + + "density and site size. Only present if all the subzones in the site have been " + + "marked as having completed planting.") + val estimatedPlants: Int?, + @Schema(description = "The latest time of the observations used in this summary.") + val latestObservationTime: Instant, + @Schema( + description = + "Percentage of plants of all species that were dead in this site's permanent " + + "monitoring plots.") + val mortalityRate: Int?, + @Schema( + description = + "Estimated planting density for the site, based on the observed planting densities " + + "of monitoring plots. Only present if all the subzones in the site have been " + + "marked as having completed planting.") + val plantingDensity: Int?, + val plantingZones: List +) { + constructor( + model: ObservationRollupResultsModel + ) : this( + earliestObservationTime = model.earliestCompletedTime, + estimatedPlants = model.estimatedPlants, + latestObservationTime = model.latestCompletedTime, + mortalityRate = model.mortalityRate, + plantingDensity = model.plantingDensity, + plantingZones = model.plantingZones.map { PlantingZoneObservationSummaryPayload(it) }) +} + data class CompletePlotObservationRequestPayload( val conditions: Set, val notes: String?, @@ -815,6 +930,13 @@ data class ListObservationResultsResponsePayload( val observations: List ) : SuccessResponsePayload +data class GetPlantingSiteObservationSummaryPayload( + @Schema( + description = + "Rollup summary of planting site observations. Null if no observation has been made.") + val summary: PlantingSiteObservationSummaryPayload?, +) : SuccessResponsePayload + data class ScheduleObservationRequestPayload( @Schema( description = diff --git a/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStore.kt b/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStore.kt index 20f41b5932e0..0b4993f22f65 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStore.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStore.kt @@ -26,9 +26,12 @@ import com.terraformation.backend.tracking.model.ObservationMonitoringPlotResult import com.terraformation.backend.tracking.model.ObservationMonitoringPlotStatus import com.terraformation.backend.tracking.model.ObservationPlantingSubzoneResultsModel import com.terraformation.backend.tracking.model.ObservationPlantingZoneResultsModel +import com.terraformation.backend.tracking.model.ObservationPlantingZoneRollupResultsModel import com.terraformation.backend.tracking.model.ObservationResultsModel +import com.terraformation.backend.tracking.model.ObservationRollupResultsModel import com.terraformation.backend.tracking.model.ObservationSpeciesResultsModel import com.terraformation.backend.tracking.model.ObservedPlotCoordinatesModel +import com.terraformation.backend.tracking.model.calculateMortalityRate import com.terraformation.backend.util.SQUARE_METERS_PER_HECTARE import jakarta.inject.Named import kotlin.math.roundToInt @@ -73,6 +76,50 @@ class ObservationResultsStore(private val dslContext: DSLContext) { return fetchByCondition(OBSERVATIONS.plantingSites.ORGANIZATION_ID.eq(organizationId), limit) } + fun fetchSummaryForPlantingSite( + plantingSiteId: PlantingSiteId, + ): ObservationRollupResultsModel? { + val allSubzoneIdsByZoneIds = + dslContext + .select(PLANTING_SUBZONES.ID, PLANTING_SUBZONES.PLANTING_ZONE_ID) + .from(PLANTING_SUBZONES) + .where(PLANTING_SUBZONES.PLANTING_SITE_ID.eq(plantingSiteId)) + .groupBy({ it[PLANTING_SUBZONES.PLANTING_ZONE_ID]!! }, { it[PLANTING_SUBZONES.ID]!! }) + + val zoneAreasById = + dslContext + .select(PLANTING_ZONES.ID, PLANTING_ZONES.AREA_HA) + .from(PLANTING_ZONES) + .where(PLANTING_ZONES.ID.`in`(allSubzoneIdsByZoneIds.keys)) + .associate { it[PLANTING_ZONES.ID]!! to it[PLANTING_ZONES.AREA_HA]!! } + + val observations = fetchByPlantingSiteId(plantingSiteId) + val subzoneCompletedObservations = + observations + .filter { it.completedTime != null } + .flatMap { observation -> observation.plantingZones.flatMap { it.plantingSubzones } } + .groupBy { it.plantingSubzoneId } + + val latestPerSubzone = + subzoneCompletedObservations.mapValues { entry -> entry.value.maxBy { it.completedTime!! } } + + val plantingZoneResults = + allSubzoneIdsByZoneIds + .map { + val zoneId = it.key + + val areaHa = zoneAreasById[zoneId]!! + val subzoneIds = it.value + val subzoneResults = + subzoneIds.associateWith { subzoneId -> latestPerSubzone[subzoneId] } + + zoneId to ObservationPlantingZoneRollupResultsModel.of(areaHa, zoneId, subzoneResults) + } + .toMap() + + return ObservationRollupResultsModel.of(plantingSiteId, plantingZoneResults) + } + private val coordinatesGpsField = OBSERVED_PLOT_COORDINATES.GPS_COORDINATES.forMultiset() private val coordinatesMultiset = @@ -256,7 +303,7 @@ class ObservationResultsStore(private val dslContext: DSLContext) { (it.totalLive + it.totalExisting) > 0 } - val mortalityRate = if (isPermanent) calculateMortalityRate(species) else null + val mortalityRate = if (isPermanent) species.calculateMortalityRate() else null val areaSquareMeters = sizeMeters * sizeMeters val plantingDensity = @@ -297,7 +344,11 @@ class ObservationResultsStore(private val dslContext: DSLContext) { private val plantingSubzoneMultiset = DSL.multiset( - DSL.select(PLANTING_SUBZONES.ID, monitoringPlotMultiset) + DSL.select( + PLANTING_SUBZONES.ID, + PLANTING_SUBZONES.AREA_HA, + PLANTING_SUBZONES.PLANTING_COMPLETED_TIME, + monitoringPlotMultiset) .from(PLANTING_SUBZONES) .where( PLANTING_SUBZONES.ID.`in`( @@ -311,9 +362,53 @@ class ObservationResultsStore(private val dslContext: DSLContext) { .and(PLANTING_SUBZONES.PLANTING_ZONE_ID.eq(PLANTING_ZONES.ID))))) .convertFrom { results -> results.map { record -> + val monitoringPlots = record[monitoringPlotMultiset] + + val areaHa = record[PLANTING_SUBZONES.AREA_HA.asNonNullable()] + + val species = monitoringPlots.flatMap { it.species } + val totalPlants = species.sumOf { it.totalLive + it.totalDead } + + val isCompleted = + monitoringPlots.isNotEmpty() && monitoringPlots.all { it.completedTime != null } + val completedTime = + if (isCompleted) { + monitoringPlots.maxOf { it.completedTime!! } + } else { + null + } + + val mortalityRate = species.calculateMortalityRate() + + val plantingCompleted = record[PLANTING_SUBZONES.PLANTING_COMPLETED_TIME] != null + val plantingDensity = + if (plantingCompleted) { + val plotDensities = monitoringPlots.map { it.plantingDensity } + if (plotDensities.isNotEmpty()) { + plotDensities.average() + } else { + null + } + } else { + null + } + + val estimatedPlants = + if (plantingDensity != null && areaHa != null) { + areaHa.toDouble() * plantingDensity + } else { + null + } ObservationPlantingSubzoneResultsModel( + areaHa = areaHa, + completedTime = completedTime, + estimatedPlants = estimatedPlants?.roundToInt(), monitoringPlots = record[monitoringPlotMultiset], + mortalityRate = mortalityRate, + plantingCompleted = plantingCompleted, + plantingDensity = plantingDensity?.roundToInt(), plantingSubzoneId = record[PLANTING_SUBZONES.ID.asNonNullable()], + totalPlants = totalPlants, ) } } @@ -393,10 +488,11 @@ class ObservationResultsStore(private val dslContext: DSLContext) { null } - val mortalityRate = calculateMortalityRate(species) + val mortalityRate = species.calculateMortalityRate() + val plantingCompleted = record[zonePlantingCompletedField] val plantingDensity = - if (record[zonePlantingCompletedField]) { + if (plantingCompleted) { val plotDensities = subzones.flatMap { subzone -> subzone.monitoringPlots.map { it.plantingDensity } @@ -422,6 +518,7 @@ class ObservationResultsStore(private val dslContext: DSLContext) { completedTime = completedTime, estimatedPlants = estimatedPlants?.roundToInt(), mortalityRate = mortalityRate, + plantingCompleted = plantingCompleted, plantingDensity = plantingDensity?.roundToInt(), plantingSubzones = subzones, plantingZoneId = record[PLANTING_ZONES.ID.asNonNullable()], @@ -471,11 +568,10 @@ class ObservationResultsStore(private val dslContext: DSLContext) { val knownSpecies = species.filter { it.certainty != RecordedSpeciesCertainty.Unknown } val liveSpecies = knownSpecies.filter { it.totalLive > 0 || it.totalExisting > 0 } - var plantingDensity: Int? = null - var estimatedPlants: Int? = null + val plantingCompleted = zones.isNotEmpty() && zones.all { it.plantingCompleted } - if (zones.isNotEmpty() && zones.all { it.plantingDensity != null }) { - plantingDensity = + val plantingDensity = + if (zones.isNotEmpty() && zones.all { it.plantingDensity != null }) { zones .flatMap { zone -> zone.plantingSubzones.flatMap { subzone -> @@ -484,21 +580,27 @@ class ObservationResultsStore(private val dslContext: DSLContext) { } .average() .roundToInt() - } + } else { + null + } - if (zones.isNotEmpty() && zones.all { it.estimatedPlants != null }) { - estimatedPlants = zones.mapNotNull { it.estimatedPlants }.sum() - } + val estimatedPlants = + if (zones.isNotEmpty() && zones.all { it.estimatedPlants != null }) { + zones.mapNotNull { it.estimatedPlants }.sum() + } else { + null + } val totalSpecies = liveSpecies.size - val mortalityRate = calculateMortalityRate(species) + val mortalityRate = species.calculateMortalityRate() ObservationResultsModel( completedTime = record[OBSERVATIONS.COMPLETED_TIME], estimatedPlants = estimatedPlants?.toInt(), mortalityRate = mortalityRate, observationId = record[OBSERVATIONS.ID.asNonNullable()], + plantingCompleted = plantingCompleted, plantingDensity = plantingDensity, plantingSiteId = record[OBSERVATIONS.PLANTING_SITE_ID.asNonNullable()], plantingZones = zones, @@ -509,19 +611,4 @@ class ObservationResultsStore(private val dslContext: DSLContext) { ) } } - - /** - * Calculates the mortality rate across all non-preexisting plants of all species in permanent - * monitoring plots. - */ - private fun calculateMortalityRate(species: List): Int { - val numNonExistingPlants = species.sumOf { it.permanentLive + it.cumulativeDead } - val numDeadPlants = species.sumOf { it.cumulativeDead } - - return if (numNonExistingPlants > 0) { - (numDeadPlants * 100.0 / numNonExistingPlants).roundToInt() - } else { - 0 - } - } } diff --git a/src/main/kotlin/com/terraformation/backend/tracking/model/ObservationResultsModel.kt b/src/main/kotlin/com/terraformation/backend/tracking/model/ObservationResultsModel.kt index e05671e5cf15..614f51f855f1 100644 --- a/src/main/kotlin/com/terraformation/backend/tracking/model/ObservationResultsModel.kt +++ b/src/main/kotlin/com/terraformation/backend/tracking/model/ObservationResultsModel.kt @@ -15,6 +15,7 @@ import com.terraformation.backend.db.tracking.RecordedSpeciesCertainty import java.math.BigDecimal import java.time.Instant import java.time.LocalDate +import kotlin.math.roundToInt import org.locationtech.jts.geom.Point import org.locationtech.jts.geom.Polygon @@ -117,33 +118,61 @@ data class ObservationMonitoringPlotResultsModel( val totalSpecies: Int, ) +/** + * Common values for monitoring results for different kinds of regions (subzones, planting zones, + * planting sites). + */ +interface BaseMonitoringResult { + /** + * Estimated number of plants in the region based on estimated planting density and area. Only + * present if all observed subzones in the region have completed planting. + */ + val estimatedPlants: Int? + + /** + * Percentage of plants of all species that were dead in the region's permanent monitoring plots. + * Dead plants from previous observations are counted in this percentage, but only live plants + * from the current observation are counted. Existing plants are not counted because the intent is + * to track the health of plants that were introduced to the site. + */ + val mortalityRate: Int + + /** + * Whether planting has completed. Planting is considered completed if all subzones in the region + * have completed planting. + */ + val plantingCompleted: Boolean + + /** + * Estimated planting density for the region based on the observed planting densities of + * monitoring plots. Only present if planting is completed. + */ + val plantingDensity: Int? +} + data class ObservationPlantingSubzoneResultsModel( + val areaHa: BigDecimal, + val completedTime: Instant?, + override val estimatedPlants: Int?, + override val mortalityRate: Int, val monitoringPlots: List, + override val plantingCompleted: Boolean, + override val plantingDensity: Int?, val plantingSubzoneId: PlantingSubzoneId, -) + /** + * Total number of plants recorded. Includes all plants, regardless of live/dead status or + * species. + */ + val totalPlants: Int, +) : BaseMonitoringResult data class ObservationPlantingZoneResultsModel( val areaHa: BigDecimal, val completedTime: Instant?, - /** - * Estimated number of plants in planting zone based on estimated planting density and planting - * zone area. Only present if all the subzones in the zone have been marked as having completed - * planting. - */ - val estimatedPlants: Int?, - /** - * Percentage of plants of all species that were dead in this zone's permanent monitoring plots. - * Dead plants from previous observations are counted in this percentage, but only live plants - * from the current observation are counted. Existing plants are not counted because the intent - * is to track the health of plants that were introduced to the site. - */ - val mortalityRate: Int, - /** - * Estimated planting density for the zone based on the observed planting densities of - * monitoring plots. Only present if all the subzones in the zone have been marked as having - * completed planting. - */ - val plantingDensity: Int?, + override val estimatedPlants: Int?, + override val mortalityRate: Int, + override val plantingCompleted: Boolean, + override val plantingDensity: Int?, val plantingSubzones: List, val plantingZoneId: PlantingZoneId, val species: List, @@ -158,34 +187,164 @@ data class ObservationPlantingZoneResultsModel( * as a separate species for purposes of this total. */ val totalSpecies: Int, -) +) : BaseMonitoringResult data class ObservationResultsModel( val completedTime: Instant?, - /** - * Estimated total number of live plants at the site, based on the estimated planting density - * and site size. Only present if all the subzones in the site have been marked as having - * completed planting. - */ - val estimatedPlants: Int?, - /** - * Percentage of plants of all species that were dead in this site's permanent monitoring plots. - * Dead plants from previous observations are counted in this percentage, but only live plants - * from the current observation are counted. Existing plants are not counted because the intent - * is to track the health of plants that were introduced to the site. - */ - val mortalityRate: Int, + override val estimatedPlants: Int?, + override val mortalityRate: Int, val observationId: ObservationId, - /** - * Estimated planting density for the site, based on the observed planting densities of - * monitoring plots. Only present if all the subzones in the site have been marked as having - * completed planting. - */ - val plantingDensity: Int?, + override val plantingCompleted: Boolean, + override val plantingDensity: Int?, val plantingSiteId: PlantingSiteId, val plantingZones: List, val species: List, val startDate: LocalDate, val state: ObservationState, val totalSpecies: Int, -) +) : BaseMonitoringResult + +data class ObservationPlantingZoneRollupResultsModel( + val areaHa: BigDecimal, + /** Time when the earliest observation in this rollup was completed. */ + val earliestCompletedTime: Instant, + override val estimatedPlants: Int?, + /** Time when the latest observation in this rollup was completed. */ + val latestCompletedTime: Instant, + override val mortalityRate: Int, + override val plantingCompleted: Boolean, + override val plantingDensity: Int?, + /** List of subzone observation results used for this rollup */ + val plantingSubzones: List, + val plantingZoneId: PlantingZoneId, + /** + * Total number of plants recorded. Includes all plants, regardless of live/dead status or + * species in this observation summary. + */ + val totalPlants: Int, +) : BaseMonitoringResult { + companion object { + fun of( + areaHa: BigDecimal, + plantingZoneId: PlantingZoneId, + /** Must include every subzone in the planting zone */ + subzoneResults: Map + ): ObservationPlantingZoneRollupResultsModel? { + val nonNullSubzoneResults = subzoneResults.values.filterNotNull() + if (nonNullSubzoneResults.isEmpty()) { + return null + } + + val plantingCompleted = subzoneResults.values.none { it == null || !it.plantingCompleted } + + val monitoringPlots = nonNullSubzoneResults.flatMap { it.monitoringPlots } + val monitoringPlotsSpecies = monitoringPlots.flatMap { it.species } + + val plantingDensity = + if (plantingCompleted) { + monitoringPlots.map { it.plantingDensity }.average().roundToInt() + } else { + null + } + + val estimatedPlants = + if (plantingDensity != null) { + areaHa.toDouble() * plantingDensity + } else { + null + } + + val mortalityRate = monitoringPlotsSpecies.calculateMortalityRate() + + return ObservationPlantingZoneRollupResultsModel( + areaHa = areaHa, + earliestCompletedTime = nonNullSubzoneResults.minOf { it.completedTime!! }, + estimatedPlants = estimatedPlants?.roundToInt(), + latestCompletedTime = nonNullSubzoneResults.maxOf { it.completedTime!! }, + mortalityRate = mortalityRate, + plantingCompleted = plantingCompleted, + plantingDensity = plantingDensity, + plantingSubzones = nonNullSubzoneResults, + plantingZoneId = plantingZoneId, + totalPlants = monitoringPlotsSpecies.sumOf { it.totalLive + it.totalDead }, + ) + } + } +} + +data class ObservationRollupResultsModel( + /** Time when the earliest observation in this rollup was completed. */ + val earliestCompletedTime: Instant, + override val estimatedPlants: Int?, + /** Time when the latest observation in this rollup was completed. */ + val latestCompletedTime: Instant, + override val mortalityRate: Int, + override val plantingCompleted: Boolean, + override val plantingDensity: Int?, + val plantingSiteId: PlantingSiteId, + /** List of subzone observation results used for this rollup */ + val plantingZones: List, +) : BaseMonitoringResult { + companion object { + fun of( + plantingSiteId: PlantingSiteId, + /** Must include every zone in the planting site */ + zoneResults: Map + ): ObservationRollupResultsModel? { + val nonNullZoneResults = zoneResults.values.filterNotNull() + if (nonNullZoneResults.isEmpty()) { + return null + } + + val plantingCompleted = zoneResults.values.none { it == null || !it.plantingCompleted } + + val monitoringPlots = + nonNullZoneResults.flatMap { zone -> + zone.plantingSubzones.flatMap { it?.monitoringPlots ?: emptyList() } + } + val monitoringPlotsSpecies = monitoringPlots.flatMap { it.species } + + val plantingDensity = + if (plantingCompleted) { + monitoringPlots.map { it.plantingDensity }.average().roundToInt() + } else { + null + } + + val estimatedPlants = + if (plantingDensity != null) { + nonNullZoneResults.sumOf { it.estimatedPlants ?: 0 } + } else { + null + } + + val mortalityRate = monitoringPlotsSpecies.calculateMortalityRate() + + return ObservationRollupResultsModel( + earliestCompletedTime = nonNullZoneResults.minOf { it.earliestCompletedTime }, + estimatedPlants = estimatedPlants, + latestCompletedTime = nonNullZoneResults.maxOf { it.latestCompletedTime }, + mortalityRate = mortalityRate, + plantingCompleted = plantingCompleted, + plantingDensity = plantingDensity, + plantingSiteId = plantingSiteId, + plantingZones = nonNullZoneResults, + ) + } + } +} + +/** + * Calculates the mortality rate across all non-preexisting plants of all species in permanent + * monitoring plots. + */ +fun List.calculateMortalityRate(): Int { + val numNonExistingPlants = this.sumOf { it.permanentLive + it.cumulativeDead } + val numDeadPlants = this.sumOf { it.cumulativeDead } + + return if (numNonExistingPlants > 0) { + (numDeadPlants * 100.0 / numNonExistingPlants).roundToInt() + } else { + 0 + } +} diff --git a/src/test/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStoreTest.kt b/src/test/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStoreTest.kt index 9c88efd48c4f..342185cab957 100644 --- a/src/test/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStoreTest.kt +++ b/src/test/kotlin/com/terraformation/backend/tracking/db/ObservationResultsStoreTest.kt @@ -20,6 +20,7 @@ import com.terraformation.backend.mockUser import com.terraformation.backend.point import com.terraformation.backend.tracking.model.ObservationMonitoringPlotPhotoModel import com.terraformation.backend.tracking.model.ObservationResultsModel +import com.terraformation.backend.tracking.model.ObservationRollupResultsModel import com.terraformation.backend.tracking.model.ObservationSpeciesResultsModel import com.terraformation.backend.tracking.model.ObservedPlotCoordinatesModel import io.ktor.utils.io.core.use @@ -29,6 +30,7 @@ import java.math.BigDecimal import java.nio.file.NoSuchFileException import java.time.Instant import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Nested import org.junit.jupiter.api.Test @@ -237,6 +239,15 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { "/tracking/observation/PermanentPlotChanges", numObservations = 3, sizeMeters = 25) } + @Test + fun `fetch observation summary`() { + runSummaryScenario( + "/tracking/observation/ObservationsSummary", + numObservations = 3, + numSpecies = 3, + sizeMeters = 25) + } + private fun runScenario(prefix: String, numObservations: Int, sizeMeters: Int) { importFromCsvFiles(prefix, numObservations, sizeMeters) val allResults = @@ -244,6 +255,31 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { assertResults(prefix, allResults) } + private fun runSummaryScenario( + prefix: String, + numObservations: Int, + numSpecies: Int, + sizeMeters: Int, + ) { + importSiteFromCsvFile(prefix, sizeMeters) + val summaryBeforeObservations = resultsStore.fetchSummaryForPlantingSite(plantingSiteId) + val summaries = + List(numObservations) { + importObservationsCsv(prefix, numSpecies, it) + resultsStore.fetchSummaryForPlantingSite(plantingSiteId)!! + } + assertNull(summaryBeforeObservations, "No observations made yet.") + assertSummary(prefix, summaries) + } + + private fun assertSummary(prefix: String, results: List) { + assertAll( + { assertSiteSummary(prefix, results) }, + { assertZoneSummary(prefix, results) }, + { assertPlotSummary(prefix, results) }, + ) + } + private fun assertResults(prefix: String, allResults: List) { assertAll( { assertSiteResults(prefix, allResults) }, @@ -269,6 +305,25 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { assertResultsMatchCsv("$prefix/SiteStats.csv", actual) } + private fun assertSiteSummary(prefix: String, allResults: List) { + val actual = + makeActualCsv(allResults, listOf(emptyList())) { _, results -> + listOf( + results.plantingDensity.toStringOrBlank(), + results.estimatedPlants.toStringOrBlank(), + results.mortalityRate.toStringOrBlank("%"), + ) + } + + assertResultsMatchCsv("$prefix/SiteStats.csv", actual) { row -> + row.filterIndexed { index, _ -> + val positionInColumnGroup = index % 4 + // Filtered out total number of species until that is computed and added to summaries + positionInColumnGroup != 2 + } + } + } + private fun assertZoneResults(prefix: String, allResults: List) { val rowKeys = zoneIds.keys.map { listOf(it) } @@ -287,6 +342,30 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { assertResultsMatchCsv("$prefix/ZoneStats.csv", actual) } + private fun assertZoneSummary(prefix: String, allResults: List) { + val rowKeys = zoneIds.keys.map { listOf(it) } + + val actual = + makeActualCsv(allResults, rowKeys) { (zoneName), results -> + val zone = + results.plantingZones.firstOrNull() { it.plantingZoneId == zoneIds[zoneName] } + listOf( + zone?.totalPlants.toStringOrBlank(), + zone?.plantingDensity.toStringOrBlank(), + zone?.mortalityRate.toStringOrBlank("%"), + zone?.estimatedPlants.toStringOrBlank(), + ) + } + + assertResultsMatchCsv("$prefix/ZoneStats.csv", actual) { row -> + row.filterIndexed { index, _ -> + val positionInColumnGroup = (index - 1) % 5 + // Filtered out total number of species until that is computed and added to summaries + positionInColumnGroup != 2 + } + } + } + private fun assertPlotResults(prefix: String, allResults: List) { val rowKeys = plotIds.keys.map { listOf(it) } @@ -317,6 +396,36 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { } } + private fun assertPlotSummary(prefix: String, allResults: List) { + val rowKeys = plotIds.keys.map { listOf(it) } + + val actual = + makeActualCsv(allResults, rowKeys) { (plotName), results -> + results.plantingZones + .flatMap { zone -> zone.plantingSubzones } + .flatMap { subzone -> subzone.monitoringPlots } + .firstOrNull { it.monitoringPlotName == plotName } + ?.let { plot -> + listOf( + plot.totalPlants.toStringOrBlank(), + plot.totalSpecies.toStringOrBlank(), + plot.mortalityRate.toStringOrBlank("%"), + // Live and existing plants columns are in spreadsheet but not included in + // calculated + // results; it will be removed by the filter function below. + plot.plantingDensity.toStringOrBlank(), + ) + } ?: listOf("", "", "", "") + } + + assertResultsMatchCsv("$prefix/PlotStats.csv", actual) { row -> + row.filterIndexed { index, _ -> + val positionInColumnGroup = (index - 1) % 7 + positionInColumnGroup != 3 && positionInColumnGroup != 4 && positionInColumnGroup != 5 + } + } + } + private fun assertSiteSpeciesResults( prefix: String, allResults: List @@ -393,10 +502,14 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { } private fun importFromCsvFiles(prefix: String, numObservations: Int, sizeMeters: Int) { + importSiteFromCsvFile(prefix, sizeMeters) + importPlantsCsv(prefix, numObservations) + } + + private fun importSiteFromCsvFile(prefix: String, sizeMeters: Int) { zoneIds = importZonesCsv(prefix) subzoneIds = importSubzonesCsv(prefix) plotIds = importPlotsCsv(prefix, sizeMeters) - importPlantsCsv(prefix, numObservations) } private fun importZonesCsv(prefix: String): Map { @@ -524,6 +637,175 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { } } + /** Imports plants based on bulk observation numbers. */ + private fun importObservationsCsv(prefix: String, numSpecies: Int, observationNum: Int) { + clock.instant = Instant.ofEpochSecond(observationNum.toLong()) + + val observationId = insertObservation() + val observedPlotNames = mutableSetOf() + + val speciesIds = + List(numSpecies) { + speciesIds.computeIfAbsent("Species $it") { _ -> + insertSpecies(scientificName = "Species $it") + } + } + + val plantsRows = + mapCsv("$prefix/Observation-${observationNum+1}.csv", 2) { cols -> + val plotName = cols[0] + val plotId = plotIds[plotName]!! + + val knownPlantsRows = + (0.. + val existingNum = cols[1 + 3 * speciesNum].toIntOrNull() + val liveNum = cols[2 + 3 * speciesNum].toIntOrNull() + val deadNum = cols[3 + 3 * speciesNum].toIntOrNull() + + if (existingNum == null || liveNum == null || deadNum == null) { + // No observation made for this plot if any grid is empty + return@mapCsv emptyList() + } + + val existingRows = + List(existingNum) { _ -> + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Known, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = speciesIds[speciesNum], + speciesName = null, + statusId = RecordedPlantStatus.Existing, + ) + } + + val liveRows = + List(liveNum) { _ -> + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Known, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = speciesIds[speciesNum], + speciesName = null, + statusId = RecordedPlantStatus.Live, + ) + } + + val deadRows = + List(deadNum) { + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Known, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = speciesIds[speciesNum], + speciesName = null, + statusId = RecordedPlantStatus.Dead, + ) + } + + listOf(existingRows, liveRows, deadRows).flatten() + } + .flatten() + + val unknownLiveNum = cols[1 + 3 * numSpecies].toIntOrNull() ?: 0 + val unknownDeadNum = cols[2 + 3 * numSpecies].toIntOrNull() ?: 0 + + val otherLiveNum = cols[3 + 3 * numSpecies].toIntOrNull() ?: 0 + val otherDeadNum = cols[4 + 3 * numSpecies].toIntOrNull() ?: 0 + + if (otherLiveNum + otherDeadNum > 0 && !allSpeciesNames.contains("Other")) { + allSpeciesNames.add("Other") + } + + val unknownLivePlantsRows = + List(unknownLiveNum) { + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Unknown, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = null, + speciesName = null, + statusId = RecordedPlantStatus.Live, + ) + } + val unknownDeadPlantsRows = + List(unknownDeadNum) { + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Unknown, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = null, + speciesName = null, + statusId = RecordedPlantStatus.Dead, + ) + } + + val otherLivePlantsRows = + List(otherLiveNum) { + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Other, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = null, + speciesName = "Other", + statusId = RecordedPlantStatus.Live, + ) + } + val otherDeadPlantsRows = + List(otherDeadNum) { + RecordedPlantsRow( + certaintyId = RecordedSpeciesCertainty.Other, + gpsCoordinates = point(1), + observationId = observationId, + monitoringPlotId = plotId, + speciesId = null, + speciesName = "Other", + statusId = RecordedPlantStatus.Dead, + ) + } + + if (plotName !in observedPlotNames) { + insertObservationPlot( + claimedBy = user.userId, + claimedTime = Instant.EPOCH, + isPermanent = plotName in permanentPlotNames, + observationId = observationId, + monitoringPlotId = plotId, + ) + + observedPlotNames.add(plotName) + } + + listOf( + knownPlantsRows, + unknownLivePlantsRows, + unknownDeadPlantsRows, + otherLivePlantsRows, + otherDeadPlantsRows, + ) + .flatten() + } + .flatten() + + // This would normally happen in ObservationService.startObservation after plot selection; + // do it explicitly since we're specifying our own plots in the test data. + observationStore.populateCumulativeDead(observationId) + + plantsRows + .groupBy { it.monitoringPlotId!! } + .forEach { (plotId, plants) -> + observationStore.completePlot( + observationId, plotId, emptySet(), "Notes", Instant.EPOCH, plants) + } + } + /** Maps each data row of a CSV to a value. */ private fun mapCsv(path: String, skipRows: Int = 1, func: (Array) -> T): List { val stream = javaClass.getResourceAsStream(path) ?: throw NoSuchFileException(path) @@ -561,10 +843,10 @@ class ObservationResultsStoreTest : DatabaseTest(), RunsAsUser { * e.g., it's a "per zone per species" CSV and a particular species wasn't present in a * particular zone, the row is not included in the generated CSV. */ - private fun makeActualCsv( - allResults: List, + private fun makeActualCsv( + allResults: List, rowKeys: List>, - columnsFromResult: (List, ObservationResultsModel) -> List + columnsFromResult: (List, T) -> List ): List> { return rowKeys.mapNotNull { initialRow -> val dataColumns = allResults.flatMap { results -> columnsFromResult(initialRow, results) } diff --git a/src/test/resources/tracking/observation/ObservationsSummary/Observation-1.csv b/src/test/resources/tracking/observation/ObservationsSummary/Observation-1.csv new file mode 100644 index 000000000000..e1dfe1073f85 --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/Observation-1.csv @@ -0,0 +1,20 @@ +Plot,Species A,,,Species B,,,Species C,,,Unknown,,Other,,Summary,,, +,Existing,Live,Dead,Existing,Live,Dead,Existing,Live,Dead,Live,Dead,Live,Dead,Total,Total Existing,Total Live,Total Dead +Alpha-1-1,16,11,2,15,18,0,16,24,3,9,0,10,6,130,47,72,11 +Alpha-1-2,18,2,3,17,16,2,18,14,1,0,4,10,9,114,53,42,19 +Alpha-1-3,20,1,1,16,11,1,14,7,2,1,6,5,10,95,50,25,20 +Beta-1-1,17,28,4,16,14,2,18,29,4,3,5,5,3,148,51,79,18 +Beta-1-2,19,21,1,17,20,3,15,2,1,8,3,8,6,124,51,59,14 +Beta-1-3,17,23,2,17,2,1,15,6,2,10,5,10,4,114,49,51,14 +Beta-2-1,0,0,0,0,0,0,19,10,3,7,8,4,10,61,19,21,21 +Beta-2-2,20,3,3,13,17,1,0,0,0,2,8,10,2,79,33,32,14 +Beta-2-3,17,23,1,16,2,2,0,0,0,9,3,5,3,81,33,39,9 +Beta-2-4,19,21,2,13,4,1,18,15,2,7,3,9,9,123,50,56,17 +Charlie-1-1,,,,,,,,,,,,,,0,0,0,0 +Charlie-1-2,,,,,,,,,,,,,,0,0,0,0 +Charlie-1-3,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-1,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-2,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-3,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-4,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-5,,,,,,,,,,,,,,0,0,0,0 \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/Observation-2.csv b/src/test/resources/tracking/observation/ObservationsSummary/Observation-2.csv new file mode 100644 index 000000000000..cd259804f30a --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/Observation-2.csv @@ -0,0 +1,20 @@ +Plot,Species A,,,Species B,,,Species C,,,Unknown,,Other,,Summary,,, +,Existing,Live,Dead,Existing,Live,Dead,Existing,Live,Dead,Live,Dead,Live,Dead,Total,Total Existing,Total Live,Total Dead +Alpha-1-1,,,,,,,,,,,,,,0,0,0,0 +Alpha-1-2,,,,,,,,,,,,,,0,0,0,0 +Alpha-1-3,,,,,,,,,,,,,,0,0,0,0 +Beta-1-1,,,,,,,,,,,,,,0,0,0,0 +Beta-1-2,,,,,,,,,,,,,,0,0,0,0 +Beta-1-3,,,,,,,,,,,,,,0,0,0,0 +Beta-2-1,,,,,,,,,,,,,,0,0,0,0 +Beta-2-2,,,,,,,,,,,,,,0,0,0,0 +Beta-2-3,,,,,,,,,,,,,,0,0,0,0 +Beta-2-4,,,,,,,,,,,,,,0,0,0,0 +Charlie-1-1,37,8,10,28,30,7,27,41,2,4,6,6,9,215,92,89,34 +Charlie-1-2,32,28,9,31,1,5,32,9,2,5,3,0,1,158,95,43,20 +Charlie-1-3,36,55,8,30,0,2,29,17,0,3,6,1,1,188,95,76,17 +Charlie-2-1,32,44,1,34,4,7,29,9,0,5,4,9,8,186,95,71,20 +Charlie-2-2,39,17,8,32,20,3,34,27,9,3,1,10,0,203,105,77,21 +Charlie-2-3,39,46,7,34,27,4,29,32,6,6,7,1,7,245,102,112,31 +Charlie-2-4,31,48,7,31,40,0,32,37,4,4,0,2,7,243,94,131,18 +Charlie-2-5,37,33,1,34,28,1,34,40,5,2,8,7,6,236,105,110,21 \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/Observation-3.csv b/src/test/resources/tracking/observation/ObservationsSummary/Observation-3.csv new file mode 100644 index 000000000000..5103085fb358 --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/Observation-3.csv @@ -0,0 +1,20 @@ +Plot,Species A,,,Species B,,,Species C,,,Unknown,,Other,,Summary,,, +,Existing,Live,Dead,Existing,Live,Dead,Existing,Live,Dead,Live,Dead,Live,Dead,Total,Total Existing,Total Live,Total Dead +Alpha-1-1,57,52,11,49,16,9,42,61,8,4,8,5,0,322,148,138,36 +Alpha-1-2,47,48,6,44,18,10,45,45,4,6,1,10,0,284,136,127,21 +Alpha-1-3,48,58,10,40,37,3,55,70,15,0,10,7,2,355,143,172,40 +Beta-1-1,47,64,12,44,31,5,38,48,10,10,4,2,9,324,129,155,40 +Beta-1-2,45,68,9,59,51,7,52,23,0,3,10,10,10,347,156,155,36 +Beta-1-3,54,64,9,52,61,7,50,75,0,3,5,9,0,389,156,212,21 +Beta-2-1,,,,,,,,,,,,,,0,0,0,0 +Beta-2-2,,,,,,,,,,,,,,0,0,0,0 +Beta-2-3,,,,,,,,,,,,,,0,0,0,0 +Beta-2-4,,,,,,,,,,,,,,0,0,0,0 +Charlie-1-1,,,,,,,,,,,,,,0,0,0,0 +Charlie-1-2,,,,,,,,,,,,,,0,0,0,0 +Charlie-1-3,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-1,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-2,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-3,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-4,,,,,,,,,,,,,,0,0,0,0 +Charlie-2-5,,,,,,,,,,,,,,0,0,0,0 \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/PlotStats.csv b/src/test/resources/tracking/observation/ObservationsSummary/PlotStats.csv new file mode 100644 index 000000000000..5b8d6d059571 --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/PlotStats.csv @@ -0,0 +1,20 @@ +Observation ->,1,1,1,1,1,1,1,2,2,2,2,2,2,2,3,3,3,3,3,3,3 +Plot,# Plants,# Species,Mortality Rate,Dead Plants,Live Plants,Existing Plants,Planting Density,# Plants,# Species,Mortality Rate,Dead Plants,Live Plants,Existing Plants,Planting Density,# Plants,# Species,Mortality Rate,Dead Plants,Live Plants,Existing Plants,Planting Density +Alpha-1-1,130,4,13%,11,72,47,1152,130,4,13%,11,72,47,1152,322,4,25%,47,138,148,2208 +Alpha-1-2,114,4,31%,19,42,53,672,114,4,31%,19,42,53,672,284,4,24%,40,127,136,2032 +Alpha-1-3,95,4,44%,20,25,50,400,95,4,44%,20,25,50,400,355,4,26%,60,172,143,2752 +Beta-1-1,148,4,19%,18,79,51,1264,148,4,19%,18,79,51,1264,324,4,27%,58,155,129,2480 +Beta-1-2,124,4,19%,14,59,51,944,124,4,19%,14,59,51,944,347,4,24%,50,155,156,2480 +Beta-1-3,114,4,22%,14,51,49,816,114,4,22%,14,51,49,816,389,4,14%,35,212,156,3392 +Beta-2-1,61,2,50%,21,21,19,336,61,2,50%,21,21,19,336,61,2,50%,21,21,19,336 +Beta-2-2,79,3,30%,14,32,33,512,79,3,30%,14,32,33,512,79,3,30%,14,32,33,512 +Beta-2-3,81,3,19%,9,39,33,624,81,3,19%,9,39,33,624,81,3,19%,9,39,33,624 +Beta-2-4,123,4,23%,17,56,50,896,123,4,23%,17,56,50,896,123,4,23%,17,56,50,896 +Charlie-1-1,,,,,,,,215,4,28%,34,89,92,1424,215,4,28%,34,89,92,1424 +Charlie-1-2,,,,,,,,158,3,32%,20,43,95,688,158,3,32%,20,43,95,688 +Charlie-1-3,,,,,,,,188,4,18%,17,76,95,1216,188,4,18%,17,76,95,1216 +Charlie-2-1,,,,,,,,186,4,22%,20,71,95,1136,186,4,22%,20,71,95,1136 +Charlie-2-2,,,,,,,,203,4,21%,21,77,105,1232,203,4,21%,21,77,105,1232 +Charlie-2-3,,,,,,,,245,4,22%,31,112,102,1792,245,4,22%,31,112,102,1792 +Charlie-2-4,,,,,,,,243,4,12%,18,131,94,2096,243,4,12%,18,131,94,2096 +Charlie-2-5,,,,,,,,236,4,16%,21,110,105,1760,236,4,16%,21,110,105,1760 \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/Plots.csv b/src/test/resources/tracking/observation/ObservationsSummary/Plots.csv new file mode 100644 index 000000000000..45373759d1c5 --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/Plots.csv @@ -0,0 +1,19 @@ +Subzone,Name,Type,Area (ha) +Alpha-1,Alpha-1-1,Permanent,0.0625 +Alpha-1,Alpha-1-2,Permanent,0.0625 +Alpha-1,Alpha-1-3,Permanent,0.0625 +Beta-1,Beta-1-1,Permanent,0.0625 +Beta-1,Beta-1-2,Permanent,0.0625 +Beta-1,Beta-1-3,Permanent,0.0625 +Beta-2,Beta-2-1,Permanent,0.0625 +Beta-2,Beta-2-2,Permanent,0.0625 +Beta-2,Beta-2-3,Permanent,0.0625 +Beta-2,Beta-2-4,Permanent,0.0625 +Charlie-1,Charlie-1-1,Permanent,0.0625 +Charlie-1,Charlie-1-2,Permanent,0.0625 +Charlie-1,Charlie-1-3,Permanent,0.0625 +Charlie-2,Charlie-2-1,Permanent,0.0625 +Charlie-2,Charlie-2-2,Permanent,0.0625 +Charlie-2,Charlie-2-3,Permanent,0.0625 +Charlie-2,Charlie-2-4,Permanent,0.0625 +Charlie-2,Charlie-2-5,Permanent,0.0625 \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/SiteStats.csv b/src/test/resources/tracking/observation/ObservationsSummary/SiteStats.csv new file mode 100644 index 000000000000..04f161613de1 --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/SiteStats.csv @@ -0,0 +1,3 @@ +1,1,1,1,2,2,2,2,3,3,3,3 +Planting Density,Estimated # Plants,# Species,Mortality Rate,Planting Density,Estimated # Plants,# Species,Mortality Rate,Planting Density,Estimated # Plants,# Species,Mortality Rate +,,4,25%,1053,2220000,4,22%,1614,4571000,4,23% \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/Subzones.csv b/src/test/resources/tracking/observation/ObservationsSummary/Subzones.csv new file mode 100644 index 000000000000..25211f6d87cd --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/Subzones.csv @@ -0,0 +1,7 @@ +Observation ->,,,1,2 +Zone,Name,Area (ha),Finished planting?,Finished planting? +Alpha,Alpha-1,500,Yes,Yes +Beta,Beta-1,750,Yes,Yes +Beta,Beta-2,250,Yes,Yes +Charlie,Charlie-1,500,Yes,Yes +Charlie,Charlie-2,500,Yes,Yes \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/ZoneStats.csv b/src/test/resources/tracking/observation/ObservationsSummary/ZoneStats.csv new file mode 100644 index 000000000000..c21382ce2656 --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/ZoneStats.csv @@ -0,0 +1,5 @@ +Observation ->,1,1,1,1,1,2,2,2,2,2,3,3,3,3,3 +Zone,# Plants (excluding existing),Planting Density,# Live Species,Mortality Rate,Est Plants,# Plants (excluding existing),Planting Density,# Live Species,Mortality Rate,Est Plants,# Plants (excluding existing),Planting Density,# Live Species,Mortality Rate,Est Plants +Alpha,189,741,4,26%,741000,189,741,4,26%,741000,534,2331,4,25%,2331000 +Beta,444,770,4,24%,770000,444,770,4,24%,770000,828,1531,4,23%,1531000 +Charlie,,,,,,891,1418,4,20%,709000,891,1418,4,20%,709000 \ No newline at end of file diff --git a/src/test/resources/tracking/observation/ObservationsSummary/Zones.csv b/src/test/resources/tracking/observation/ObservationsSummary/Zones.csv new file mode 100644 index 000000000000..91648896e6fd --- /dev/null +++ b/src/test/resources/tracking/observation/ObservationsSummary/Zones.csv @@ -0,0 +1,5 @@ +Observation ->,, +Site,Name,Area (ha) +Demo Site,Alpha,1000 +Demo Site,Beta,1000 +Demo Site,Charlie,500 \ No newline at end of file