diff --git a/backend/migrations/2023-07-03-165000_heatmap/down.sql b/backend/migrations/2023-07-03-165000_heatmap/down.sql new file mode 100644 index 000000000..0ea121f29 --- /dev/null +++ b/backend/migrations/2023-07-03-165000_heatmap/down.sql @@ -0,0 +1,8 @@ +-- This file should undo anything in `up.sql` +DROP FUNCTION get_plant_relations; +DROP FUNCTION calculate_score_from_relations; +DROP FUNCTION calculate_score; +DROP FUNCTION scale_score; +DROP FUNCTION calculate_heatmap; +DROP TYPE SCORE; +DROP FUNCTION calculate_bbox; diff --git a/backend/migrations/2023-07-03-165000_heatmap/up.sql b/backend/migrations/2023-07-03-165000_heatmap/up.sql new file mode 100644 index 000000000..c0a65f549 --- /dev/null +++ b/backend/migrations/2023-07-03-165000_heatmap/up.sql @@ -0,0 +1,238 @@ +-- Your SQL goes here + +-- Calculate the bounding box of the map geometry. +CREATE OR REPLACE FUNCTION calculate_bbox(map_id INTEGER) +RETURNS TABLE (x_min INTEGER, y_min INTEGER, x_max INTEGER, y_max INTEGER) AS $$ +BEGIN + RETURN QUERY + SELECT + CAST(floor(ST_XMin(bbox)) AS INTEGER) AS x_min, + CAST(floor(ST_YMin(bbox)) AS INTEGER) AS y_min, + CAST(ceil(ST_XMax(bbox)) AS INTEGER) AS x_max, + CAST(ceil(ST_YMax(bbox)) AS INTEGER) AS y_max + FROM ( + SELECT + ST_Envelope(geometry) AS bbox + FROM maps + WHERE id = map_id + ) AS subquery; +END; +$$ LANGUAGE plpgsql; + +-- The score is defined as the preference and the relevance. +CREATE TYPE score AS ( + preference REAL, + relevance REAL +); + +-- Returns preference from 0-1 and relevance from 0-1 for each pixel of the map. +-- +-- Positions where the plant should not be placed have preference close to 0. +-- Positions where the plant should be placed have preference close to 1. +-- +-- Positions where there is no relevant data have relevance close to 0. +-- Positions where there is relevant data have relevance close to 1. +-- +-- The resulting matrix does not contain valid (x,y) map coordinates, +-- instead (x,y) are simply the indices in the matrix. +-- The (x,y) coordinate of the computed heatmap always starts at +-- (0,0) no matter the boundaries of the map. +-- To get valid coordinates the user would therefore need to move and scale the +-- calculated heatmap by taking into account the boundaries of the map. +-- +-- View the API documentation via Swagger for additional information. +-- +-- p_map_id ... map id +-- p_layer_ids ... ids of the layers +-- p_plant_id ... id of the plant for which to consider relations +-- date ... date at which to generate the heatmap +-- granularity ... resolution of the map (must be greater than 0) +-- x_min,y_min,x_max,y_max ... boundaries of the map +CREATE OR REPLACE FUNCTION calculate_heatmap( + p_map_id INTEGER, + p_layer_ids INTEGER [], + p_plant_id INTEGER, + date DATE, + granularity INTEGER, + x_min INTEGER, + y_min INTEGER, + x_max INTEGER, + y_max INTEGER +) +RETURNS TABLE (preference REAL, relevance REAL, x INTEGER, y INTEGER) AS $$ +DECLARE + score SCORE; + map_geometry GEOMETRY(POLYGON, 4326); + point GEOMETRY; + bbox GEOMETRY; + num_cols INTEGER; + num_rows INTEGER; + x_pos INTEGER; + y_pos INTEGER; + plant_relation RECORD; +BEGIN + -- Makes sure the layers exists and fits to the map + FOR i IN 1..array_length(p_layer_ids, 1) LOOP + IF NOT EXISTS (SELECT 1 FROM layers WHERE id = p_layer_ids[i] AND map_id = p_map_id) THEN + RAISE EXCEPTION 'Layer with id % not found on map with id %', p_layer_ids[i], p_map_id; + END IF; + END LOOP; + -- Makes sure the plant exists + IF NOT EXISTS (SELECT 1 FROM plants WHERE id = p_plant_id) THEN + RAISE EXCEPTION 'Plant with id % not found', p_plant_id; + END IF; + + -- INTO STRICT makes sure the map exists. Does have to be explicitly checked as bounding box calculation would error anyways. + SELECT geometry FROM maps WHERE id = p_map_id INTO STRICT map_geometry; + + -- Calculate the number of rows and columns based on the map's size and granularity + num_cols := FLOOR((x_max - x_min) / granularity); -- Adjusted for granularity + num_rows := FLOOR((y_max - y_min) / granularity); -- Adjusted for granularity + + -- Calculate the score for each point on the heatmap + FOR i IN 0..num_cols-1 LOOP + -- i and j do not represent coordinates. We need to adjust them to actual coordinates. + x_pos := x_min + (i * granularity) + (granularity / 2); + + FOR j IN 0..num_rows-1 LOOP + y_pos := y_min + (j * granularity) + (granularity / 2); + + -- Create a point from x_pos and y_pos + point := ST_SetSRID(ST_MakePoint(x_pos, y_pos), 4326); + + -- If the point is on the map calculate a score; otherwise set score to 0. + IF ST_Intersects(point, map_geometry) THEN + score := calculate_score(p_map_id, p_layer_ids, p_plant_id, date, x_pos, y_pos); + score := scale_score(score); -- scale to be between 0 and 1 + preference := score.preference; + relevance := score.relevance; + ELSE + preference := 0.0; + relevance := 0.0; + END IF; + + x := i; + y := j; + + RETURN NEXT; + END LOOP; + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- Scales to values between 0 and 1. +-- +-- Preference input space: Any value. +-- Relevance input space: >=0 +CREATE OR REPLACE FUNCTION scale_score(input SCORE) +RETURNS SCORE AS $$ +DECLARE + score SCORE; +BEGIN + score.preference := 1 / (1 + exp(-input.preference)); -- standard sigmoid, so that f(0)=0.5 + score.relevance := (2 / (1 + exp(-input.relevance))) - 1; -- modified sigmoid, so that f(0)=0 + RETURN score; +END; +$$ LANGUAGE plpgsql; + +-- Calculate score for a certain position. +-- +-- p_map_id ... map id +-- p_layer_ids[1] ... plant layer (only the first array-element is used by the function) +-- p_plant_id ... id of the plant for which to consider relations +-- date ... date at which to generate the heatmap +-- x_pos,y_pos ... coordinates on the map where to calculate the score +CREATE OR REPLACE FUNCTION calculate_score( + p_map_id INTEGER, + p_layer_ids INTEGER [], + p_plant_id INTEGER, + date DATE, + x_pos INTEGER, + y_pos INTEGER +) +RETURNS SCORE AS $$ +DECLARE + plants SCORE; +BEGIN + plants := calculate_score_from_relations(p_layer_ids[1], p_plant_id, date, x_pos, y_pos); + + RETURN plants; +END; +$$ LANGUAGE plpgsql; + +-- Calculate score using the plants relations and their distances. +CREATE OR REPLACE FUNCTION calculate_score_from_relations( + p_layer_id INTEGER, + p_plant_id INTEGER, + date DATE, + x_pos INTEGER, + y_pos INTEGER +) +RETURNS SCORE AS $$ +DECLARE + plant_relation RECORD; + distance REAL; + weight REAL; + score SCORE; +BEGIN + IF NOT EXISTS (SELECT 1 FROM layers WHERE id = p_layer_id AND type = 'plants') THEN + RAISE EXCEPTION 'Plant layer with id % not found', p_layer_id; + END IF; + + score.preference := 0.0; + score.relevance := 0.0; + + FOR plant_relation IN (SELECT * FROM get_plant_relations(p_layer_id, p_plant_id, date)) LOOP + -- calculate euclidean distance + distance := sqrt((plant_relation.x - x_pos)^2 + (plant_relation.y - y_pos)^2); + + -- calculate weight based on distance + -- weight decreases between 1 and 0 based on distance + -- distance is squared so it decreases faster the further away + -- weight is halved at 50 cm away + weight := 1 / (1 + (distance / 50)^2); + + -- update score based on relation + IF plant_relation.relation = 'companion' THEN + score.preference := score.preference + 0.5 * weight; + ELSE + score.preference := score.preference - 0.5 * weight; + END IF; + + score.relevance := score.relevance + 0.5 * weight; + END LOOP; + + RETURN score; +END; +$$ LANGUAGE plpgsql; + +-- Get all relations for the plant on the specified layer. +CREATE OR REPLACE FUNCTION get_plant_relations( + p_layer_id INTEGER, + p_plant_id INTEGER, + date DATE +) +RETURNS TABLE (x INTEGER, y INTEGER, relation RELATION_TYPE) AS $$ +BEGIN + RETURN QUERY + -- We only need x,y and type of relation to calculate a score. + SELECT plantings.x, plantings.y, relations.relation + FROM plantings + JOIN plants ON plantings.plant_id = plants.id + JOIN ( + -- We need UNION as the relation is bidirectional. + SELECT plant1 AS plant, r1.relation + FROM relations r1 + WHERE plant2 = p_plant_id + AND r1.relation != 'neutral' + UNION + SELECT plant2 AS plant, r2.relation + FROM relations r2 + WHERE plant1 = p_plant_id + AND r2.relation != 'neutral' + ) relations ON plants.id = relations.plant + WHERE plantings.layer_id = p_layer_id + AND (plantings.add_date IS NULL OR plantings.add_date <= date) + AND (plantings.remove_date IS NULL OR plantings.remove_date > date); +END; +$$ LANGUAGE plpgsql; diff --git a/backend/migrations/2023-07-22-110000_shadings/down.sql b/backend/migrations/2023-07-22-110000_shadings/down.sql new file mode 100644 index 000000000..7dbf48a1d --- /dev/null +++ b/backend/migrations/2023-07-22-110000_shadings/down.sql @@ -0,0 +1,24 @@ +-- This file should undo anything in `up.sql` +DROP FUNCTION calculate_score_from_shadings; + +CREATE OR REPLACE FUNCTION calculate_score( + p_map_id INTEGER, + p_layer_ids INTEGER [], + p_plant_id INTEGER, + date DATE, + x_pos INTEGER, + y_pos INTEGER +) +RETURNS SCORE AS $$ +DECLARE + plants SCORE; +BEGIN + plants := calculate_score_from_relations(p_layer_ids[1], p_plant_id, date, x_pos, y_pos); + + RETURN plants; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER check_shade_layer_type_before_insert_or_update ON shadings; +DROP FUNCTION check_shade_layer_type; +DROP TABLE shadings; diff --git a/backend/migrations/2023-07-22-110000_shadings/up.sql b/backend/migrations/2023-07-22-110000_shadings/up.sql new file mode 100644 index 000000000..545ea9a87 --- /dev/null +++ b/backend/migrations/2023-07-22-110000_shadings/up.sql @@ -0,0 +1,153 @@ +-- Your SQL goes here +CREATE TABLE shadings ( + id UUID PRIMARY KEY, + layer_id INTEGER NOT NULL, + shade SHADE NOT NULL, + geometry GEOMETRY (POLYGON, 4326) NOT NULL, + add_date DATE, + remove_date DATE, + FOREIGN KEY (layer_id) REFERENCES layers (id) ON DELETE CASCADE +); + +CREATE FUNCTION check_shade_layer_type() RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + IF (SELECT type FROM layers WHERE id = NEW.layer_id) != 'shade' THEN + RAISE EXCEPTION 'Layer type must be "shade"'; + END IF; + RETURN NEW; +END; +$$; + +CREATE TRIGGER check_shade_layer_type_before_insert_or_update +BEFORE INSERT OR UPDATE ON shadings +FOR EACH ROW EXECUTE FUNCTION check_shade_layer_type(); + +-- Calculate score and relevance for a certain position. +-- +-- p_map_id ... map id +-- p_layer_ids[1] ... plant layer +-- p_layer_ids[2] ... shade layer +-- p_plant_id ... id of the plant for which to consider relations +-- date ... date at which to generate the heatmap +-- x_pos,y_pos ... coordinates on the map where to calculate the score +CREATE OR REPLACE FUNCTION calculate_score( + p_map_id INTEGER, + p_layer_ids INTEGER [], + p_plant_id INTEGER, + date DATE, + x_pos INTEGER, + y_pos INTEGER +) +RETURNS SCORE AS $$ +DECLARE + score SCORE; + plants SCORE; + shades SCORE; +BEGIN + plants := calculate_score_from_relations(p_layer_ids[1], p_plant_id, date, x_pos, y_pos); + shades := calculate_score_from_shadings(p_layer_ids[2], p_plant_id, date, x_pos, y_pos); + + score.preference := plants.preference + shades.preference; + score.relevance := plants.relevance + shades.relevance; + + RETURN score; +END; +$$ LANGUAGE plpgsql; + +-- Calculate preference: Between -1 and 1 depending on shadings. +-- Calculate relevance: 1 if there is shading; otherwise 0.0. +-- +-- If the plant would die at the position set preference=-100 and relevance=100. +CREATE FUNCTION calculate_score_from_shadings( + p_layer_id INTEGER, + p_plant_id INTEGER, + date DATE, + x_pos INTEGER, + y_pos INTEGER +) +RETURNS SCORE AS $$ +DECLARE + point GEOMETRY; + plant_shade SHADE; + plant_light_requirement light_requirement []; + allowed_shades SHADE [] := '{}'; + shading_shade SHADE; + all_values SHADE[]; + pos1 INTEGER; + pos2 INTEGER; + score SCORE; +BEGIN + IF NOT EXISTS (SELECT 1 FROM layers WHERE id = p_layer_id AND type = 'shade') THEN + RAISE EXCEPTION 'Shade layer with id % not found', p_layer_id; + END IF; + + -- Get the required light level and preferred shade level of the plant + SELECT light_requirement, shade INTO plant_light_requirement, plant_shade + FROM plants + WHERE id = p_plant_id; + + -- Create a point from x_pos and y_pos + point := ST_SetSRID(ST_MakePoint(x_pos, y_pos), 4326); + -- Select the shading with the darkest shade that intersects the point + SELECT shade INTO shading_shade + FROM shadings + WHERE layer_id = p_layer_id + AND (add_date IS NULL OR add_date <= date) + AND (remove_date IS NULL OR remove_date > date) + AND ST_Intersects(geometry, point) + ORDER BY shade DESC + LIMIT 1; + + -- If there's no shading, then there is sun. + IF NOT FOUND THEN + shading_shade := 'no shade'; + END IF; + + -- Check if the plant can survive at the position. + -- If the plant can't survive set preference=-100 and relevance=100. + IF plant_light_requirement IS NOT NULL + THEN + IF 'full sun' = ANY(plant_light_requirement) + THEN + allowed_shades := allowed_shades || '{"no shade", "light shade"}'; + END IF; + IF 'partial sun/shade' = ANY(plant_light_requirement) + THEN + allowed_shades := allowed_shades || '{"light shade", "partial shade", "permanent shade"}'; + END IF; + IF 'full shade' = ANY(plant_light_requirement) + THEN + allowed_shades := allowed_shades || '{"permanent shade", "permanent deep shade"}'; + END IF; + + IF NOT (shading_shade = ANY(allowed_shades)) + THEN + score.preference := -100; + score.relevance := 100; + RETURN score; + END IF; + END IF; + + -- If there's no shading, return 0. + IF plant_shade IS NULL THEN + score.preference := 0.0; + score.relevance := 0.0; + RETURN score; + END IF; + + -- Get all possible enum values + SELECT enum_range(NULL::SHADE) INTO all_values; + + -- Get the position of each enum value in the array + SELECT array_position(all_values, plant_shade) INTO pos1; + SELECT array_position(all_values, shading_shade) INTO pos2; + + -- Calculate the 'distance' to the preferred shade as a values between -1 and 1 + score.preference := (0.5 - (abs(pos1 - pos2) / (ARRAY_LENGTH(all_values, 1) - 1)::REAL)^0.5) * 2.0; + score.relevance := 1.0; + + RETURN score; +END; +$$ LANGUAGE plpgsql; diff --git a/backend/migrations/2023-08-08-200334_add_missing_shade_layers/down.sql b/backend/migrations/2023-08-08-200334_add_missing_shade_layers/down.sql new file mode 100644 index 000000000..db3cb5b21 --- /dev/null +++ b/backend/migrations/2023-08-08-200334_add_missing_shade_layers/down.sql @@ -0,0 +1,3 @@ +DELETE +FROM layers +WHERE layers.name = 'Shade Layer '; diff --git a/backend/migrations/2023-08-08-200334_add_missing_shade_layers/up.sql b/backend/migrations/2023-08-08-200334_add_missing_shade_layers/up.sql new file mode 100644 index 000000000..130935b66 --- /dev/null +++ b/backend/migrations/2023-08-08-200334_add_missing_shade_layers/up.sql @@ -0,0 +1,19 @@ +INSERT INTO layers (map_id, type, name, is_alternative) +SELECT + maps_without_shade_layer.id AS map_id, + 'shade' AS type, -- noqa: RF04 + -- Use an extra space to identify shade layers that were added using this migration script + -- so that the changes can be undone if necessary. + 'Shade Layer ' AS name, -- noqa: RF04 + false AS is_alternative +FROM ( + SELECT maps.id AS id + FROM maps + + EXCEPT + + SELECT maps.id + FROM maps + LEFT JOIN layers ON layers.map_id = maps.id + WHERE layers.type = 'shade' +) AS maps_without_shade_layer; diff --git a/backend/src/config/api_doc.rs b/backend/src/config/api_doc.rs index b611150fc..5682466d0 100644 --- a/backend/src/config/api_doc.rs +++ b/backend/src/config/api_doc.rs @@ -11,7 +11,7 @@ use super::auth::Config; use crate::{ controller::{ base_layer_image, blossoms, config, guided_tours, layers, map, plant_layer, plantings, - plants, seed, timeline, users, + plants, seed, shadings, timeline, users, }, model::{ dto::{ @@ -25,6 +25,10 @@ use crate::{ UpdateAddDatePlantingDto, UpdatePlantingDto, UpdatePlantingNoteDto, UpdateRemoveDatePlantingDto, }, + shadings::{ + DeleteShadingDto, NewShadingDto, ShadingDto, UpdateAddDateShadingDto, + UpdateRemoveDateShadingDto, UpdateShadingDto, UpdateValuesShadingDto, + }, timeline::{TimelineDto, TimelineEntryDto}, BaseLayerImageDto, ConfigDto, Coordinates, GainedBlossomsDto, GuidedToursDto, LayerDto, MapDto, NewLayerDto, NewMapDto, NewSeedDto, PageLayerDto, PageMapDto, @@ -33,7 +37,7 @@ use crate::{ }, r#enum::{ privacy_option::PrivacyOption, quality::Quality, quantity::Quantity, - relation_type::RelationType, + relation_type::RelationType, shade::Shade, }, }, }; @@ -192,6 +196,32 @@ struct BaseLayerImagesApiDoc; )] struct PlantingsApiDoc; +/// Struct used by [`utoipa`] to generate `OpenApi` documentation for all shadings endpoints. +#[derive(OpenApi)] +#[openapi( + paths( + shadings::find, + shadings::create, + shadings::update, + shadings::delete + ), + components( + schemas( + ShadingDto, + NewShadingDto, + UpdateShadingDto, + DeleteShadingDto, + UpdateValuesShadingDto, + UpdateAddDateShadingDto, + UpdateRemoveDateShadingDto, + Shade + + ) + ), + modifiers(&SecurityAddon) +)] +struct ShadingsApiDoc; + /// Struct used by [`utoipa`] to generate `OpenApi` documentation for all user data endpoints. #[derive(OpenApi)] #[openapi( @@ -266,6 +296,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { openapi.merge(PlantLayerApiDoc::openapi()); openapi.merge(BaseLayerImagesApiDoc::openapi()); openapi.merge(PlantingsApiDoc::openapi()); + openapi.merge(ShadingsApiDoc::openapi()); openapi.merge(UsersApiDoc::openapi()); openapi.merge(TimelineApiDoc::openapi()); diff --git a/backend/src/config/routes.rs b/backend/src/config/routes.rs index 8f9a2f7d3..cbb68d876 100644 --- a/backend/src/config/routes.rs +++ b/backend/src/config/routes.rs @@ -6,7 +6,7 @@ use actix_web_httpauth::middleware::HttpAuthentication; use crate::controller::{ base_layer_image, blossoms, config, drawings, guided_tours, layers, map, plant_layer, - plantings, plants, seed, sse, timeline, users, + plantings, plants, seed, shadings, sse, timeline, users, }; use super::auth::middleware::validator; @@ -73,6 +73,15 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(plantings::update) .service(plantings::delete), ), + ) + .service( + web::scope("/shade").service( + web::scope("/shadings") + .service(shadings::find) + .service(shadings::create) + .service(shadings::update) + .service(shadings::delete), + ), ), ) .service( diff --git a/backend/src/controller/mod.rs b/backend/src/controller/mod.rs index b63ba2548..42908c72e 100644 --- a/backend/src/controller/mod.rs +++ b/backend/src/controller/mod.rs @@ -11,6 +11,7 @@ pub mod plant_layer; pub mod plantings; pub mod plants; pub mod seed; +pub mod shadings; pub mod sse; pub mod timeline; pub mod users; diff --git a/backend/src/controller/plant_layer.rs b/backend/src/controller/plant_layer.rs index b0aedc271..fd855db67 100644 --- a/backend/src/controller/plant_layer.rs +++ b/backend/src/controller/plant_layer.rs @@ -14,7 +14,8 @@ use crate::{ /// Endpoint for generating a heatmap signaling ideal locations for planting the plant. /// -/// Grey pixels signal areas where the plant shouldn't be planted, while green areas signal ideal locations. +/// Red pixels signal areas where the plant shouldn't be planted, while green areas signal ideal locations. +/// The more transparent the location is the less data there is to support the claimed preference. /// /// The resulting heatmap does represent actual coordinates, meaning the pixel at (0,0) is not necessarily at coordinates (0,0). /// Instead the image has to be moved and scaled to fit inside the maps boundaries. diff --git a/backend/src/controller/shadings.rs b/backend/src/controller/shadings.rs new file mode 100644 index 000000000..5234ee885 --- /dev/null +++ b/backend/src/controller/shadings.rs @@ -0,0 +1,172 @@ +//! `Shading` endpoints. + +use actix_web::{ + delete, get, patch, post, + web::{Data, Json, Path, Query}, + HttpResponse, Result, +}; + +use crate::model::dto::actions::Action; +use crate::model::dto::core::ActionDtoWrapper; +use crate::{config::auth::user_info::UserInfo, config::data::AppDataInner}; +use crate::{ + model::dto::shadings::{ + DeleteShadingDto, NewShadingDto, ShadingSearchParameters, UpdateShadingDto, + }, + service::shadings, +}; + +/// Endpoint for listing and filtering `Shading`. +/// +/// # Errors +/// * If the connection to the database could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/layers/shade/shadings", + params( + ("map_id" = i32, Path, description = "The id of the map the layer is on"), + ShadingSearchParameters + ), + responses( + (status = 200, description = "Find shadings", body = Vec) + ), + security( + ("oauth2" = []) + ) +)] +#[get("")] +pub async fn find( + search_params: Query, + app_data: Data, +) -> Result { + let response = shadings::find(search_params.into_inner(), &app_data).await?; + Ok(HttpResponse::Ok().json(response)) +} + +/// Endpoint for creating a new `Shading`. +/// +/// # Errors +/// * If the connection to the database could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/layers/shade/shadings", + params( + ("map_id" = i32, Path, description = "The id of the map the layer is on"), + ), + request_body = NewShadingDto, + responses( + (status = 201, description = "Create a shading", body = ShadingDto) + ), + security( + ("oauth2" = []) + ) +)] +#[post("")] +pub async fn create( + path: Path, + new_shadings: Json>>, + app_data: Data, + user_info: UserInfo, +) -> Result { + let map_id = path.into_inner(); + + let ActionDtoWrapper { action_id, dto } = new_shadings.into_inner(); + + let created_shadings = shadings::create(dto, &app_data).await?; + + app_data + .broadcaster + .broadcast( + map_id, + Action::new_create_shading_action(&created_shadings, user_info.id, action_id), + ) + .await; + + Ok(HttpResponse::Created().json(created_shadings)) +} + +/// Endpoint for updating a `Shading`. +/// +/// # Errors +/// * If the connection to the database could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/layers/shade/shadings", + params( + ("map_id" = i32, Path, description = "The id of the map the layer is on"), + ), + request_body = ActionDtoWrapperUpdateShadings, + responses( + (status = 200, description = "Update a shading", body = Vec) + ), + security( + ("oauth2" = []) + ) +)] +#[patch("")] +pub async fn update( + path: Path, + update_shading: Json>, + app_data: Data, + user_info: UserInfo, +) -> Result { + let map_id = path.into_inner(); + + let ActionDtoWrapper { action_id, dto } = update_shading.into_inner(); + + let shading = shadings::update(dto.clone(), &app_data).await?; + + let action = match &dto { + UpdateShadingDto::Update(dto) => { + Action::new_update_shading_action(dto, user_info.id, action_id) + } + UpdateShadingDto::UpdateAddDate(dto) => { + Action::new_update_shading_add_date_action(dto, user_info.id, action_id) + } + UpdateShadingDto::UpdateRemoveDate(dto) => { + Action::new_update_shading_remove_date_action(dto, user_info.id, action_id) + } + }; + + app_data.broadcaster.broadcast(map_id, action).await; + + Ok(HttpResponse::Ok().json(shading)) +} + +/// Endpoint for deleting a `Shading`. +/// +/// # Errors +/// * If the connection to the database could not be established. +#[utoipa::path( + context_path = "/api/maps/{map_id}/layers/shade/shadings", + params( + ("map_id" = i32, Path, description = "The id of the map the layer is on"), + ), + request_body = DeleteShadingDto, + responses( + (status = 200, description = "Delete a shading") + ), + security( + ("oauth2" = []) + ) +)] +#[delete("")] +pub async fn delete( + path: Path, + delete_shadings: Json>>, + app_data: Data, + user_info: UserInfo, +) -> Result { + let map_id = path.into_inner(); + + let ActionDtoWrapper { action_id, dto } = delete_shadings.into_inner(); + + shadings::delete_by_ids(dto.clone(), &app_data).await?; + + app_data + .broadcaster + .broadcast( + map_id, + Action::new_delete_shading_action(&dto, user_info.id, action_id), + ) + .await; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/backend/src/model/dto.rs b/backend/src/model/dto.rs index fcff53974..d5d4369aa 100644 --- a/backend/src/model/dto.rs +++ b/backend/src/model/dto.rs @@ -32,8 +32,10 @@ pub mod plantings; pub mod plantings_impl; pub mod plants_impl; pub mod seed_impl; +pub mod shadings; +pub mod shadings_impl; pub mod timeline; -mod update_map_geometry_impl; +pub mod update_map_geometry_impl; pub mod update_map_impl; pub mod users_impl; @@ -387,29 +389,6 @@ pub struct ConnectToMapQueryParams { pub user_id: String, } -/// Search parameters for plant suggestions. -#[typeshare] -#[derive(Debug, Deserialize, IntoParams)] -pub struct PlantSuggestionsSearchParameters { - /// The kind of suggestion returned by the endpoint. - #[param(inline)] - pub suggestion_type: SuggestionType, - /// Date representing the season to search for. - /// Only the month and day are used, nevertheless it must be an existing date. - pub relative_to_date: NaiveDate, -} - -/// Kind of suggestion. -#[typeshare] -#[derive(Debug, Deserialize, ToSchema)] -#[serde(rename_all = "lowercase")] -pub enum SuggestionType { - /// Suggests plants that are available for planting. - Available, - /// Suggests plants based on diversity criteria. - Diversity, -} - /// Contains information about an image displayed on the base layer. #[typeshare] #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] @@ -459,9 +438,14 @@ pub struct DeleteBaseLayerImageDto { #[derive(Debug, Deserialize, IntoParams)] pub struct HeatMapQueryParams { /// The id of the plant layer the planting will be planted on. - pub layer_id: i32, + pub plant_layer_id: i32, + /// The id of the shade layer the planting will be planted on. + pub shade_layer_id: i32, /// The id of the plant you want to plant. pub plant_id: i32, + /// The date at which to generate the heatmap. + /// Will be set to the current date if `None`. + pub date: Option, } #[typeshare] diff --git a/backend/src/model/dto/actions.rs b/backend/src/model/dto/actions.rs index 4f8fa95bd..bd2eddfa2 100644 --- a/backend/src/model/dto/actions.rs +++ b/backend/src/model/dto/actions.rs @@ -7,7 +7,10 @@ // Don't make the `new` functions const, there might come more fields in the future. #![allow(clippy::missing_const_for_fn)] -use crate::model::dto::plantings::PlantingDto; +use crate::model::dto::shadings::{ + DeleteShadingDto, UpdateAddDateShadingDto, UpdateRemoveDateShadingDto, UpdateValuesShadingDto, +}; +use crate::model::{dto::plantings::PlantingDto, r#enum::shade::Shade}; use chrono::NaiveDate; use postgis_diesel::types::{Point, Polygon}; use serde::Serialize; @@ -20,6 +23,7 @@ use super::{ DeletePlantingDto, MovePlantingDto, TransformPlantingDto, UpdateAddDatePlantingDto, UpdatePlantingNoteDto, UpdateRemoveDatePlantingDto, }, + shadings::ShadingDto, BaseLayerImageDto, UpdateMapGeometryDto, }; @@ -52,6 +56,16 @@ pub enum ActionType { UpdatePlantingRemoveDate(Vec), /// An action used to broadcast updating a Markdown notes of a plant. UpdatePlatingNotes(Vec), + /// An action used to broadcast creation of a shading. + CreateShading(Vec), + /// An action used to broadcast deletion of a shading. + DeleteShading(Vec), + /// An action used to broadcast change of a shading. + UpdateShading(Vec), + /// An action used to update the `add_date` of a shading. + UpdateShadingAddDate(Vec), + /// An action used to update the `remove_date` of a shading. + UpdateShadingRemoveDate(Vec), /// An action used to broadcast creation of a baseLayerImage. CreateBaseLayerImage(CreateBaseLayerImageActionPayload), /// An action used to broadcast update of a baseLayerImage. @@ -228,6 +242,104 @@ impl Action { ), } } + + #[must_use] + pub fn new_create_shading_action(dtos: &[ShadingDto], user_id: Uuid, action_id: Uuid) -> Self { + Self { + action_id, + user_id, + action: ActionType::CreateShading( + dtos.iter() + .map(|dto| CreateShadingActionPayload { + id: dto.id, + layer_id: dto.layer_id, + shade: dto.shade, + add_date: dto.add_date, + remove_date: dto.remove_date, + geometry: dto.geometry.clone(), + }) + .collect(), + ), + } + } + + #[must_use] + pub fn new_delete_shading_action( + dtos: &[DeleteShadingDto], + user_id: Uuid, + action_id: Uuid, + ) -> Self { + Self { + action_id, + user_id, + action: ActionType::DeleteShading( + dtos.iter() + .map(|dto| DeleteShadingActionPayload { id: dto.id }) + .collect(), + ), + } + } + + #[must_use] + pub fn new_update_shading_action( + dtos: &[UpdateValuesShadingDto], + user_id: Uuid, + action_id: Uuid, + ) -> Self { + Self { + action_id, + user_id, + action: ActionType::UpdateShading( + dtos.iter() + .map(|dto| UpdateShadingActionPayload { + id: dto.id, + shade: dto.shade, + geometry: dto.clone().geometry, + }) + .collect(), + ), + } + } + + #[must_use] + pub fn new_update_shading_remove_date_action( + dtos: &[UpdateRemoveDateShadingDto], + user_id: Uuid, + action_id: Uuid, + ) -> Self { + Self { + action_id, + user_id, + action: ActionType::UpdateShadingRemoveDate( + dtos.iter() + .map(|dto| UpdateShadingRemoveDateActionPayload { + id: dto.id, + remove_date: dto.remove_date, + }) + .collect(), + ), + } + } + + #[must_use] + pub fn new_update_shading_add_date_action( + dtos: &[UpdateAddDateShadingDto], + user_id: Uuid, + action_id: Uuid, + ) -> Self { + Self { + action_id, + user_id, + action: ActionType::UpdateShadingAddDate( + dtos.iter() + .map(|dto| UpdateShadingAddDateActionPayload { + id: dto.id, + add_date: dto.add_date, + }) + .collect(), + ), + } + } } #[typeshare] @@ -282,6 +394,99 @@ pub struct TransformPlantActionPayload { size_y: i32, } +#[typeshare] +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CreateShadingActionPayload { + id: Uuid, + layer_id: i32, + shade: Shade, + geometry: Polygon, + add_date: Option, + remove_date: Option, +} + +impl CreateShadingActionPayload { + #[must_use] + pub fn new(payload: ShadingDto) -> Self { + Self { + id: payload.id, + layer_id: payload.layer_id, + shade: payload.shade, + geometry: payload.geometry, + add_date: payload.add_date, + remove_date: payload.remove_date, + } + } +} + +#[typeshare] +#[derive(Debug, Serialize, Clone)] +/// The payload of the [`ActionType::UpdateShading`]. +#[serde(rename_all = "camelCase")] +pub struct UpdateShadingActionPayload { + id: Uuid, + shade: Option, + geometry: Option>, +} + +impl UpdateShadingActionPayload { + #[must_use] + pub fn new(payload: ShadingDto) -> Self { + Self { + id: payload.id, + shade: Some(payload.shade), + geometry: Some(payload.geometry), + } + } +} + +#[typeshare] +#[derive(Debug, Serialize, Clone)] +/// The payload of the [`ActionType::DeleteShading`]. +#[serde(rename_all = "camelCase")] +pub struct DeleteShadingActionPayload { + id: Uuid, +} + +#[typeshare] +#[derive(Debug, Serialize, Clone)] +/// The payload of the [`ActionType::UpdateShadingAddDate`]. +#[serde(rename_all = "camelCase")] +pub struct UpdateShadingAddDateActionPayload { + id: Uuid, + add_date: Option, +} + +impl UpdateShadingAddDateActionPayload { + #[must_use] + pub fn new(payload: &ShadingDto) -> Self { + Self { + id: payload.id, + add_date: payload.add_date, + } + } +} + +#[typeshare] +#[derive(Debug, Serialize, Clone)] +/// The payload of the [`ActionType::UpdateShadingRemoveDate`]. +#[serde(rename_all = "camelCase")] +pub struct UpdateShadingRemoveDateActionPayload { + id: Uuid, + remove_date: Option, +} + +impl UpdateShadingRemoveDateActionPayload { + #[must_use] + pub fn new(payload: &ShadingDto) -> Self { + Self { + id: payload.id, + remove_date: payload.remove_date, + } + } +} + #[typeshare] #[derive(Debug, Serialize, Clone)] /// The payload of the [`ActionType::UpdatePlatingNotes`]. diff --git a/backend/src/model/dto/shadings.rs b/backend/src/model/dto/shadings.rs new file mode 100644 index 000000000..d4e3e1d32 --- /dev/null +++ b/backend/src/model/dto/shadings.rs @@ -0,0 +1,130 @@ +//! All DTOs associated with [`ShadingDto`]. + +use chrono::NaiveDate; +use postgis_diesel::types::{Point, Polygon}; +use serde::{Deserialize, Serialize}; +use typeshare::typeshare; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::model::r#enum::shade::Shade; + +/// Represents shade on a map. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ShadingDto { + /// The id of the shading. + pub id: Uuid, + /// The layer the shadings is on. + pub layer_id: i32, + /// The type/strength of shade. + pub shade: Shade, + /// The position of the shade on the map. + /// + /// E.g. `{"rings": [[{"x": 0.0,"y": 0.0},{"x": 1000.0,"y": 0.0},{"x": 1000.0,"y": 1000.0},{"x": 0.0,"y": 1000.0},{"x": 0.0,"y": 0.0}]],"srid": 4326}` + #[schema(value_type = Object)] + pub geometry: Polygon, + /// The date the shading was added to the map. + /// If None, the shading always existed. + pub add_date: Option, + /// The date the shading was removed from the map. + /// If None, the shading is still on the map. + pub remove_date: Option, +} + +/// Used to create a new shading. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NewShadingDto { + /// The id of the shading. + pub id: Option, + /// The plant layer the shadings is on. + pub layer_id: i32, + /// The type/strength of shade. + pub shade: Shade, + /// The position of the shade on the map. + /// + /// E.g. `{"rings": [[{"x": 0.0,"y": 0.0},{"x": 1000.0,"y": 0.0},{"x": 1000.0,"y": 1000.0},{"x": 0.0,"y": 1000.0},{"x": 0.0,"y": 0.0}]],"srid": 4326}` + #[schema(value_type = Object)] + pub geometry: Polygon, + /// The date the shading was added to the map. + /// If None, the shading always existed. + pub add_date: Option, +} + +/// Used to differentiate between different update operations on shadings. +/// +/// Ordering of enum variants is important. +/// Serde will try to deserialize starting from the top. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type", content = "content")] +pub enum UpdateShadingDto { + /// Update values of a shading. + Update(Vec), + /// Change the `add_date` of a shading. + UpdateAddDate(Vec), + /// Change the `remove_date` of a shading. + UpdateRemoveDate(Vec), +} + +/// Used to update the values of an existing shading. +#[typeshare] +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateValuesShadingDto { + /// The id of the shading. + pub id: Uuid, + /// The type/strength of shade. + pub shade: Option, + /// The position of the shade on the map. + /// + /// E.g. `{"rings": [[{"x": 0.0,"y": 0.0},{"x": 1000.0,"y": 0.0},{"x": 1000.0,"y": 1000.0},{"x": 0.0,"y": 1000.0},{"x": 0.0,"y": 0.0}]],"srid": 4326}` + #[schema(value_type = Option)] + pub geometry: Option>, +} + +/// Used to change the `add_date` of a shading. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateAddDateShadingDto { + /// The id of the shading. + pub id: Uuid, + /// The date the shading was added to the map. + /// If None, the shading always existed. + pub add_date: Option, +} + +/// Used to change the `remove_date` of a shading. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateRemoveDateShadingDto { + /// The id of the shading. + pub id: Uuid, + /// The date the shading was removed from the map. + /// If None, the shading is still on the map. + pub remove_date: Option, +} + +/// Used to delete a shading. +/// The id of the shading is passed in the path. +#[typeshare] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeleteShadingDto { + /// The id of the shading. + pub id: Uuid, +} + +/// Query parameters for searching shadings. +#[typeshare] +#[derive(Debug, Deserialize, IntoParams)] +pub struct ShadingSearchParameters { + /// The id of the layer the shading is placed on. + pub layer_id: Option, + /// Shadings that exist around this date are returned. + pub relative_to_date: NaiveDate, +} diff --git a/backend/src/model/dto/shadings_impl.rs b/backend/src/model/dto/shadings_impl.rs new file mode 100644 index 000000000..48653e106 --- /dev/null +++ b/backend/src/model/dto/shadings_impl.rs @@ -0,0 +1,82 @@ +//! Contains the implementations related to [`ShadingDto`]. + +use crate::model::dto::shadings::{ + UpdateAddDateShadingDto, UpdateRemoveDateShadingDto, UpdateValuesShadingDto, +}; +use uuid::Uuid; + +use crate::model::entity::shadings::{Shading, UpdateShading}; + +use super::shadings::{NewShadingDto, ShadingDto, UpdateShadingDto}; + +impl From for ShadingDto { + fn from(entity: Shading) -> Self { + Self { + id: entity.id, + layer_id: entity.layer_id, + shade: entity.shade, + geometry: entity.geometry, + add_date: entity.add_date, + remove_date: entity.remove_date, + } + } +} + +impl From for Shading { + fn from(dto: NewShadingDto) -> Self { + Self { + id: dto.id.unwrap_or_else(Uuid::new_v4), + layer_id: dto.layer_id, + shade: dto.shade, + geometry: dto.geometry, + add_date: dto.add_date, + remove_date: None, + } + } +} + +impl From for Vec { + fn from(dto: UpdateShadingDto) -> Self { + match dto { + UpdateShadingDto::Update(vec) => vec.into_iter().map(Into::into).collect(), + UpdateShadingDto::UpdateAddDate(vec) => vec.into_iter().map(Into::into).collect(), + UpdateShadingDto::UpdateRemoveDate(vec) => vec.into_iter().map(Into::into).collect(), + } + } +} + +impl From for UpdateShading { + fn from(dto: UpdateValuesShadingDto) -> Self { + Self { + id: dto.id, + shade: dto.shade, + geometry: dto.geometry, + add_date: None, + remove_date: None, + } + } +} + +impl From for UpdateShading { + fn from(dto: UpdateAddDateShadingDto) -> Self { + Self { + id: dto.id, + shade: None, + geometry: None, + add_date: Some(dto.add_date), + remove_date: None, + } + } +} + +impl From for UpdateShading { + fn from(dto: UpdateRemoveDateShadingDto) -> Self { + Self { + id: dto.id, + shade: None, + geometry: None, + add_date: None, + remove_date: Some(dto.remove_date), + } + } +} diff --git a/backend/src/model/entity.rs b/backend/src/model/entity.rs index 6c18f5437..1bcda5d3b 100644 --- a/backend/src/model/entity.rs +++ b/backend/src/model/entity.rs @@ -12,6 +12,8 @@ pub mod plantings; pub mod plantings_impl; pub mod plants_impl; pub mod seed_impl; +pub mod shadings; +pub mod shadings_impl; pub mod timeline; pub mod users_impl; diff --git a/backend/src/model/entity/plant_layer.rs b/backend/src/model/entity/plant_layer.rs index 738be10c3..790ea62b6 100644 --- a/backend/src/model/entity/plant_layer.rs +++ b/backend/src/model/entity/plant_layer.rs @@ -1,13 +1,16 @@ //! Contains the database implementation of the plant layer. +use std::cmp::max; + +use chrono::NaiveDate; use diesel::{ debug_query, pg::Pg, - sql_types::{Float, Integer}, + sql_types::{Array, Date, Float, Integer}, CombineDsl, ExpressionMethods, QueryDsl, QueryResult, QueryableByName, }; use diesel_async::{AsyncPgConnection, RunQueryDsl}; -use log::debug; +use log::{debug, trace}; use crate::{ model::{ @@ -17,9 +20,6 @@ use crate::{ schema::relations, }; -/// The resolution of the generated heatmap in cm. -pub const GRANULARITY: i32 = 10; - /// A bounding box around the maps geometry. #[derive(Debug, Clone, QueryableByName)] struct BoundingBox { @@ -42,7 +42,10 @@ struct BoundingBox { struct HeatMapElement { /// The score on the heatmap. #[diesel(sql_type = Float)] - score: f32, + preference: f32, + /// The alpha on the heatmap. + #[diesel(sql_type = Float)] + relevance: f32, /// The x values of the score #[diesel(sql_type = Integer)] x: i32, @@ -64,42 +67,73 @@ struct HeatMapElement { )] pub async fn heatmap( map_id: i32, - layer_id: i32, + plant_layer_id: i32, + shade_layer_id: i32, plant_id: i32, + date: NaiveDate, conn: &mut AsyncPgConnection, -) -> QueryResult>> { +) -> QueryResult>> { // Fetch the bounding box x and y values of the maps coordinates let bounding_box_query = diesel::sql_query("SELECT * FROM calculate_bbox($1)").bind::(map_id); debug!("{}", debug_query::(&bounding_box_query)); let bounding_box = bounding_box_query.get_result::(conn).await?; + let granularity = calculate_granularity(&bounding_box); + // Fetch the heatmap - let query = diesel::sql_query("SELECT * FROM calculate_score($1, $2, $3, $4, $5, $6, $7, $8)") - .bind::(map_id) - .bind::(layer_id) - .bind::(plant_id) - .bind::(GRANULARITY) - .bind::(bounding_box.x_min) - .bind::(bounding_box.y_min) - .bind::(bounding_box.x_max) - .bind::(bounding_box.y_max); + let query = + diesel::sql_query("SELECT * FROM calculate_heatmap($1, $2, $3, $4, $5, $6, $7, $8, $9)") + .bind::(map_id) + .bind::, _>(vec![plant_layer_id, shade_layer_id]) + .bind::(plant_id) + .bind::(date) + .bind::(granularity) + .bind::(bounding_box.x_min) + .bind::(bounding_box.y_min) + .bind::(bounding_box.x_max) + .bind::(bounding_box.y_max); debug!("{}", debug_query::(&query)); let result = query.load::(conn).await?; // Convert the result to a matrix. // Matrix will be from 0..0 to ((x_max - x_min) / granularity)..((y_max - y_min) / granularity). let num_cols = - (f64::from(bounding_box.x_max - bounding_box.x_min) / f64::from(GRANULARITY)).ceil(); + (f64::from(bounding_box.x_max - bounding_box.x_min) / f64::from(granularity)).floor(); let num_rows = - (f64::from(bounding_box.y_max - bounding_box.y_min) / f64::from(GRANULARITY)).ceil(); - let mut heatmap = vec![vec![0.0; num_cols as usize]; num_rows as usize]; - for HeatMapElement { score, x, y } in result { - heatmap[y as usize][x as usize] = score; + (f64::from(bounding_box.y_max - bounding_box.y_min) / f64::from(granularity)).floor(); + let mut heatmap = vec![vec![(0.0, 0.0); num_cols as usize]; num_rows as usize]; + for HeatMapElement { + preference, + relevance, + x, + y, + } in result + { + heatmap[y as usize][x as usize] = (preference, relevance); } + + trace!("{heatmap:#?}"); Ok(heatmap) } +/// The number of values the resulting heatmap matrix should have. +const NUMBER_OF_SQUARES: f64 = 10000.0; + +/// Calculate granularity so the number of scores calculated stays constant independent of map size. +fn calculate_granularity(bounding_box: &BoundingBox) -> i32 { + let width = bounding_box.x_max - bounding_box.x_min; + let height = bounding_box.y_max - bounding_box.y_min; + + // Mathematical reformulation: + // width * height = number_of_squares * granularity^2 + // granularity = sqrt((width * height) / number_of_squares) + #[allow(clippy::cast_possible_truncation)] // ok, because we don't care about exact values + let granularity = (f64::from(width * height) / NUMBER_OF_SQUARES).sqrt() as i32; + + max(1, granularity) +} + /// Get all relations of a certain plant. /// /// # Errors diff --git a/backend/src/model/entity/shadings.rs b/backend/src/model/entity/shadings.rs new file mode 100644 index 000000000..2345b2d24 --- /dev/null +++ b/backend/src/model/entity/shadings.rs @@ -0,0 +1,44 @@ +//! All entities associated with [`Shading`]. + +use chrono::NaiveDate; +use diesel::{AsChangeset, Identifiable, Insertable, Queryable}; +use postgis_diesel::types::{Point, Polygon}; +use uuid::Uuid; + +use crate::{model::r#enum::shade::Shade, schema::shadings}; + +/// The `Shading` entity. +#[derive(Debug, Clone, Identifiable, Queryable, Insertable)] +#[diesel(table_name = shadings)] +pub struct Shading { + /// The id of the shading. + pub id: Uuid, + /// The plant layer the shadings is on. + pub layer_id: i32, + /// The type/strength of shade. + pub shade: Shade, + /// The position of the shade on the map. + pub geometry: Polygon, + /// The date the shading was added to the map. + /// If None, the shading always existed. + pub add_date: Option, + /// The date the shading was removed from the map. + /// If None, the shading is still on the map. + pub remove_date: Option, +} + +/// The `UpdateShading` entity. +#[derive(Debug, Clone, Default, AsChangeset)] +#[diesel(table_name = shadings)] +pub struct UpdateShading { + /// The id of the shading. + pub id: Uuid, + /// The type/strength of shade. + pub shade: Option, + /// The position of the shade on the map. + pub geometry: Option>, + /// The date the shading was added to the map. + pub add_date: Option>, + /// The date the shading was removed from the map. + pub remove_date: Option>, +} diff --git a/backend/src/model/entity/shadings_impl.rs b/backend/src/model/entity/shadings_impl.rs new file mode 100644 index 000000000..7d141b01c --- /dev/null +++ b/backend/src/model/entity/shadings_impl.rs @@ -0,0 +1,143 @@ +//! Contains the implementation of [`Shading`]. + +use chrono::NaiveDate; +use diesel::pg::Pg; +use diesel::{debug_query, BoolExpressionMethods, ExpressionMethods, QueryDsl, QueryResult}; +use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; +use log::debug; +use std::future::Future; +use uuid::Uuid; + +use crate::model::dto::shadings::{DeleteShadingDto, NewShadingDto, ShadingDto, UpdateShadingDto}; +use crate::model::entity::shadings::{Shading, UpdateShading}; +use crate::schema::shadings::{self, all_columns, layer_id}; + +/// Arguments for the database layer find shadings function. +pub struct FindShadingsParameters { + /// The id of the layer to find shadings for. + pub layer_id: Option, + /// First date in the time frame shadings are searched for. + pub from: NaiveDate, + /// Last date in the time frame shadings are searched for. + pub to: NaiveDate, +} + +impl Shading { + /// Get all shadings associated with the query. + /// + /// # Errors + /// * Unknown, diesel doesn't say why it might error. + pub async fn find( + search_parameters: FindShadingsParameters, + conn: &mut AsyncPgConnection, + ) -> QueryResult> { + let mut query = shadings::table.select(all_columns).into_boxed(); + + if let Some(id) = search_parameters.layer_id { + query = query.filter(layer_id.eq(id)); + } + + let shadings_added_before_date = shadings::add_date + .is_null() + .or(shadings::add_date.lt(search_parameters.to)); + let shadings_removed_after_date = shadings::remove_date + .is_null() + .or(shadings::remove_date.gt(search_parameters.from)); + + query = query.filter(shadings_added_before_date.and(shadings_removed_after_date)); + + debug!("{}", debug_query::(&query)); + + Ok(query + .load::(conn) + .await? + .into_iter() + .map(Into::into) + .collect()) + } + + /// Create a new shading in the database. + /// + /// # Errors + /// * If the `layer_id` references a layer that is not of type `plant`. + /// * Unknown, diesel doesn't say why it might error. + pub async fn create( + dto_vec: Vec, + conn: &mut AsyncPgConnection, + ) -> QueryResult> { + let new_shadings: Vec = dto_vec.into_iter().map(Into::into).collect(); + let query = diesel::insert_into(shadings::table).values(&new_shadings); + + debug!("{}", debug_query::(&query)); + + let result = query + .get_results::(conn) + .await? + .into_iter() + .map(Into::into) + .collect::>(); + + Ok(result) + } + + /// Partially update a shading in the database. + /// + /// # Errors + /// * Unknown, diesel doesn't say why it might error. + pub async fn update( + dto: UpdateShadingDto, + conn: &mut AsyncPgConnection, + ) -> QueryResult> { + let shading_updates = Vec::from(dto); + + let result = conn + .transaction(|transaction| { + Box::pin(async { + let futures = Self::do_update(shading_updates, transaction); + + let results = futures_util::future::try_join_all(futures).await?; + + Ok(results) as QueryResult> + }) + }) + .await?; + + Ok(result.into_iter().map(Into::into).collect()) + } + + /// Performs the actual update of the plantings using pipelined requests. + /// See [`diesel_async::AsyncPgConnection`] for more information. + /// Because the type system can not easily infer the type of futures + /// this helper function is needed, with explicit type annotations. + fn do_update( + updates: Vec, + conn: &mut AsyncPgConnection, + ) -> Vec>> { + let mut futures = Vec::with_capacity(updates.len()); + + for update in updates { + let updated_shadings = diesel::update(shadings::table.find(update.id)) + .set(update) + .get_result::(conn); + + futures.push(updated_shadings); + } + + futures + } + + /// Delete the shading from the database. + /// + /// # Errors + /// * Unknown, diesel doesn't say why it might error. + pub async fn delete_by_ids( + dtos: Vec, + conn: &mut AsyncPgConnection, + ) -> QueryResult { + let ids: Vec = dtos.iter().map(|&DeleteShadingDto { id }| id).collect(); + + let query = diesel::delete(shadings::table.filter(shadings::id.eq_any(ids))); + debug!("{}", debug_query::(&query)); + query.execute(conn).await + } +} diff --git a/backend/src/model/enum/shade.rs b/backend/src/model/enum/shade.rs index 862aeb9d0..c5acbea0e 100644 --- a/backend/src/model/enum/shade.rs +++ b/backend/src/model/enum/shade.rs @@ -7,7 +7,7 @@ use utoipa::ToSchema; #[allow(clippy::missing_docs_in_private_items)] // TODO: See #97. #[typeshare] -#[derive(Serialize, Deserialize, DbEnum, Debug, ToSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DbEnum, ToSchema)] #[ExistingTypePath = "crate::schema::sql_types::Shade"] pub enum Shade { #[serde(rename = "no shade")] diff --git a/backend/src/schema.patch b/backend/src/schema.patch index 88f0f4345..63801cda2 100644 --- a/backend/src/schema.patch +++ b/backend/src/schema.patch @@ -1,15 +1,12 @@ -diff --git a/backend/src/schema.rs b/backend/src/schema.rs 2023-07-20 -index 54f26f46..68427977 100644 ---- a/backend/src/schema.rs -+++ b/backend/src/schema.rs -@@ -10,20 +10,12 @@ pub mod sql_types { - pub struct ExternalSource; +--- src/schema.rs 2023-12-16 21:15:32.447836438 +0100 ++++ src/schema_that_works.rs 2023-12-16 21:13:48.667866145 +0100 +@@ -15,20 +15,12 @@ #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "fertility"))] pub struct Fertility; -- #[derive(diesel::sql_types::SqlType)] + #[derive(diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "geography"))] - pub struct Geography; - @@ -17,13 +14,14 @@ index 54f26f46..68427977 100644 - #[diesel(postgres_type(name = "geometry"))] - pub struct Geometry; - - #[derive(diesel::sql_types::SqlType)] +- #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "growth_rate"))] pub struct GrowthRate; #[derive(diesel::sql_types::SqlType)] #[diesel(postgres_type(name = "herbaceous_or_woody"))] -@@ -100,16 +92,15 @@ diesel::table! { + pub struct HerbaceousOrWoody; +@@ -165,16 +157,15 @@ is_alternative -> Bool, } } @@ -41,3 +39,20 @@ index 54f26f46..68427977 100644 name -> Text, creation_date -> Date, deletion_date -> Nullable, +@@ -311,15 +302,15 @@ + archived_at -> Nullable, + } + } + + diesel::table! { + use postgis_diesel::sql_types::Geography; ++ use postgis_diesel::sql_types::Geometry; + use diesel::sql_types::*; + use super::sql_types::Shade; +- use super::sql_types::Geometry; + + shadings (id) { + id -> Uuid, + layer_id -> Int4, + shade -> Shade, + geometry -> Geometry, diff --git a/backend/src/service/map.rs b/backend/src/service/map.rs index cdd0c919b..c8505d703 100644 --- a/backend/src/service/map.rs +++ b/backend/src/service/map.rs @@ -21,7 +21,12 @@ use crate::{ }; /// Defines which layers should be created when a new map is created. -const LAYER_TYPES: [LayerType; 3] = [LayerType::Base, LayerType::Drawing, LayerType::Plants]; +const LAYER_TYPES: [LayerType; 4] = [ + LayerType::Base, + LayerType::Drawing, + LayerType::Shade, + LayerType::Plants, +]; /// Search maps from the database. /// diff --git a/backend/src/service/mod.rs b/backend/src/service/mod.rs index 83ff113d4..8f8e633cd 100644 --- a/backend/src/service/mod.rs +++ b/backend/src/service/mod.rs @@ -10,6 +10,7 @@ pub mod plant_layer; pub mod plantings; pub mod plants; pub mod seed; +pub mod shadings; pub mod timeline; pub mod users; pub mod util; diff --git a/backend/src/service/plant_layer.rs b/backend/src/service/plant_layer.rs index e1d67b2a7..f7a0c2870 100644 --- a/backend/src/service/plant_layer.rs +++ b/backend/src/service/plant_layer.rs @@ -4,7 +4,8 @@ use std::io::Cursor; use actix_http::StatusCode; use actix_web::web::Data; -use image::{ImageBuffer, Rgb}; +use chrono::Utc; +use image::{ImageBuffer, Rgba}; use crate::{ config::data::AppDataInner, @@ -32,8 +33,10 @@ pub async fn heatmap( let mut conn = app_data.pool.get().await?; let result = plant_layer::heatmap( map_id, - query_params.layer_id, + query_params.plant_layer_id, + query_params.shade_layer_id, query_params.plant_id, + query_params.date.unwrap_or_else(|| Utc::now().date_naive()), &mut conn, ) .await?; @@ -49,19 +52,26 @@ pub async fn heatmap( clippy::indexing_slicing, // ok, because size of image is generated using matrix width and height clippy::cast_sign_loss // ok, because we only care about positive values )] -fn matrix_to_image(matrix: &Vec>) -> Result, ServiceError> { +fn matrix_to_image(matrix: &Vec>) -> Result, ServiceError> { let (width, height) = (matrix[0].len(), matrix.len()); let mut imgbuf = ImageBuffer::new(width as u32, height as u32); for (x, y, pixel) in imgbuf.enumerate_pixels_mut() { - let data = matrix[y as usize][x as usize]; + let (preference, relevance) = matrix[y as usize][x as usize]; - // The closer data is to 1 the green it gets. - let red = data.mul_add(-128.0, 128.0); - let green = data.mul_add(255.0 - 128.0, 128.0); - let blue = data.mul_add(-128.0, 128.0); + // The closer data is to 1 the greener it gets. + let red = preference.mul_add(-255.0, 255.0); + let green = preference * 255.0; + let blue = 0.0_f32; + // For some reason every relevance value returned by the database is between + // (about) 0.5 and 1 while it should be between 0 and 1. + // + // Unfortunately I could not figure out why this is the case and therefore just + // rescaled the relevance value accordingly. + // - Moritz (badnames) + let alpha = (relevance - 0.5) * 512.0; - *pixel = Rgb([red as u8, green as u8, blue as u8]); + *pixel = Rgba([red as u8, green as u8, blue as u8, alpha as u8]); } let mut buffer: Vec = Vec::new(); diff --git a/backend/src/service/shadings.rs b/backend/src/service/shadings.rs new file mode 100644 index 000000000..f28e853f8 --- /dev/null +++ b/backend/src/service/shadings.rs @@ -0,0 +1,100 @@ +//! Service layer for shadings. + +use actix_http::StatusCode; +use actix_web::web::Data; +use chrono::Days; + +use crate::config::data::AppDataInner; +use crate::error::ServiceError; +use crate::model::dto::core::TimelinePage; +use crate::model::dto::shadings::{ + DeleteShadingDto, NewShadingDto, ShadingDto, ShadingSearchParameters, UpdateShadingDto, +}; +use crate::model::entity::shadings::Shading; +use crate::model::entity::shadings_impl::FindShadingsParameters; + +/// Time offset in days for loading shadings in the timeline. +pub const TIME_LINE_LOADING_OFFSET_DAYS: u64 = 356; + +/// Search shadings from the database. +/// +/// # Errors +/// If the connection to the database could not be established. +pub async fn find( + search_parameters: ShadingSearchParameters, + app_data: &Data, +) -> Result, ServiceError> { + let mut conn = app_data.pool.get().await?; + + let from = search_parameters + .relative_to_date + .checked_sub_days(Days::new(TIME_LINE_LOADING_OFFSET_DAYS)) + .ok_or_else(|| { + ServiceError::new( + StatusCode::BAD_REQUEST, + "Could not add days to relative_to_date".into(), + ) + })?; + + let to = search_parameters + .relative_to_date + .checked_add_days(Days::new(TIME_LINE_LOADING_OFFSET_DAYS)) + .ok_or_else(|| { + ServiceError::new( + StatusCode::BAD_REQUEST, + "Could not add days to relative_to_date".into(), + ) + })?; + + let search_parameters = FindShadingsParameters { + layer_id: search_parameters.layer_id, + from, + to, + }; + let result = Shading::find(search_parameters, &mut conn).await?; + + Ok(TimelinePage { + results: result, + from, + to, + }) +} + +/// Create a new shading in the database. +/// +/// # Errors +/// If the connection to the database could not be established. +pub async fn create( + dto: Vec, + app_data: &Data, +) -> Result, ServiceError> { + let mut conn = app_data.pool.get().await?; + let result = Shading::create(dto, &mut conn).await?; + Ok(result) +} + +/// Update the shading in the database. +/// +/// # Errors +/// If the connection to the database could not be established. +pub async fn update( + dto: UpdateShadingDto, + app_data: &Data, +) -> Result, ServiceError> { + let mut conn = app_data.pool.get().await?; + let result = Shading::update(dto, &mut conn).await?; + Ok(result) +} + +/// Delete the shading from the database. +/// +/// # Errors +/// If the connection to the database could not be established. +pub async fn delete_by_ids( + dtos: Vec, + app_data: &Data, +) -> Result<(), ServiceError> { + let mut conn = app_data.pool.get().await?; + let _ = Shading::delete_by_ids(dtos, &mut conn).await?; + Ok(()) +} diff --git a/backend/src/test/mod.rs b/backend/src/test/mod.rs index 4e3fd2569..1dd3f89c3 100644 --- a/backend/src/test/mod.rs +++ b/backend/src/test/mod.rs @@ -12,9 +12,10 @@ mod map; mod pagination; mod plant; mod plant_layer; -// mod plant_layer_heatmap; +mod plant_layer_heatmap; mod plantings; mod seed; +mod shadings; mod timeline; mod users; pub mod util; diff --git a/backend/src/test/plant_layer_heatmap.rs b/backend/src/test/plant_layer_heatmap.rs index 61bbcae97..57586fd99 100644 --- a/backend/src/test/plant_layer_heatmap.rs +++ b/backend/src/test/plant_layer_heatmap.rs @@ -15,14 +15,15 @@ use uuid::Uuid; use crate::{ error::ServiceError, - model::{ - entity::plant_layer::GRANULARITY, - r#enum::{layer_type::LayerType, privacy_option::PrivacyOption}, + model::r#enum::{ + layer_type::LayerType, light_requirement::LightRequirement, privacy_option::PrivacyOption, + relation_type::RelationType, shade::Shade, }, test::util::{ + data, dummy_map_polygons::{ rectangle_with_missing_bottom_left_corner, small_rectangle, - small_rectangle_with_non_0_xmin, tall_rectangle, + small_rectangle_with_non_0_xmin, small_square, tall_rectangle, }, init_test_app, init_test_database, }, @@ -49,13 +50,22 @@ async fn initial_db_values( .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(( - &crate::schema::layers::id.eq(-1), - &crate::schema::layers::map_id.eq(-1), - &crate::schema::layers::type_.eq(LayerType::Plants), - &crate::schema::layers::name.eq("Some name"), - &crate::schema::layers::is_alternative.eq(false), - )) + .values(vec![ + ( + &crate::schema::layers::id.eq(-1), + &crate::schema::layers::map_id.eq(-1), + &crate::schema::layers::type_.eq(LayerType::Plants), + &crate::schema::layers::name.eq("Some name"), + &crate::schema::layers::is_alternative.eq(false), + ), + ( + &crate::schema::layers::id.eq(-2), + &crate::schema::layers::map_id.eq(-1), + &crate::schema::layers::type_.eq(LayerType::Shade), + &crate::schema::layers::name.eq("Some name"), + &crate::schema::layers::is_alternative.eq(false), + ), + ]) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) @@ -63,6 +73,7 @@ async fn initial_db_values( &crate::schema::plants::id.eq(-1), &crate::schema::plants::unique_name.eq("Testia testia"), &crate::schema::plants::common_name_en.eq(Some(vec![Some("T".to_owned())])), + &crate::schema::plants::shade.eq(Some(Shade::NoShade)), )) .execute(conn) .await?; @@ -76,7 +87,7 @@ async fn test_generate_heatmap_succeeds() { let (token, app) = init_test_app(pool.clone()).await; let resp = test::TestRequest::get() - .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&layer_id=-1") + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-2") .insert_header((header::AUTHORIZATION, token)) .send_request(&app) .await; @@ -95,7 +106,7 @@ async fn test_check_heatmap_dimensionality_succeeds() { let (token, app) = init_test_app(pool.clone()).await; let resp = test::TestRequest::get() - .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&layer_id=-1") + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-2") .insert_header((header::AUTHORIZATION, token)) .send_request(&app) .await; @@ -108,11 +119,8 @@ async fn test_check_heatmap_dimensionality_succeeds() { let result = test::read_body(resp).await; let result = &result.bytes().collect::, _>>().unwrap(); let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); - let image = image.as_rgb8().unwrap(); - assert_eq!( - ((10 / GRANULARITY) as u32, (100 / GRANULARITY) as u32), - image.dimensions() - ); // smaller by factor of 10 because of granularity + let image = image.as_rgba8().unwrap(); + assert_eq!((10, 100), image.dimensions()); } #[actix_rt::test] @@ -124,7 +132,7 @@ async fn test_check_heatmap_non_0_xmin_succeeds() { let (token, app) = init_test_app(pool.clone()).await; let resp = test::TestRequest::get() - .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&layer_id=-1") + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-2") .insert_header((header::AUTHORIZATION, token)) .send_request(&app) .await; @@ -137,15 +145,12 @@ async fn test_check_heatmap_non_0_xmin_succeeds() { let result = test::read_body(resp).await; let result = &result.bytes().collect::, _>>().unwrap(); let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); - let image = image.as_rgb8().unwrap(); - assert_eq!( - ((90 / GRANULARITY) as u32, (100 / GRANULARITY) as u32), - image.dimensions() - ); + let image = image.as_rgba8().unwrap(); + assert_eq!((90, 100), image.dimensions()); } /// Test with a map geometry that excludes a corner. -/// The missing corner should be colored entirely in grey, as you cannot put plants there. +/// The missing corner should be transparent, as you cannot put plants there. #[actix_rt::test] async fn test_heatmap_with_missing_corner_succeeds() { let pool = init_test_database(|conn| { @@ -155,7 +160,7 @@ async fn test_heatmap_with_missing_corner_succeeds() { let (token, app) = init_test_app(pool.clone()).await; let resp = test::TestRequest::get() - .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&layer_id=-1") + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-2") .insert_header((header::AUTHORIZATION, token)) .send_request(&app) .await; @@ -168,21 +173,316 @@ async fn test_heatmap_with_missing_corner_succeeds() { let result = test::read_body(resp).await; let result = &result.bytes().collect::, _>>().unwrap(); let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); - let image = image.as_rgb8().unwrap(); + let image = image.as_rgba8().unwrap(); + + // (0,0) is be top left. + let (x_dim, y_dim) = image.dimensions(); + let top_left_pixel = image.get_pixel(0, 0); + let top_right_pixel = image.get_pixel(x_dim - 1, 0); + let bottom_left_pixel = image.get_pixel(0, y_dim - 1); + let bottom_right_pixel = image.get_pixel(x_dim - 1, y_dim - 1); + assert_eq!([68, 186, 0, 0], top_left_pixel.0); + assert_eq!([68, 186, 0, 0], top_right_pixel.0); + assert_eq!([255, 0, 0, 0], bottom_left_pixel.0); + assert_eq!([68, 186, 0, 0], bottom_right_pixel.0); +} + +#[actix_rt::test] +async fn test_heatmap_with_shadings_succeeds() { + let pool = init_test_database(|conn| { + async { + initial_db_values(conn, tall_rectangle()).await?; + diesel::insert_into(crate::schema::shadings::table) + .values(( + &crate::schema::shadings::id.eq(Uuid::new_v4()), + &crate::schema::shadings::layer_id.eq(-2), + &crate::schema::shadings::shade.eq(Shade::PermanentDeepShade), + &crate::schema::shadings::geometry.eq(small_square()), + )) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-2") + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/png")) + ); + let result = test::read_body(resp).await; + let result = &result.bytes().collect::, _>>().unwrap(); + let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); + let image = image.as_rgba8().unwrap(); + + // (0,0) is be top left. + let (x_dim, y_dim) = image.dimensions(); + let top_left_pixel = image.get_pixel(0, 0); + let bottom_right_pixel = image.get_pixel(x_dim - 1, y_dim - 1); + // The shading is the exact opposite of the plants preference, therefore the map will be red. + assert_eq!([186, 68, 0, 0], top_left_pixel.0); + // Plant like other positions, therefore green. + assert_eq!([68, 186, 0, 0], bottom_right_pixel.0); +} + +#[actix_rt::test] +async fn test_heatmap_with_shadings_and_light_requirement_succeeds() { + let pool = init_test_database(|conn| { + async { + initial_db_values(conn, tall_rectangle()).await?; + diesel::insert_into(crate::schema::plants::table) + .values(vec![ + ( + &crate::schema::plants::id.eq(-2), + &crate::schema::plants::unique_name.eq("Testia"), + &crate::schema::plants::common_name_en.eq(Some(vec![Some("T".to_owned())])), + &crate::schema::plants::shade.eq(Some(Shade::PermanentDeepShade)), + &crate::schema::plants::light_requirement + .eq(Some(vec![Some(LightRequirement::FullShade)])), + ), + ( + &crate::schema::plants::id.eq(-3), + &crate::schema::plants::unique_name.eq("Testia testum"), + &crate::schema::plants::common_name_en.eq(Some(vec![Some("T".to_owned())])), + &crate::schema::plants::shade.eq(Some(Shade::LightShade)), + &crate::schema::plants::light_requirement.eq(Some(vec![ + Some(LightRequirement::Full), + Some(LightRequirement::Partial), + ])), + ), + ]) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(( + &crate::schema::shadings::id.eq(Uuid::new_v4()), + &crate::schema::shadings::layer_id.eq(-2), + &crate::schema::shadings::shade.eq(Shade::PermanentDeepShade), + &crate::schema::shadings::geometry.eq(small_square()), + )) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&plant_layer_id=-1&shade_layer_id=-2") + .insert_header((header::AUTHORIZATION, token.clone())) + .send_request(&app) + .await; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/png")) + ); + let result = test::read_body(resp).await; + let result = &result.bytes().collect::, _>>().unwrap(); + let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); + let image = image.as_rgba8().unwrap(); + + // (0,0) is be top left. + let (x_dim, y_dim) = image.dimensions(); + let top_left_pixel = image.get_pixel(0, 0); + let bottom_right_pixel = image.get_pixel(x_dim - 1, y_dim - 1); + // The shading is deep shade with is ok for the plant. + assert_eq!([68, 186, 0, 0], top_left_pixel.0); + // The plant can't grow in sun. + assert_eq!([255, 0, 0, 255], bottom_right_pixel.0); + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-3&plant_layer_id=-1&shade_layer_id=-2") + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + + assert_eq!(resp.status(), StatusCode::OK); assert_eq!( - ((100 / GRANULARITY) as u32, (100 / GRANULARITY) as u32), - image.dimensions() + resp.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/png")) ); + let result = test::read_body(resp).await; + let result = &result.bytes().collect::, _>>().unwrap(); + let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); + let image = image.as_rgba8().unwrap(); // (0,0) is be top left. - let top_left_pixel = image.get_pixel(2, 2); - let top_right_pixel = image.get_pixel(8, 2); - let bottom_left_pixel = image.get_pixel(2, 8); - let bottom_right_pixel = image.get_pixel(8, 8); - assert_eq!([64, 191, 64], top_left_pixel.0); - assert_eq!([64, 191, 64], top_right_pixel.0); - assert_eq!([128, 128, 128], bottom_left_pixel.0); - assert_eq!([64, 191, 64], bottom_right_pixel.0); + let (x_dim, y_dim) = image.dimensions(); + let top_left_pixel = image.get_pixel(0, 0); + let bottom_right_pixel = image.get_pixel(x_dim - 1, y_dim - 1); + // The plant can't grow in deep shade. + assert_eq!([255, 0, 0, 255], top_left_pixel.0); + // The plant can grow in sun. + assert_eq!([127, 127, 0, 0], bottom_right_pixel.0); +} + +#[actix_rt::test] +async fn test_heatmap_with_plantings_succeeds() { + let pool = init_test_database(|conn| { + async { + initial_db_values(conn, tall_rectangle()).await?; + diesel::insert_into(crate::schema::plants::table) + .values(( + &crate::schema::plants::id.eq(-2), + &crate::schema::plants::unique_name.eq("Testia"), + &crate::schema::plants::common_name_en.eq(Some(vec![Some("T".to_owned())])), + )) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::relations::table) + .values(vec![( + &crate::schema::relations::plant1.eq(-1), + &crate::schema::relations::plant2.eq(-2), + &crate::schema::relations::relation.eq(RelationType::Companion), + )]) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::plantings::table) + .values(vec![data::TestInsertablePlanting { + id: Uuid::new_v4(), + layer_id: -1, + plant_id: -1, + x: 0, + y: 0, + ..Default::default() + }]) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&plant_layer_id=-1&shade_layer_id=-2") + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/png")) + ); + let result = test::read_body(resp).await; + let result = &result.bytes().collect::, _>>().unwrap(); + let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); + let image = image.as_rgba8().unwrap(); + + let (x_dim, y_dim) = image.dimensions(); + let on_planting = image.get_pixel(0, 0); + let close_to_planting = image.get_pixel(1, 1); + let far_away_from_planting = image.get_pixel(x_dim - 1, y_dim - 1); + // The planting influences the map. + assert!(on_planting.0[0] <= close_to_planting.0[0]); + assert!(on_planting.0[1] >= close_to_planting.0[1]); + assert!(on_planting.0[3] >= close_to_planting.0[3]); + // There is no influence on locations far away. + assert_eq!([127, 127, 0, 0], far_away_from_planting.0); +} + +#[actix_rt::test] +async fn test_heatmap_with_deleted_planting_succeeds() { + let pool = init_test_database(|conn| { + async { + initial_db_values(conn, tall_rectangle()).await?; + diesel::insert_into(crate::schema::plants::table) + .values(( + &crate::schema::plants::id.eq(-2), + &crate::schema::plants::unique_name.eq("Testia"), + &crate::schema::plants::common_name_en.eq(Some(vec![Some("T".to_owned())])), + )) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::relations::table) + .values(vec![( + &crate::schema::relations::plant1.eq(-1), + &crate::schema::relations::plant2.eq(-2), + &crate::schema::relations::relation.eq(RelationType::Companion), + )]) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::plantings::table) + .values(vec![data::TestInsertablePlanting { + id: Uuid::new_v4(), + layer_id: -1, + plant_id: -1, + x: 0, + y: 0, + remove_date: Some(NaiveDate::from_ymd_opt(2023, 07, 30).unwrap()), + ..Default::default() + }]) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&plant_layer_id=-1&shade_layer_id=-2") + .insert_header((header::AUTHORIZATION, token.clone())) + .send_request(&app) + .await; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/png")) + ); + let result = test::read_body(resp).await; + let result = &result.bytes().collect::, _>>().unwrap(); + let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); + let image = image.as_rgba8().unwrap(); + + let (x_dim, y_dim) = image.dimensions(); + let on_planting = image.get_pixel(0, 0); + let close_to_planting = image.get_pixel(1, 1); + let far_away_from_planting = image.get_pixel(x_dim - 1, y_dim - 1); + // The planting doesn't influences the map as it is deleted. + assert_eq!([127, 127, 0, 0], on_planting.0); + assert_eq!([127, 127, 0, 0], close_to_planting.0); + assert_eq!([127, 127, 0, 0], far_away_from_planting.0); + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&plant_layer_id=-1&shade_layer_id=-2&date=2023-07-21") + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get(header::CONTENT_TYPE), + Some(&header::HeaderValue::from_static("image/png")) + ); + let result = test::read_body(resp).await; + let result = &result.bytes().collect::, _>>().unwrap(); + let image = load_from_memory_with_format(result.as_slice(), image::ImageFormat::Png).unwrap(); + let image_rgba8 = image.as_rgba8().unwrap(); + + let on_planting = image_rgba8.get_pixel(0, 0); + let close_to_planting = image_rgba8.get_pixel(1, 1); + // The planting influences the map as we set the date back in the query. + assert!(on_planting.0[0] <= close_to_planting.0[0]); + assert!(on_planting.0[1] >= close_to_planting.0[1]); + assert!(on_planting.0[3] >= close_to_planting.0[3]); } #[actix_rt::test] @@ -195,23 +495,31 @@ async fn test_missing_entities_fails() { // Invalid map id let resp = test::TestRequest::get() - .uri("/api/maps/-2/layers/plants/heatmap?plant_id=-1&layer_id=-1") + .uri("/api/maps/-2/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-2") .insert_header((header::AUTHORIZATION, token.clone())) .send_request(&app) .await; assert_eq!(resp.status(), StatusCode::NOT_FOUND); - // Invalid layer id + // Invalid plant id let resp = test::TestRequest::get() - .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&layer_id=-1") + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&plant_layer_id=-1&shade_layer_id=-2") .insert_header((header::AUTHORIZATION, token.clone())) .send_request(&app) .await; assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); - // Invalid plant id + // Invalid plant layer id + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-2&plant_layer_id=-5&shade_layer_id=-2") + .insert_header((header::AUTHORIZATION, token.clone())) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // Invalid shade layer id let resp = test::TestRequest::get() - .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&layer_id=-2") + .uri("/api/maps/-1/layers/plants/heatmap?plant_id=-1&plant_layer_id=-1&shade_layer_id=-5") .insert_header((header::AUTHORIZATION, token)) .send_request(&app) .await; diff --git a/backend/src/test/plantings.rs b/backend/src/test/plantings.rs index ad22d3894..daf45c377 100644 --- a/backend/src/test/plantings.rs +++ b/backend/src/test/plantings.rs @@ -34,8 +34,8 @@ async fn test_can_search_plantings() { .await?; diesel::insert_into(crate::schema::layers::table) .values(vec![ - data::TestInsertableLayer::default(), - data::TestInsertableLayer { + data::TestInsertablePlantLayer::default(), + data::TestInsertablePlantLayer { id: -2, name: "Test Layer 2".to_owned(), is_alternative: true, @@ -105,7 +105,7 @@ async fn test_create_fails_with_invalid_layer() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer { + .values(data::TestInsertablePlantLayer { type_: LayerType::Base, ..Default::default() }) @@ -157,7 +157,7 @@ async fn test_can_create_plantings() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) + .values(data::TestInsertablePlantLayer::default()) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) @@ -208,7 +208,7 @@ async fn test_can_update_plantings() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) + .values(data::TestInsertablePlantLayer::default()) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) @@ -277,7 +277,7 @@ async fn test_can_delete_planting() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) + .values(data::TestInsertablePlantLayer::default()) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) @@ -336,7 +336,7 @@ async fn test_removed_planting_outside_loading_offset_is_not_in_timeline() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) + .values(data::TestInsertablePlantLayer::default()) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) @@ -386,7 +386,7 @@ async fn test_removed_planting_inside_loading_offset_is_in_timeline() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) + .values(data::TestInsertablePlantLayer::default()) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) @@ -435,7 +435,7 @@ async fn test_added_planting_outside_loading_offset_is_not_in_timeline() { .execute(conn) .await?; diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) + .values(data::TestInsertablePlantLayer::default()) .execute(conn) .await?; diesel::insert_into(crate::schema::plants::table) diff --git a/backend/src/test/shadings.rs b/backend/src/test/shadings.rs new file mode 100644 index 000000000..cabded9b9 --- /dev/null +++ b/backend/src/test/shadings.rs @@ -0,0 +1,403 @@ +//! Tests for [`crate::controller::shadings`]. + +use std::ops::Add; + +use actix_http::StatusCode; +use actix_web::{http::header, test}; +use chrono::{Days, NaiveDate}; +use diesel_async::{scoped_futures::ScopedFutureExt, RunQueryDsl}; +use uuid::Uuid; + +use crate::{ + model::{ + dto::{ + core::TimelinePage, + shadings::{ + DeleteShadingDto, NewShadingDto, ShadingDto, UpdateShadingDto, + UpdateValuesShadingDto, + }, + }, + r#enum::{layer_type::LayerType, shade::Shade}, + }, + service::shadings::TIME_LINE_LOADING_OFFSET_DAYS, + test::util::{data, dummy_map_polygons::small_rectangle}, +}; + +use crate::test::util::{init_test_app, init_test_database}; + +#[actix_rt::test] +async fn test_can_search_shadings() { + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(vec![ + data::TestInsertableShadeLayer::default(), + data::TestInsertableShadeLayer { + id: -2, + name: "Test Layer 2".to_owned(), + is_alternative: true, + ..Default::default() + }, + ]) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(vec![ + data::TestInsertableShading { + id: Uuid::new_v4(), + layer_id: -1, + ..Default::default() + }, + data::TestInsertableShading { + id: Uuid::new_v4(), + layer_id: -2, + ..Default::default() + }, + ]) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/shade/shadings") + .insert_header((header::AUTHORIZATION, token.clone())) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let page: TimelinePage = test::read_body_json(resp).await; + assert_eq!(page.results.len(), 1); + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/shade/shadings") + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let page: TimelinePage = test::read_body_json(resp).await; + assert_eq!(page.results.len(), 2); +} + +#[actix_rt::test] +async fn test_create_fails_with_invalid_layer() { + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer { + type_: LayerType::Base, + ..Default::default() + }) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let new_shading = NewShadingDto { + id: Some(Uuid::new_v4()), + shade: Shade::LightShade, + geometry: small_rectangle(), + layer_id: -1, + add_date: None, + }; + + let mut shading_vec = Vec::new(); + shading_vec.push(new_shading); + + let resp = test::TestRequest::post() + .uri("/api/maps/-1/layers/shade/shadings") + .insert_header((header::AUTHORIZATION, token)) + .set_json(shading_vec) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); +} + +#[actix_rt::test] +async fn test_can_create_shadings() { + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer::default()) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let new_shading = NewShadingDto { + id: Some(Uuid::new_v4()), + layer_id: -1, + shade: Shade::LightShade, + geometry: small_rectangle(), + add_date: None, + }; + + let mut shading_vec = Vec::new(); + shading_vec.push(new_shading); + + let resp = test::TestRequest::post() + .uri("/api/maps/-1/layers/shade/shadings") + .insert_header((header::AUTHORIZATION, token)) + .set_json(shading_vec) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::CREATED); +} + +#[actix_rt::test] +async fn test_can_update_shadings() { + let shading_id = Uuid::new_v4(); + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(data::TestInsertableShading { + id: shading_id, + ..Default::default() + }) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let update_data = UpdateValuesShadingDto { + id: shading_id, + shade: Some(Shade::PermanentDeepShade), + geometry: None, + }; + let mut update_vec = Vec::new(); + update_vec.push(update_data); + + let update_object = UpdateShadingDto::Update(update_vec); + + let resp = test::TestRequest::patch() + .uri(&format!("/api/maps/-1/layers/shade/shadings/")) + .insert_header((header::AUTHORIZATION, token)) + .set_json(update_object) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let shading: Vec = test::read_body_json(resp).await; + assert_eq!(shading[0].shade, Shade::PermanentDeepShade); +} + +#[actix_rt::test] +async fn test_can_delete_shading() { + let shading_id = Uuid::new_v4(); + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(data::TestInsertableShading { + id: shading_id, + ..Default::default() + }) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let mut delete_vec = Vec::new(); + delete_vec.push(DeleteShadingDto { id: shading_id }); + + let resp = test::TestRequest::delete() + .uri(&format!("/api/maps/-1/layers/shade/shadings/",)) + .insert_header((header::AUTHORIZATION, token.clone())) + .set_json(delete_vec) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let resp = test::TestRequest::get() + .uri("/api/maps/-1/layers/shade/shadings?relative_to_date=2023-05-08") + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let page: TimelinePage = test::read_body_json(resp).await; + assert_eq!(page.results.len(), 0); +} + +#[actix_rt::test] +async fn test_removed_shading_outside_loading_offset_is_not_in_timeline() { + let shading_id = Uuid::new_v4(); + let remove_date = NaiveDate::from_ymd_opt(2022, 1, 1).expect("date is valid"); + + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(data::TestInsertableShading { + id: shading_id, + remove_date: Some(remove_date), + ..Default::default() + }) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri(&format!( + "/api/maps/-1/layers/shade/shadings?relative_to_date={}", + remove_date + .add(Days::new(TIME_LINE_LOADING_OFFSET_DAYS)) + .format("%Y-%m-%d"), + )) + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let page: TimelinePage = test::read_body_json(resp).await; + assert_eq!(page.results.len(), 0); +} + +#[actix_rt::test] +async fn test_removed_shading_inside_loading_offset_is_in_timeline() { + let shading_id = Uuid::new_v4(); + let remove_date = NaiveDate::from_ymd_opt(2022, 1, 1).expect("date is valid"); + + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(data::TestInsertableShading { + id: shading_id, + remove_date: Some(remove_date), + ..Default::default() + }) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri(&format!( + "/api/maps/-1/layers/shade/shadings?relative_to_date={}", + remove_date.add(Days::new(1)).format("%Y-%m-%d"), + )) + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let page: TimelinePage = test::read_body_json(resp).await; + assert_eq!(page.results.len(), 1); +} + +#[actix_rt::test] +async fn test_added_shading_outside_loading_offset_is_not_in_timeline() { + let shading_id = Uuid::new_v4(); + let current_date = NaiveDate::from_ymd_opt(2022, 1, 1).expect("date is valid"); + let add_date = current_date.add(Days::new(TIME_LINE_LOADING_OFFSET_DAYS)); + + let pool = init_test_database(|conn| { + async { + diesel::insert_into(crate::schema::maps::table) + .values(data::TestInsertableMap::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::layers::table) + .values(data::TestInsertableShadeLayer::default()) + .execute(conn) + .await?; + diesel::insert_into(crate::schema::shadings::table) + .values(data::TestInsertableShading { + id: shading_id, + add_date: Some(add_date), + ..Default::default() + }) + .execute(conn) + .await?; + Ok(()) + } + .scope_boxed() + }) + .await; + let (token, app) = init_test_app(pool.clone()).await; + + let resp = test::TestRequest::get() + .uri(&format!( + "/api/maps/-1/layers/shade/shadings?relative_to_date={}", + current_date.format("%Y-%m-%d"), + )) + .insert_header((header::AUTHORIZATION, token)) + .send_request(&app) + .await; + assert_eq!(resp.status(), StatusCode::OK); + + let page: TimelinePage = test::read_body_json(resp).await; + assert_eq!(page.results.len(), 0); +} diff --git a/backend/src/test/timeline.rs b/backend/src/test/timeline.rs index d82ea91ff..5844e7a4a 100644 --- a/backend/src/test/timeline.rs +++ b/backend/src/test/timeline.rs @@ -19,10 +19,6 @@ async fn initial_db_values(conn: &mut AsyncPgConnection) -> Result<(), ServiceEr .values(data::TestInsertableMap::default()) .execute(conn) .await?; - diesel::insert_into(crate::schema::layers::table) - .values(data::TestInsertableLayer::default()) - .execute(conn) - .await?; diesel::insert_into(crate::schema::plants::table) .values(data::TestInsertablePlant::default()) .execute(conn) diff --git a/backend/src/test/util/data.rs b/backend/src/test/util/data.rs index 45efecf6c..652851cbb 100644 --- a/backend/src/test/util/data.rs +++ b/backend/src/test/util/data.rs @@ -5,9 +5,9 @@ use diesel::Insertable; use postgis_diesel::types::{Point, Polygon}; use uuid::Uuid; -use crate::model::r#enum::{layer_type::LayerType, privacy_option::PrivacyOption}; +use crate::model::r#enum::{layer_type::LayerType, privacy_option::PrivacyOption, shade::Shade}; -use super::dummy_map_polygons::tall_rectangle; +use super::dummy_map_polygons::{small_rectangle, tall_rectangle}; #[derive(Insertable)] #[diesel(table_name = crate::schema::maps)] @@ -45,7 +45,7 @@ impl Default for TestInsertableMap { #[derive(Insertable)] #[diesel(table_name = crate::schema::layers)] -pub struct TestInsertableLayer { +pub struct TestInsertablePlantLayer { pub id: i32, pub map_id: i32, pub type_: LayerType, @@ -53,7 +53,7 @@ pub struct TestInsertableLayer { pub is_alternative: bool, } -impl Default for TestInsertableLayer { +impl Default for TestInsertablePlantLayer { fn default() -> Self { Self { id: -1, @@ -65,6 +65,28 @@ impl Default for TestInsertableLayer { } } +#[derive(Insertable)] +#[diesel(table_name = crate::schema::layers)] +pub struct TestInsertableShadeLayer { + pub id: i32, + pub map_id: i32, + pub type_: LayerType, + pub name: String, + pub is_alternative: bool, +} + +impl Default for TestInsertableShadeLayer { + fn default() -> Self { + Self { + id: -1, + map_id: -1, + type_: LayerType::Shade, + name: "Test Layer 1".to_owned(), + is_alternative: false, + } + } +} + #[derive(Insertable)] #[diesel(table_name = crate::schema::plants)] pub struct TestInsertablePlant { @@ -116,3 +138,27 @@ impl Default for TestInsertablePlanting { } } } + +#[derive(Insertable)] +#[diesel(table_name = crate::schema::shadings)] +pub struct TestInsertableShading { + pub id: Uuid, + pub layer_id: i32, + pub shade: Shade, + pub geometry: Polygon, + pub add_date: Option, + pub remove_date: Option, +} + +impl Default for TestInsertableShading { + fn default() -> Self { + Self { + id: Uuid::default(), + layer_id: -1, + shade: Shade::NoShade, + geometry: small_rectangle(), + add_date: None, + remove_date: None, + } + } +} diff --git a/backend/src/test/util/dummy_map_polygons.rs b/backend/src/test/util/dummy_map_polygons.rs index 074071db7..497f24a27 100644 --- a/backend/src/test/util/dummy_map_polygons.rs +++ b/backend/src/test/util/dummy_map_polygons.rs @@ -132,3 +132,34 @@ pub fn rectangle_with_missing_bottom_left_corner() -> Polygon { }); serde_json::from_value(polygon).unwrap() } + +pub fn small_square() -> Polygon { + let polygon = json!({ + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 20.0, + "y": 0.0 + }, + { + "x": 20.0, + "y": 20.0 + }, + { + "x": 0.0, + "y": 20.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + }); + serde_json::from_value(polygon).unwrap() +} diff --git a/backend/typeshare.toml b/backend/typeshare.toml index b5be91d8e..059e413c5 100644 --- a/backend/typeshare.toml +++ b/backend/typeshare.toml @@ -2,3 +2,4 @@ "NaiveDate" = "string" "Uuid" = "string" "Value" = "object" +"Polygon" = "object" diff --git a/benchmarks/README.md b/benchmarks/README.md index 2a79327d1..a233318fe 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -1,4 +1,4 @@ -# PermaplanT Performance Audit +# Benchmarks ## Requirements diff --git a/benchmarks/backend/README.md b/benchmarks/backend/README.md new file mode 100644 index 000000000..dc9921a2f --- /dev/null +++ b/benchmarks/backend/README.md @@ -0,0 +1,3 @@ +# Backend Benchmarks + +Documentation about the backend benchmarks can be found [here](../../doc/backend/06performance_benchmarks.md). diff --git a/benchmarks/backend/config/get_statistics.sh b/benchmarks/backend/config/get_statistics.sh new file mode 100755 index 000000000..c38a3b159 --- /dev/null +++ b/benchmarks/backend/config/get_statistics.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env nu + +cd backend + +print "Requests:" +grep 'Total: connections ' httperf.log | awk '{print "total:", $3}' +grep 'Connection time \[ms\]: min' httperf.log | awk '{print "min:", $5, "ms\navg:", $7, "ms\nmax:", $9, "ms"}' diff --git a/benchmarks/backend/config/run_httperf.sh b/benchmarks/backend/config/run_httperf.sh new file mode 100755 index 000000000..816e718e2 --- /dev/null +++ b/benchmarks/backend/config/run_httperf.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Run httperf +httperf --server localhost --port 8080 --uri '/api/config' --num-conns 10000 --rate 100 --add-header="Authorization:Bearer ${access_token}\n" > backend/httperf.log 2>&1 diff --git a/benchmarks/backend/config/setup.sh b/benchmarks/backend/config/setup.sh new file mode 100755 index 000000000..ac14e55b1 --- /dev/null +++ b/benchmarks/backend/config/setup.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# Start the database and run migrations +cd backend +docker run -d --name postgis -e POSTGRES_PASSWORD=permaplant -e POSTGRES_USER=permaplant -p 5432:5432 postgis/postgis:13-3.1 -c log_min_duration_statement=0 -c log_statement=all +sleep 10 +LC_ALL=C diesel setup +LC_ALL=C diesel migration run + +# Start the backend +cd ../backend +RUST_LOG="backend=warn,actix_web=warn" PERF=/usr/lib/linux-tools/5.4.0-153-generic/perf cargo flamegraph --open + +# Remove database +docker kill postgis +docker rm postgis diff --git a/benchmarks/backend/heatmap/get_statistics.sh b/benchmarks/backend/heatmap/get_statistics.sh new file mode 100755 index 000000000..d95f745f3 --- /dev/null +++ b/benchmarks/backend/heatmap/get_statistics.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env nu + +cd backend + +grep ' UTC \[[0-9]*\] ' postgis.log | lines | split column -r '\[|\]' | group-by column2 | values | each {|pid| $pid | each {|el| $"($el.column1)[($el.column2)]($el.column3)" } } | flatten | save -f postgis_parsed.log + +print "SQL queries:" +grep 'LOG: execute s.*: SELECT \* FROM calculate_heatmap' postgis_parsed.log -A 2 + | grep 'LOG: duration: .* ms$' + | awk 'NR == 1 {count=1; sum=$7; min=$7; max=$7} + NR > 1 {count++; sum+=$7; if ($7<0+min) min=$7; if ($7>0+max) max=$7} + END {print "total:", count, "\nmin:", min, "ms\navg:", sum/count, "ms\nmax:", max, "ms"}' + +print "" + +print "Requests:" +grep 'Total: connections ' httperf.log | awk '{print "total:", $3}' +grep 'Connection time \[ms\]: min' httperf.log | awk '{print "min:", $5, "ms\navg:", $7, "ms\nmax:", $9, "ms"}' diff --git a/benchmarks/backend/heatmap/insert_data-large_map.sh b/benchmarks/backend/heatmap/insert_data-large_map.sh new file mode 100755 index 000000000..ab6bce3d3 --- /dev/null +++ b/benchmarks/backend/heatmap/insert_data-large_map.sh @@ -0,0 +1,717 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Create map +curl --location 'http://localhost:8080/api/maps' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "Test Map", + "creation_date": "2023-07-25", + "is_inactive": false, + "zoom_factor": 100, + "honors": 0, + "visits": 0, + "harvested": 0, + "privacy": "public", + "description": "", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 10000.0, + "y": 0.0 + }, + { + "x": 10000.0, + "y": 10000.0 + }, + { + "x": 0.0, + "y": 10000.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + } +}' + +# Create plantings +DATABASE_NAME="permaplant" +DATABASE_USER="permaplant" +PGPASSWORD=permaplant psql -h localhost -p 5432 -U $DATABASE_USER -d $DATABASE_NAME -c " +INSERT INTO "plantings" ("id", "layer_id", "plant_id", "x", "y", "width", "height", "rotation", "scale_x", "scale_y", + "add_date", "remove_date") +VALUES ('00000000-0000-0000-0000-000000000000', 2, 4506, 0300, 1200, 0, 0, 0, 0, 0, null, null), -- sweet cherry + + ('00000000-0000-0000-0000-000000000001', 2, 4532, 0400, 0200, 0, 0, 0, 0, 0, null, null), -- european plum + + ('00000000-0000-0000-0000-000000000002', 2, 1658, 0000, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000003', 2, 1658, 0010, 0005, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000004', 2, 1658, 0300, 0010, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000005', 2, 1658, 0350, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000006', 2, 1658, 0400, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000007', 2, 1658, 0500, 0020, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000008', 2, 1658, 0520, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000009', 2, 1658, 0550, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000010', 2, 1658, 0600, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000011', 2, 1658, 0610, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000012', 2, 1658, 0620, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000013', 2, 1658, 0650, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000014', 2, 1658, 0800, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000015', 2, 1658, 0820, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000016', 2, 1658, 0880, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000017', 2, 1658, 0900, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-000000000018', 2, 1658, 0080, 0600, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000019', 2, 1658, 0080, 0650, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000020', 2, 1658, 0080, 0700, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000021', 2, 1658, 0080, 0750, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000022', 2, 1658, 0080, 0800, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000023', 2, 1658, 0080, 0850, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-000000000024', 2, 5557, 0600, 1050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-000000000025', 2, 5557, 0650, 1050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-000000000026', 2, 5557, 0700, 1050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-000000000027', 2, 0355, 0740, 1050, 0, 0, 0, 0, 0, null, null), -- chives + ('00000000-0000-0000-0000-000000000028', 2, 0355, 0760, 1050, 0, 0, 0, 0, 0, null, null), -- chives + ('00000000-0000-0000-0000-000000000029', 2, 6247, 0550, 1030, 0, 0, 0, 0, 0, null, null), -- rhubarb + ('00000000-0000-0000-0000-000000000030', 2, 7708, 0550, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000031', 2, 7708, 0575, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000032', 2, 7708, 0600, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000033', 2, 7708, 0625, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000034', 2, 7708, 0650, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000035', 2, 7708, 0675, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + + ('00000000-0000-0000-0000-000000000036', 2, 5807, 0000, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000037', 2, 5807, 0100, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000038', 2, 5807, 0200, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000039', 2, 5807, 0300, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000040', 2, 5807, 0400, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000041', 2, 5807, 0500, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000042', 2, 5807, 0600, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-000000000043', 2, 5807, 0700, 1970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + + + + ('00000000-0000-0000-0000-100000000000', 2, 4506, 5300, 6200, 0, 0, 0, 0, 0, null, null), -- sweet cherry + + ('00000000-0000-0000-0000-100000000001', 2, 4532, 5400, 5200, 0, 0, 0, 0, 0, null, null), -- european plum + + ('00000000-0000-0000-0000-100000000002', 2, 1658, 5000, 5000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000003', 2, 1658, 5010, 5005, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000004', 2, 1658, 5300, 5010, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000005', 2, 1658, 5350, 5000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000006', 2, 1658, 5400, 5000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000007', 2, 1658, 5500, 5020, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000008', 2, 1658, 5520, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000009', 2, 1658, 5550, 5000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000010', 2, 1658, 5600, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000011', 2, 1658, 5610, 5000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000012', 2, 1658, 5620, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000013', 2, 1658, 5650, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000014', 2, 1658, 5800, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000015', 2, 1658, 5820, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000016', 2, 1658, 5880, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000017', 2, 1658, 5900, 5015, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-100000000018', 2, 1658, 5080, 5600, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000019', 2, 1658, 5080, 5650, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000020', 2, 1658, 5080, 5700, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000021', 2, 1658, 5080, 5750, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000022', 2, 1658, 5080, 5800, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-100000000023', 2, 1658, 5080, 5850, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-100000000024', 2, 5557, 5600, 6050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-100000000025', 2, 5557, 5650, 6050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-100000000026', 2, 5557, 5700, 6050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-100000000027', 2, 0355, 5740, 6050, 0, 0, 0, 0, 0, null, null), -- chives + ('00000000-0000-0000-0000-100000000028', 2, 0355, 5760, 6050, 0, 0, 0, 0, 0, null, null), -- chives + ('00000000-0000-0000-0000-100000000029', 2, 6247, 5550, 6030, 0, 0, 0, 0, 0, null, null), -- rhubarb + ('00000000-0000-0000-0000-100000000030', 2, 7708, 5550, 6000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-100000000031', 2, 7708, 5575, 6000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-100000000032', 2, 7708, 5600, 6000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-100000000033', 2, 7708, 5625, 6000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-100000000034', 2, 7708, 5650, 6000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-100000000035', 2, 7708, 5675, 6000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + + ('00000000-0000-0000-0000-100000000036', 2, 5807, 5000, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000037', 2, 5807, 5100, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000038', 2, 5807, 5200, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000039', 2, 5807, 5300, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000040', 2, 5807, 5400, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000041', 2, 5807, 5500, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000042', 2, 5807, 5600, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-100000000043', 2, 5807, 5700, 6970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + + + + ('00000000-0000-0000-0000-200000000000', 2, 4506, 8300, 8200, 0, 0, 0, 0, 0, null, null), -- sweet cherry + + ('00000000-0000-0000-0000-200000000001', 2, 4532, 8400, 8200, 0, 0, 0, 0, 0, null, null), -- european plum + + ('00000000-0000-0000-0000-200000000002', 2, 1658, 8000, 8000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000003', 2, 1658, 8010, 8005, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000004', 2, 1658, 8300, 8010, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000005', 2, 1658, 8350, 8000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000006', 2, 1658, 8400, 8000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000007', 2, 1658, 8500, 8020, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000008', 2, 1658, 8520, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000009', 2, 1658, 8550, 8000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000010', 2, 1658, 8600, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000011', 2, 1658, 8610, 8000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000012', 2, 1658, 8620, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000013', 2, 1658, 8650, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000014', 2, 1658, 8800, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000015', 2, 1658, 8820, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000016', 2, 1658, 8880, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000017', 2, 1658, 8900, 8015, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-200000000018', 2, 1658, 8080, 8600, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000019', 2, 1658, 8080, 8650, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000020', 2, 1658, 8080, 8700, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000021', 2, 1658, 8080, 8750, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000022', 2, 1658, 8080, 8800, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-200000000023', 2, 1658, 8080, 8850, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-200000000024', 2, 5557, 8600, 8050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-200000000025', 2, 5557, 8650, 8050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-200000000026', 2, 5557, 8700, 8050, 0, 0, 0, 0, 0, null, null), -- tomato + ('00000000-0000-0000-0000-200000000027', 2, 0355, 8740, 8050, 0, 0, 0, 0, 0, null, null), -- chives + ('00000000-0000-0000-0000-200000000028', 2, 0355, 8760, 8050, 0, 0, 0, 0, 0, null, null), -- chives + ('00000000-0000-0000-0000-200000000029', 2, 6247, 8550, 8030, 0, 0, 0, 0, 0, null, null), -- rhubarb + ('00000000-0000-0000-0000-200000000030', 2, 7708, 8550, 8000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-200000000031', 2, 7708, 8575, 8000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-200000000032', 2, 7708, 8600, 8000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-200000000033', 2, 7708, 8625, 8000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-200000000034', 2, 7708, 8650, 8000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-200000000035', 2, 7708, 8675, 8000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + + ('00000000-0000-0000-0000-200000000036', 2, 5807, 8000, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000037', 2, 5807, 8100, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000038', 2, 5807, 8200, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000039', 2, 5807, 8300, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000040', 2, 5807, 8400, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000041', 2, 5807, 8500, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000042', 2, 5807, 8600, 8970, 0, 0, 0, 0, 0, null, null), -- Thuja plicata + ('00000000-0000-0000-0000-200000000043', 2, 5807, 8700, 8970, 0, 0, 0, 0, 0, null, null) -- Thuja plicata +; +" + +# Create shadings +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "partial shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 250.0, + "y": 50.0 + }, + { + "x": 400.0, + "y": 12.26 + }, + { + "x": 550.0, + "y": 50.0 + }, + { + "x": 612.26, + "y": 200.0 + }, + { + "x": 550.0, + "y": 350.0 + }, + { + "x": 400.0, + "y": 412.26 + }, + { + "x": 250.0, + "y": 350.0 + }, + { + "x": 187.74, + "y": 200.0 + }, + { + "x": 250.0, + "y": 50.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 400.0 + }, + { + "x": 100.0, + "y": 400.0 + }, + { + "x": 100.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 400.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 900.0 + }, + { + "x": 800.0, + "y": 900.0 + }, + { + "x": 800.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 900.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 150.0, + "y": 1050.0 + }, + { + "x": 300.0, + "y": 1012.26 + }, + { + "x": 450.0, + "y": 1050.0 + }, + { + "x": 512.26, + "y": 1200.0 + }, + { + "x": 450.0, + "y": 1350.0 + }, + { + "x": 300.0, + "y": 1412.26 + }, + { + "x": 150.0, + "y": 1350.0 + }, + { + "x": 87.74, + "y": 1200.0 + }, + { + "x": 150.0, + "y": 1050.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent deep shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 1800.0 + }, + { + "x": 800.0, + "y": 1800.0 + }, + { + "x": 800.0, + "y": 2000.0 + }, + { + "x": 0.0, + "y": 2000.0 + }, + { + "x": 0.0, + "y": 1800.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + + + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "partial shade", + "geometry": { + "rings": [ + [ + { + "x": 5000.0, + "y": 5000.0 + }, + { + "x": 6000.0, + "y": 5000.0 + }, + { + "x": 6000.0, + "y": 5200.0 + }, + { + "x": 5000.0, + "y": 5200.0 + }, + { + "x": 5000.0, + "y": 5000.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 5250.0, + "y": 5050.0 + }, + { + "x": 5400.0, + "y": 5012.26 + }, + { + "x": 5550.0, + "y": 5050.0 + }, + { + "x": 5612.26, + "y": 5200.0 + }, + { + "x": 5550.0, + "y": 5350.0 + }, + { + "x": 5400.0, + "y": 5412.26 + }, + { + "x": 5250.0, + "y": 5350.0 + }, + { + "x": 5187.74, + "y": 5200.0 + }, + { + "x": 5250.0, + "y": 5050.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 5000.0, + "y": 5400.0 + }, + { + "x": 5100.0, + "y": 5400.0 + }, + { + "x": 5100.0, + "y": 6000.0 + }, + { + "x": 5000.0, + "y": 6000.0 + }, + { + "x": 5000.0, + "y": 5400.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 5000.0, + "y": 5900.0 + }, + { + "x": 5800.0, + "y": 5900.0 + }, + { + "x": 5800.0, + "y": 6000.0 + }, + { + "x": 5000.0, + "y": 6000.0 + }, + { + "x": 5000.0, + "y": 5900.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 5150.0, + "y": 6050.0 + }, + { + "x": 5300.0, + "y": 6012.26 + }, + { + "x": 5450.0, + "y": 6050.0 + }, + { + "x": 5512.26, + "y": 6200.0 + }, + { + "x": 5450.0, + "y": 6350.0 + }, + { + "x": 5300.0, + "y": 6412.26 + }, + { + "x": 5150.0, + "y": 6350.0 + }, + { + "x": 5087.74, + "y": 6200.0 + }, + { + "x": 5150.0, + "y": 6050.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent deep shade", + "geometry": { + "rings": [ + [ + { + "x": 5000.0, + "y": 6800.0 + }, + { + "x": 5800.0, + "y": 6800.0 + }, + { + "x": 5800.0, + "y": 7000.0 + }, + { + "x": 5000.0, + "y": 7000.0 + }, + { + "x": 5000.0, + "y": 6800.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' diff --git a/benchmarks/backend/heatmap/insert_data-middle_map.sh b/benchmarks/backend/heatmap/insert_data-middle_map.sh new file mode 100755 index 000000000..827d87e0e --- /dev/null +++ b/benchmarks/backend/heatmap/insert_data-middle_map.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Create map +curl --location 'http://localhost:8080/api/maps' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "My Garden", + "creation_date": "2023-07-31", + "is_inactive": false, + "zoom_factor": 100, + "honors": 0, + "visits": 0, + "harvested": 0, + "privacy": "public", + "description": "", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + } +}' + +# Create plantings +DATABASE_NAME="permaplant" +DATABASE_USER="permaplant" +PGPASSWORD=permaplant psql -h localhost -p 5432 -U $DATABASE_USER -d $DATABASE_NAME -c " +INSERT INTO "plantings" ("id", "layer_id", "plant_id", "x", "y", "width", "height", "rotation", "scale_x", "scale_y", + "add_date", "remove_date") +VALUES ('00000000-0000-0000-0000-000000000001', 2, 4532, 0400, 0200, 0, 0, 0, 0, 0, null, null), -- european plum + + ('00000000-0000-0000-0000-000000000002', 2, 1658, 0000, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000003', 2, 1658, 0010, 0005, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000004', 2, 1658, 0300, 0010, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000005', 2, 1658, 0350, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000006', 2, 1658, 0400, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000007', 2, 1658, 0500, 0020, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000008', 2, 1658, 0520, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000009', 2, 1658, 0550, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000010', 2, 1658, 0600, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000011', 2, 1658, 0610, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000012', 2, 1658, 0620, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000013', 2, 1658, 0650, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000014', 2, 1658, 0800, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000015', 2, 1658, 0820, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000016', 2, 1658, 0880, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000017', 2, 1658, 0900, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-000000000018', 2, 1658, 0080, 0600, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000019', 2, 1658, 0080, 0650, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000020', 2, 1658, 0080, 0700, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000021', 2, 1658, 0080, 0750, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000022', 2, 1658, 0080, 0800, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000023', 2, 1658, 0080, 0850, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-000000000030', 2, 7708, 0550, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000031', 2, 7708, 0575, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000032', 2, 7708, 0600, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000033', 2, 7708, 0625, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000034', 2, 7708, 0650, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000035', 2, 7708, 0675, 1000, 0, 0, 0, 0, 0, null, null) -- Iris germanica +; +" + +# Create shadings +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "partial shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 250.0, + "y": 50.0 + }, + { + "x": 400.0, + "y": 12.26 + }, + { + "x": 550.0, + "y": 50.0 + }, + { + "x": 612.26, + "y": 200.0 + }, + { + "x": 550.0, + "y": 350.0 + }, + { + "x": 400.0, + "y": 412.26 + }, + { + "x": 250.0, + "y": 350.0 + }, + { + "x": 187.74, + "y": 200.0 + }, + { + "x": 250.0, + "y": 50.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 400.0 + }, + { + "x": 100.0, + "y": 400.0 + }, + { + "x": 100.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 400.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 900.0 + }, + { + "x": 800.0, + "y": 900.0 + }, + { + "x": 800.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 900.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' diff --git a/benchmarks/backend/heatmap/insert_data-small_map.sh b/benchmarks/backend/heatmap/insert_data-small_map.sh new file mode 100755 index 000000000..436dd0581 --- /dev/null +++ b/benchmarks/backend/heatmap/insert_data-small_map.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Create map +curl --location 'http://localhost:8080/api/maps' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "Test Map", + "creation_date": "2023-07-25", + "is_inactive": false, + "zoom_factor": 100, + "honors": 0, + "visits": 0, + "harvested": 0, + "privacy": "public", + "description": "", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 100.0, + "y": 0.0 + }, + { + "x": 100.0, + "y": 100.0 + }, + { + "x": 0.0, + "y": 100.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + } +}' + +# Create plantings +DATABASE_NAME="permaplant" +DATABASE_USER="permaplant" +PGPASSWORD=permaplant psql -h localhost -p 5432 -U $DATABASE_USER -d $DATABASE_NAME -c " +INSERT INTO plantings (id, layer_id, plant_id, x, y, width, height, rotation, scale_x, scale_y, add_date, remove_date) +VALUES + ('00000000-0000-0000-0000-000000000000', 2, 1, 15, 15, 0, 0, 0, 0, 0, DEFAULT, DEFAULT), + ('00000000-0000-0000-0000-000000000001', 2, 2, 20, 30, 0, 0, 0, 0, 0, DEFAULT, DEFAULT); +" + +# Create shadings +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "partial shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 200.0, + "y": 0.0 + }, + { + "x": 200.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' diff --git a/benchmarks/backend/heatmap/run_httperf.sh b/benchmarks/backend/heatmap/run_httperf.sh new file mode 100755 index 000000000..483650188 --- /dev/null +++ b/benchmarks/backend/heatmap/run_httperf.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 +number_of_requests=$3 +request_rate=$4 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Run httperf +httperf --server localhost --port 8080 --uri '/api/maps/1/layers/plants/heatmap?plant_id=1&plant_layer_id=2&shade_layer_id=3' --num-conns $number_of_requests --rate $request_rate --add-header="Authorization:Bearer ${access_token}\n" > backend/httperf.log 2>&1 diff --git a/benchmarks/backend/heatmap/setup.sh b/benchmarks/backend/heatmap/setup.sh new file mode 100755 index 000000000..01665ba56 --- /dev/null +++ b/benchmarks/backend/heatmap/setup.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +# Start the database and run migrations +cd backend +docker run -d --name postgis -e POSTGRES_PASSWORD=permaplant -e POSTGRES_USER=permaplant -p 5432:5432 postgis/postgis:13-3.1 -c log_min_duration_statement=0 -c log_statement=all +sleep 10 +LC_ALL=C diesel setup +LC_ALL=C diesel migration run + +# Insert data +cd ../scraper && npm run insert +printf "\nNow run the backend in a second shell.\n" +read -n 1 -p "Press any key to continue!" +read -n 1 -p "Do you want to benchmark a small, medium or large map? (s/m/l) " opt; +case $opt in + s|S) + echo "small map" + ../benchmarks/backend/heatmap/insert_data-small_map.sh $1 $2 + ;; + m|M) + echo "middle map" + ../benchmarks/backend/heatmap/insert_data-middle_map.sh $1 $2 + ;; + l|L) + echo "large map" + ../benchmarks/backend/heatmap/insert_data-large_map.sh $1 $2 + ;; +esac +printf "\n\nStop the backend.\n" +read -n 1 -p "Press any key to continue!" + +# Start the backend +cd ../backend +RUST_LOG="backend=warn,actix_web=warn" PERF=/usr/lib/linux-tools/5.4.0-153-generic/perf cargo flamegraph --open + +# Collect db logs +docker logs postgis > postgis.log 2>&1 + +# Remove database +docker kill postgis +docker rm postgis diff --git a/benchmarks/backend/relations/get_statistics.sh b/benchmarks/backend/relations/get_statistics.sh new file mode 100755 index 000000000..84f9734f2 --- /dev/null +++ b/benchmarks/backend/relations/get_statistics.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env nu + +cd backend + +grep ' UTC \[[0-9]*\] ' postgis.log | lines | split column -r '\[|\]' | group-by column2 | values | each {|pid| $pid | each {|el| $"($el.column1)[($el.column2)]($el.column3)" } } | flatten | save -f postgis_parsed.log + +print "SQL queries:" +grep 'LOG: execute s.*: (SELECT "relations"\."plant2", "relations"\."relation" FROM' postgis_parsed.log -A 2 + | grep 'LOG: duration: .* ms$' + | awk 'NR == 1 {count=1; sum=$7; min=$7; max=$7} + NR > 1 {count++; sum+=$7; if ($7<0+min) min=$7; if ($7>0+max) max=$7} + END {print "total:", count, "\nmin:", min, "ms\navg:", sum/count, "ms\nmax:", max, "ms"}' + +print "" + +print "Requests:" +grep 'Connection time \[ms\]: min' httperf.log + | awk '{print $5, " ", $7, " ", $9}' + | awk 'NR == 1 {count=1; sum=$1; min=$1; max=$1} + NR > 1 {count++; sum+=$1; if ($1<0+min) min=$1; if ($1>0+max) max=$1} + END {print "total:", count, "\nmin:", min, "ms\navg:", sum/count, "ms\nmax:", max, "ms"}' diff --git a/benchmarks/backend/relations/insert_data.sh b/benchmarks/backend/relations/insert_data.sh new file mode 100755 index 000000000..827d87e0e --- /dev/null +++ b/benchmarks/backend/relations/insert_data.sh @@ -0,0 +1,258 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Create map +curl --location 'http://localhost:8080/api/maps' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "My Garden", + "creation_date": "2023-07-31", + "is_inactive": false, + "zoom_factor": 100, + "honors": 0, + "visits": 0, + "harvested": 0, + "privacy": "public", + "description": "", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + } +}' + +# Create plantings +DATABASE_NAME="permaplant" +DATABASE_USER="permaplant" +PGPASSWORD=permaplant psql -h localhost -p 5432 -U $DATABASE_USER -d $DATABASE_NAME -c " +INSERT INTO "plantings" ("id", "layer_id", "plant_id", "x", "y", "width", "height", "rotation", "scale_x", "scale_y", + "add_date", "remove_date") +VALUES ('00000000-0000-0000-0000-000000000001', 2, 4532, 0400, 0200, 0, 0, 0, 0, 0, null, null), -- european plum + + ('00000000-0000-0000-0000-000000000002', 2, 1658, 0000, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000003', 2, 1658, 0010, 0005, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000004', 2, 1658, 0300, 0010, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000005', 2, 1658, 0350, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000006', 2, 1658, 0400, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000007', 2, 1658, 0500, 0020, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000008', 2, 1658, 0520, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000009', 2, 1658, 0550, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000010', 2, 1658, 0600, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000011', 2, 1658, 0610, 0000, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000012', 2, 1658, 0620, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000013', 2, 1658, 0650, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000014', 2, 1658, 0800, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000015', 2, 1658, 0820, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000016', 2, 1658, 0880, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000017', 2, 1658, 0900, 0015, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-000000000018', 2, 1658, 0080, 0600, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000019', 2, 1658, 0080, 0650, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000020', 2, 1658, 0080, 0700, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000021', 2, 1658, 0080, 0750, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000022', 2, 1658, 0080, 0800, 0, 0, 0, 0, 0, null, null), -- sweet fern + ('00000000-0000-0000-0000-000000000023', 2, 1658, 0080, 0850, 0, 0, 0, 0, 0, null, null), -- sweet fern + + ('00000000-0000-0000-0000-000000000030', 2, 7708, 0550, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000031', 2, 7708, 0575, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000032', 2, 7708, 0600, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000033', 2, 7708, 0625, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000034', 2, 7708, 0650, 1000, 0, 0, 0, 0, 0, null, null), -- Iris germanica + ('00000000-0000-0000-0000-000000000035', 2, 7708, 0675, 1000, 0, 0, 0, 0, 0, null, null) -- Iris germanica +; +" + +# Create shadings +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "partial shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 0.0 + }, + { + "x": 1000.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 200.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 250.0, + "y": 50.0 + }, + { + "x": 400.0, + "y": 12.26 + }, + { + "x": 550.0, + "y": 50.0 + }, + { + "x": 612.26, + "y": 200.0 + }, + { + "x": 550.0, + "y": 350.0 + }, + { + "x": 400.0, + "y": 412.26 + }, + { + "x": 250.0, + "y": 350.0 + }, + { + "x": 187.74, + "y": 200.0 + }, + { + "x": 250.0, + "y": 50.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "permanent shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 400.0 + }, + { + "x": 100.0, + "y": 400.0 + }, + { + "x": 100.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 400.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' + +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header "Authorization: Bearer ${access_token}" \ +--header 'Content-Type: application/json' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 900.0 + }, + { + "x": 800.0, + "y": 900.0 + }, + { + "x": 800.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 1000.0 + }, + { + "x": 0.0, + "y": 900.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' diff --git a/benchmarks/backend/relations/run_httperf.sh b/benchmarks/backend/relations/run_httperf.sh new file mode 100755 index 000000000..559ba69b3 --- /dev/null +++ b/benchmarks/backend/relations/run_httperf.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +username=$1 +password=$2 +number_of_requests=$3 +request_rate=$4 + +# Get the OAuth2 access token +access_token=$(curl --request POST \ + --url 'https://auth.permaplant.net/realms/PermaplanT/protocol/openid-connect/token' \ + --header 'content-type: application/x-www-form-urlencoded' \ + --data grant_type=password \ + --data "username=${username}" \ + --data "password=${password}" \ + --data 'client_id=localhost' | jq -r .access_token) + +# Run httperf + +# Loop to send requests +for (( i=1; i<=$number_of_requests; i++ )) +do + # Generate a random number between 0 and 9810 (number of plants) + ID=$(($RANDOM % 9810)) + + # Execute httperf with the random ID and append to log file + httperf --server localhost --port 8080 --uri /api/maps/1/layers/plants/relations?map_id=1\&plant_id=$ID --num-conns 1 --add-header="Authorization:Bearer ${access_token}\n" >> backend/httperf.log 2>&1 + let "sleep_time = 1 / $request_rate" + sleep $sleep_time +done diff --git a/benchmarks/backend/relations/setup.sh b/benchmarks/backend/relations/setup.sh new file mode 100755 index 000000000..295d650ae --- /dev/null +++ b/benchmarks/backend/relations/setup.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# Start the database and run migrations +cd backend +docker run -d --name postgis -e POSTGRES_PASSWORD=permaplant -e POSTGRES_USER=permaplant -p 5432:5432 postgis/postgis:13-3.1 -c log_min_duration_statement=0 -c log_statement=all +sleep 10 +LC_ALL=C diesel setup +LC_ALL=C diesel migration run + +# Insert data +cd ../scraper && npm run insert +printf "\nNow run the backend in a second shell.\n" +read -n 1 -p "Press any key to continue!" +../benchmarks/backend/relations/insert_data.sh $1 $2 +printf "\n\nStop the backend.\n" +read -n 1 -p "Press any key to continue!" + +# Start the backend +cd ../backend +RUST_LOG="backend=warn,actix_web=warn" PERF=/usr/lib/linux-tools/5.4.0-153-generic/perf cargo flamegraph --open + +# Collect db logs +docker logs postgis > postgis.log 2>&1 + +# Remove database +docker kill postgis +docker rm postgis diff --git a/benchmarks/.gitignore b/benchmarks/frontend/.gitignore similarity index 100% rename from benchmarks/.gitignore rename to benchmarks/frontend/.gitignore diff --git a/benchmarks/frontend/README.md b/benchmarks/frontend/README.md new file mode 100644 index 000000000..72615ccd5 --- /dev/null +++ b/benchmarks/frontend/README.md @@ -0,0 +1,56 @@ +# PermaplanT Performance Audit + +## Requirements + +- nodejs 19.4.0 +- npm + +## Installation and Usage + +1. Install dependencies + +```shell +npm install +``` + +2. Start the backend from the backend folder + +```shell +cargo run +``` + +3. Start the frontend from the frontend folder + +```shell +npm run dev +``` + +4. Run benchmarks + +```shell +npm run benchmark +``` + +## Benchmarking + +The benchmarking script runs a performance audit on a web page using [Lighthouse](https://github.com/GoogleChrome/lighthouse) and [Playwright](https://playwright.dev/). +The audit measures the performance of a web page by generating a performance score and then saves the results of the audit in `report` folder: + +- `-report.json` - the raw report of LightHouse for a single test case defined in `performance-audit.spec.js` +- `-lighthouse-results.csv` - the results of the audit for all test cases + +Metrics: + +- `First Contentful Paint (FCP)` - the time it takes for the browser to render the first bit of content on the page. Measured in milliseconds. +- `Interactive` - the time it takes for the page to become fully interactive. Measured in milliseconds. +- other metrics are described in the [Chrome Developers documentation](https://web.dev/performance-scoring/) + +Pages to audit are defined in `performance-audit.spec.js` file as individual test cases. +In order to add new pages to the audit, add a new test case to the file e.g.: + +```javascript +test("Another page", async () => { + const testname = "Another page"; + await audit(testname, "http://localhost:5173/another_page", results); +}); +``` diff --git a/benchmarks/package-lock.json b/benchmarks/frontend/package-lock.json similarity index 100% rename from benchmarks/package-lock.json rename to benchmarks/frontend/package-lock.json diff --git a/benchmarks/package.json b/benchmarks/frontend/package.json similarity index 100% rename from benchmarks/package.json rename to benchmarks/frontend/package.json diff --git a/benchmarks/playwright.config.js b/benchmarks/frontend/playwright.config.js similarity index 100% rename from benchmarks/playwright.config.js rename to benchmarks/frontend/playwright.config.js diff --git a/benchmarks/tests/performance-audit.spec.js b/benchmarks/frontend/tests/performance-audit.spec.js similarity index 100% rename from benchmarks/tests/performance-audit.spec.js rename to benchmarks/frontend/tests/performance-audit.spec.js diff --git a/doc/backend/06performance_benchmarks.md b/doc/backend/06performance_benchmarks.md new file mode 100644 index 000000000..f0bf04285 --- /dev/null +++ b/doc/backend/06performance_benchmarks.md @@ -0,0 +1,106 @@ +# Performance Benchmarks + +## Requirements + +The following tools are required to run the benchmarks: + +- docker +- [nushell](https://github.com/nushell/nushell) (e.g. via `cargo install nu`) +- perf (package might be called `linux-perf-5.10`) +- [flamegraph](https://github.com/flamegraph-rs/flamegraph) (cargo install flamegraph) +- [httperf](https://github.com/httperf/httperf) +- `jq` +- `psql` (part of PostgreSQL) +- standard linux tools like `grep`, `awk` etc. + +## Setup + +Add the following to the end of `backend/Cargo.toml`. +This is necessary to ensure that perf can accurately track the function stack. + +```toml +[profile.bench] +debug = true +``` + +The `benchmarks/backend/*/setup.sh` scripts contain `PERF=/usr/lib/linux-tools/5.4.0-153-generic/perf` to set the location of perf. +Depending on your distribution it might or might not be needed to change this. +On Debian no change is needed. +Otherwise modify the path to point to your `perf` installation. + +## Scripts + +You can find the scripts in `benchmark/backend/`. +They are supposed to be run from the repository's root folder. + +The subfolders contain scripts to run performance benchmarks in specific endpoints. + +### `setup.sh` + +Execute it as follows: +`./benchmarks/backend//setup.sh `. + +The database and backend have to be started manually. +Depending on the endpoint it might execute `insert_data.sh` scripts to insert additional data into the database. + +The script might output instructions while executing. +Follow these instructions to ensure the benchmark works correctly. + +Parameters: + +- username: Your PermaplanT username for https://auth.permaplant.net. +- password: Your PermaplanT password. + +### `run_httperf.sh` + +Execute like the following: +`./benchmarks/backend//run_httperf.sh `. + +This script shall be run as soon as the `setup.sh` starts the backend via `cargo flamegraph`. +It will execute requests on the backend using httperf. + +Once this script finishes you can interrupt `setup.sh` via Ctrl+C. +Note that it might take 20min or longer to finish once interrupted. +Do not press Ctrl+C again, otherwise the generated flamegraph will not include all data. + +Parameters: + +- username: Your PermaplanT username for https://auth.permaplant.net. +- password: Your PermaplanT password. +- number_of_requests: The total number of requests httperf will execute (e.g. 10000). +- request_rate: How many requests will be executed per second (e.g. 100). + +### `get_statistics.sh` + +Execute like the following: +`./benchmarks/backend//get_statistics.sh`. + +This script shall be executed once all previous scripts finished. +It will parse the PostgreSQL logs and httperf logs to extract execution times. + +## Example run + +The following is a step by step guide on how to execute the benchmark for the heatmap: + +1. Insert + ```toml + [profile.bench] + debug = true + ``` + into `Cargo.toml`. +2. Execute: `./benchmarks/backend/heatmap/setup.sh `. Do the following once the script gives the instructions. + - Start the backend in dev mode. + - Press Enter. + - Select map size: press `s`. + - Stop the backend. + - Press Enter. +3. Wait for the backend to start in release mode. +4. Once its started execute in a second shell: `./benchmarks/backend/heatmap/run_httperf.sh 100 10` +5. Wait for httperf to finish. +6. Press Ctrl+C in the `setup.sh` shell. +7. Wait until `flamegraph.svg` was generated (this might take 20min or longer). + If you interrupt this step you have to rerun the benchmark. +8. Execute: `./benchmarks/backend/heatmap/get_statistics.sh` +9. Collect results: + - flamgraph.svg + - Copy request execution times and query execution times from stdout of `get_statistics.sh` diff --git a/doc/changelog.md b/doc/changelog.md index 08db20a38..9c9da97eb 100644 --- a/doc/changelog.md +++ b/doc/changelog.md @@ -76,6 +76,7 @@ Syntax: `- short text describing the change _(Your Name)_` ## 0.4.0 - 12.4.2024 +- Implement a heatmap that shows which locations are most suited for a specific plant _(Gabriel, Paul, Moritz)_ - needs new migrations - needs new scraper data (integer for plant spread and height) - pin python package versions for e2e tests #1200 _(4ydan)_ diff --git a/doc/tests/manual/protocol.md b/doc/tests/manual/protocol.md index 85f3cce35..e449cc5f4 100644 --- a/doc/tests/manual/protocol.md +++ b/doc/tests/manual/protocol.md @@ -31,7 +31,7 @@ DONT FILL OUT ACTUAL/TEST RESULT. - Test Result: - Notes: -## Heatmap (NOT IMPLEMENTED) +## Heatmap - Description: Test whether the heatmap endpoints generates the image correctly. - Given I am on a map page with the plant layer active @@ -154,3 +154,111 @@ DONT FILL OUT ACTUAL/TEST RESULT. - Actual Result: - Test Result: - Notes: The additional name must also be visible when a different account views the same map. + +## Shade layer: add shading + +- Description: Add a new Shading to the map. +- Given I am on the map page with the shade layer active +- When I click on the 'Light' button +- When I click on the map +- Then I can see that a new shading was added +- Actual Result: +- Test Result: +- Notes: Repeat this test for all Shading types + +## Shade layer: remove shading + +- Description: Remove a Shading from the map. +- Given I am on the map page with the shade layer active and a Shading is selected +- When I click on 'Delte' in the left toolbar +- Then I can see that a new shading was deleted +- Actual Result: +- Test Result: + +## Shade layer: edit shade type + +- Description: Change the shade of a shading. +- Given I am on the map page with the shade layer active and a Shading is selected +- When I select different shade from the drop-down menu in the left toolbar +- Then I can see that the shade is changed successfully +- Actual Result: +- Test Result: +- Notes: select each shading type at least once + +## Shade layer: set creation date in the future + +- Description: Update the date from which the shading should be active +- Given I am on the map page with the shade layer active and a Shading is selected +- When I select the current date in the timeline +- When I select a creation date in the future +- Then I can see that the shading is no longer shown on the map +- Actual Result: +- Test Result: + +## Shade layer: set creation date in the past + +- Description: Update the date from which the shading should be active +- Given I am on the map page with the shade layer active and a Shading is selected +- When I select the current date in the timeline +- When I select a creation date in the past +- Then I can see that the shading is shown on the map +- Actual Result: +- Test Result: + +## Shade layer: set removal date in the future + +- Description: Update the date from which the shading should be active +- Given I am on the map page with the shade layer active and a Shading is selected +- When I select the current date in the timeline +- When I select a creation date in the past +- When I select a removal date in the future +- Then I can see that the shading is shown on the map +- Actual Result: +- Test Result: + +## Shade layer: set removal date in the past + +- Description: Update the date until which the shading should be active +- Given I am on the map page with the shade layer active and a Shading is selected +- When I select the current date in the timeline +- When I select a creation date in the past +- When I select a removal date in the past after the creation date +- Then I can see that the shading is no longer shown on the map +- Actual Result: +- Test Result: + +## Shade layer: add polygon point + +- Description: Edit the polygon of a shading +- Given I am on the map page with the shade layer active and a Shading is selected +- When I press the pencil button in the left toolbar +- Then I can see that a message describing the selected action is shown in the status bar +- Then I can see that a highlighted border is drawn around the polygon +- When I click anywhere on the map +- Then I can see that a point has been added to the nearest polygon edge +- Actual Result: +- Test Result: + +## Shade layer: remove polygon point + +- Description: Edit the polygon of a shading +- Given I am on the map page with the shade layer active and a Shading is selected +- When I press the eraser button in the left toolbar +- Then I can see that a message describing the selected action is shown in the status bar +- Then I can see that a highlighted border is drawn around the polygon +- When I click on a polygon point +- Then I can see that the point has been removed from the polygon +- Actual Result: +- Test Result: + +## Shade layer: move polygon point + +- Description: Edit the polygon of a shading +- Given I am on the map page with the shade layer active and a Shading is selected +- When I press the cursor button in the left toolbar +- Then I can see that a message describing the selected action is shown in the status bar +- Then I can see that a highlighted border is drawn around the polygon +- When I drag and release a polygon point +- Then I can see that the point is now at a new position +- Actual Result: +- Test Result: diff --git a/doc/tests/manual/reports/230723_heatmap_with_shade.md b/doc/tests/manual/reports/230723_heatmap_with_shade.md new file mode 100644 index 000000000..80346d94c --- /dev/null +++ b/doc/tests/manual/reports/230723_heatmap_with_shade.md @@ -0,0 +1,238 @@ +# Heatmap with Shade + +## General + +- Tester: Gabriel +- Date/Time: 31.07.2023 21:50 +- Duration: 15 min +- Commit/Tag: a8b0079cbdd638aea5600373434d8dddcca8e7e7 +- Planned tests: 1 +- Executed tests: **1** +- Passed tests: 1 +- Failed tests: 0 + +## Error Analysis + +## Closing remarks + +This test was executed to show how the heatmap can be generated without the frontend being fully implemented. + +## Testcases + +### TC-007 - Heatmap + +1. Get a clean database with all migrations: `cd backend && diesel database reset`. +2. Insert the plants and relations using the scraper (`cd scraper && npm run insert`) +3. Start the backend (`cd backend && cargo run`). +4. Create a map (via Postman, equivalent cURL below - remember to insert the token). + +```bash +curl --location 'http://localhost:8080/api/maps' \ +--header 'Authorization: Bearer ' \ +--header 'Content-Type: application/json' \ +--data '{ + "name": "Test1", + "creation_date": "2023-07-06", + "is_inactive": false, + "zoom_factor": 100, + "honors": 0, + "visits": 0, + "harvested": 0, + "privacy": "public", + "description": "", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 100.0, + "y": 0.0 + }, + { + "x": 100.0, + "y": 100.0 + }, + { + "x": 50.0, + "y": 100.0 + }, + { + "x": 50.0, + "y": 50.0 + }, + { + "x": 0.0, + "y": 50.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + } +}' +``` + +The result should be: + +```json +{ + "id": 1, + "name": "Test1", + "creation_date": "2023-07-06", + "deletion_date": null, + "last_visit": null, + "is_inactive": false, + "zoom_factor": 100, + "honors": 0, + "visits": 0, + "harvested": 0, + "privacy": "public", + "description": "", + "location": null, + "owner_id": "361c7c28-020f-4b31-84ea-cc629cc43180", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0, + "srid": 4326 + }, + { + "x": 100.0, + "y": 0.0, + "srid": 4326 + }, + { + "x": 100.0, + "y": 100.0, + "srid": 4326 + }, + { + "x": 50.0, + "y": 100.0, + "srid": 4326 + }, + { + "x": 50.0, + "y": 50.0, + "srid": 4326 + }, + { + "x": 0.0, + "y": 50.0, + "srid": 4326 + }, + { + "x": 0.0, + "y": 0.0, + "srid": 4326 + } + ] + ], + "srid": 4326 + } +} +``` + +5. Create a shading. + +```bash +curl --location 'http://localhost:8080/api/maps/1/layers/shade/shadings' \ +--header 'Content-Type: application/json' \ +--header 'Authorization: Bearer ' \ +--data '{ + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0 + }, + { + "x": 20.0, + "y": 0.0 + }, + { + "x": 20.0, + "y": 20.0 + }, + { + "x": 0.0, + "y": 20.0 + }, + { + "x": 0.0, + "y": 0.0 + } + ] + ], + "srid": 4326 + }, + "actionId": "00000000-0000-0000-0000-000000000000" +}' +``` + +The result should be: + +```json +{ + "id": "21c9ca45-5ff4-492a-a537-7eb64c134613", + "layerId": 3, + "shade": "light shade", + "geometry": { + "rings": [ + [ + { + "x": 0.0, + "y": 0.0, + "srid": 4326 + }, + { + "x": 20.0, + "y": 0.0, + "srid": 4326 + }, + { + "x": 20.0, + "y": 20.0, + "srid": 4326 + }, + { + "x": 0.0, + "y": 20.0, + "srid": 4326 + }, + { + "x": 0.0, + "y": 0.0, + "srid": 4326 + } + ] + ], + "srid": 4326 + }, + "addDate": null, + "removeDate": null +} +``` + +6. Execute the request. + +```bash +curl -o file.png --location 'http://localhost:8080/api/maps/1/layers/plants/heatmap?plant_id=1&plant_layer_id=2&shade_layer_id=3' \ +--header 'Authorization: Bearer ' +``` + +7. Verify: + +- The bottom left corner should be transparent, everything else should be green. +- The top left corner should be green (as there is shade there and plant with id 1 likes shade); the rest should be yellow. diff --git a/frontend/src/components/Form/SelectMenu.tsx b/frontend/src/components/Form/SelectMenu.tsx index d11392b9f..3884afd46 100644 --- a/frontend/src/components/Form/SelectMenu.tsx +++ b/frontend/src/components/Form/SelectMenu.tsx @@ -5,6 +5,7 @@ import Select, { ClassNamesConfig, GroupBase, MultiValue, + OnChangeValue, SingleValue, StylesConfig, } from 'react-select'; @@ -19,6 +20,7 @@ export interface SelectMenuProps< T extends FieldValues, Option = SelectOption, IsMulti extends boolean = false, + Value = string | number | undefined, > { /** Per page unique identifier of this UI element. */ id: Path; @@ -26,6 +28,8 @@ export interface SelectMenuProps< isMulti?: IsMulti; /** This text will be displayed in a label above select menu. */ labelText?: string; + /** Deactivate this UI element. */ + disabled?: boolean; /** * Reference to a react-hook-form control. * Caution: control can only be omitted in the context of a FormProvider. @@ -33,8 +37,13 @@ export interface SelectMenuProps< control?: Control; /** Options content that may be selected by the user */ options: Option[]; + /** If this component is used with react hook form, you must supply a function to convert from values to options */ + optionFromValue?: (value: Value) => Option; + /** If this component is used with react hook form, you must supply a function to convert from options to values */ + valueFromOption?: (value: OnChangeValue) => Value; /** Force a selected option. */ value?: Option; + defaultValue?: Option; /** Whether the user has to select something before they can submit the containing form. */ required?: boolean; /** Text that is displayed in place of the content if no option has been selected. */ @@ -50,6 +59,8 @@ export interface SelectMenuProps< onInputChange?: (inputValue: string) => void; /** Disables the x icon at the end of the select menu that allows the user to deselect the current option. */ isClearable?: boolean; + /** Additional CSS classes */ + className?: string; } /** @@ -66,18 +77,24 @@ export default function SelectMenu< labelText, control, options, + disabled, required = false, value, + defaultValue, placeholder, handleOptionsChange, + optionFromValue, + valueFromOption, onChange, onInputChange, isClearable = true, + className = '', }: SelectMenuProps) { const customClassNames: ClassNamesConfig> = { menu: () => 'bg-neutral-100 dark:bg-neutral-50-dark', control: (state) => { return ` + ${className} h-[44px] bg-neutral-200 rounded border dark:bg-neutral-50-dark focus:border-primary-500 hover:border-primary-500 dark:focus:border-primary-300 dark:hover:border-primary-300 @@ -134,14 +151,20 @@ export default function SelectMenu< ( + render={({ field }) => (