+ Developed by the World Bank, GEEST (The Gender Enabling Environments Spatial Tool) is a powerful and user-friendly open-source spatial mapping tool that enables a comprehensive analysis of how various spatial factors influence women's employment and business opportunities in any geographic area of interest.
+
+ By using this tool, you can identify key areas for intervention, make data-driven decisions, and ultimately work toward creating more equitable job opportunities for women across different regions.
-
diff --git a/admin.py b/admin.py
index 5fcaa0b8..beb50494 100644
--- a/admin.py
+++ b/admin.py
@@ -79,12 +79,22 @@ def install(context: typer.Context, build_src: bool = True):
build(context, clean=True) if build_src else LOCAL_ROOT_DIR / "build" / SRC_NAME
)
- root_directory = (
- Path.home() / f".local/share/QGIS/QGIS3/profiles/"
- f"{context.obj['qgis_profile']}"
- )
+ # For windows root dir in in AppData
+ if os.name == "nt":
+ print("User profile:")
+ print(os.environ["USERPROFILE"])
+ plugin_path = os.path.join(
+
+ "AppData", "Roaming", "QGIS", "QGIS3", "profiles", "default",
+ )
+ root_directory = os.environ["USERPROFILE"] + "\\" + plugin_path
+ else:
+ root_directory = (
+ Path.home() / f".local/share/QGIS/QGIS3/profiles/"
+ f"{context.obj['qgis_profile']}"
+ )
- base_target_directory = root_directory / "python/plugins" / SRC_NAME
+ base_target_directory = os.path.join(root_directory, "python/plugins", SRC_NAME)
_log(f"Copying built plugin to {base_target_directory}...", context=context)
shutil.copytree(built_directory, base_target_directory)
_log(
@@ -197,7 +207,7 @@ def build(
icon_path = copy_icon(output_directory)
if icon_path is None:
_log("Could not copy icon", context=context)
- compile_resources(context, output_directory)
+ # compile_resources(context, output_directory)
add_requirements_file(context, output_directory)
generate_metadata(context, output_directory)
return output_directory
diff --git a/config.json b/config.json
index 2bfc8c48..47af8436 100644
--- a/config.json
+++ b/config.json
@@ -6,17 +6,17 @@
"icon": "icon.png",
"experimental": true,
"deprecated": false,
- "homepage": "",
+ "homepage": "https://github.com/worldbank/GEEST",
"tracker": "https://github.com/worldbank/GEEST/issues",
"repository": "https://github.com/worldbank/GEEST",
"tags": [],
"category": ["plugins"],
"hasProcessingProvider": "no",
"about": "GEEST was built for the practical implementation of the Geospatial Women’s Employment Analytical Framework (GeoWEAF), developed by the World Bank, which identifies the location-specific factors affecting women's access to jobs. This framework categorizes regions based on their level of support for enabling women’s access to employment. It identifies 15 critical, spatially varying factors that influence women’s job prospects, divided into three main dimensions: Contextual, Accessibility, and Place Characterization.",
- "author": "Kartoza for/and The World Bank",
+ "author": "Kartoza for and with The World Bank",
"email": "info@kartoza.com, gost@worldbank.org",
"description": "Gender Enabling Environments Spatial Tool",
- "version": "0.5.5",
+ "version": "0.5.9",
"changelog": "",
"server": false
}
diff --git a/docs/userguide/accessibility.md b/docs/userguide/accessibility.md
index d8f83640..0468512b 100644
--- a/docs/userguide/accessibility.md
+++ b/docs/userguide/accessibility.md
@@ -16,7 +16,7 @@ This tool evaluates how easily women can access essential services and amenities
**Travel mode**: The user can select walking or driving as a travel mode, and it is recommended that the same travel mode should be selected for all accessibility factors. The default travel mode is walking due to its inclusive nature.
-**Measurement**: The default measurement for travel is distance in meters, which is most appropriate for walking. These thresholds1 are based on evidence from the literature at the factor level and are designed to provide consistency across analyses. If driving is selected as a travel mode, time in minutes is a more appropriate measurement.
+**Measurement**: The default measurement for travel is distance in meters, which is most appropriate for walking. These thresholds are based on evidence from the literature at the factor level and are designed to provide consistency across analyses. If driving is selected as a travel mode, time in minutes is a more appropriate measurement.
---
@@ -51,6 +51,40 @@ This tool evaluates how easily women can access essential services and amenities
onclick="window.open(this.src, '_blank')">
+Default Women's Travel Patterns thresholds:
+
+
+
+
Distance to Facilities (meters)
+
Score
+
+
+
0 - 400
+
5
+
+
+
401 - 800
+
4
+
+
+
801 - 1,200
+
3
+
+
+
1,201 - 1,500
+
2
+
+
+
1,501 - 2,000
+
1
+
+
+
Over 2,000
+
0
+
+
+
+
**Process Women's Travel Patterns factors**
@@ -104,6 +138,41 @@ The successful completion of the process is indicated by the green checkmark wid
onclick="window.open(this.src, '_blank')">
+Default Access to Public Transport thresholds:
+
+
+
+
Distance to Public Transport stops (meters)
+
Score
+
+
+
0 - 250
+
5
+
+
+
251 - 500
+
4
+
+
+
501 - 750
+
3
+
+
+
751 - 1,000
+
2
+
+
+
1,001 - 1,250
+
1
+
+
+
Over 1,250
+
0
+
+
+
+
+
**Process Access to Public Transport factor**
Back in the Data Processing Interface:
@@ -148,6 +217,41 @@ The process should be successfully completed and indicated by a green checkmark
onclick="window.open(this.src, '_blank')">
+Default Access to Health Facilities thresholds:
+
+
+
+
Distance to Health Facilities (meters)
+
Score
+
+
+
0 - 2,000
+
5
+
+
+
2,001 - 4,000
+
4
+
+
+
4,001 - 6,000
+
3
+
+
+
6,001 - 8,000
+
2
+
+
+
8,001 - 10,000
+
1
+
+
+
Over 10,000
+
0
+
+
+
+
+
**Process Access to Health Facilities factor**
Back in the Data Processing Interface:
@@ -181,6 +285,41 @@ The process should be successfully completed and indicated by a green checkmark
onclick="window.open(this.src, '_blank')">
+Default Access to Education and Training Facilities thresholds:
+
+
+
+
Distance to Facilities (meters)
+
Score
+
+
+
0 - 2,000
+
5
+
+
+
2,001 - 4,000
+
4
+
+
+
4,001 - 6,000
+
3
+
+
+
6,001 - 8,000
+
2
+
+
+
8,001 - 10,000
+
1
+
+
+
Over 10,000
+
0
+
+
+
+
+
**Process Access to Education and Training Facilities factor**
Back in the Data Processing Interface:
@@ -214,6 +353,41 @@ The process should be successfully completed and indicated by a green checkmark
onclick="window.open(this.src, '_blank')">
+Default Access to Financial Facilities thresholds:
+
+
+
+
Distance to Financial Facilities (meters)
+
Score
+
+
+
0 - 400
+
5
+
+
+
401 - 800
+
4
+
+
+
801 - 1,200
+
3
+
+
+
1,201 - 2,000
+
2
+
+
+
2,001 - 3,000
+
1
+
+
+
Over 3,000
+
0
+
+
+
+
+
**Process Access to Financial Facilities factor**
Back in the Data Processing Interface:
@@ -265,8 +439,6 @@ After completing the process, the outputs are automatically added to the Layer P
The outputs consist of all factors and subfactors, as well as the aggregation of these into the final Accessibility output. All scores are assessed on a scale from 0 to 5, categorized as follows: ≤ 0.5 (Not Enabling) | 0.5–1.5 (Very Low Enablement) | 1.5–2.5 (Low Enablement) | 2.5–3.5 (Moderately Enabling) | 3.5–4.5 (Enabling) | 4.5–5.0 (Highly Enabling).
-[Not working - Need to be amended]
-
The outputs are stored under the Accessibility folder within the project folder created during the setup phase as raster files. These files can be shared and further utilized for various purposes, such as visualization in QGIS or other GIS software, integration into reports, overlaying with other spatial datasets, or performing advanced geospatial analyses, such as identifying priority areas or conducting trend analysis based on the scores.
If the results do not immediately appear in the Layer Panel after processing the Accessibility Dimension, you can resolve this by either adding them manually from the folder path or by right-clicking on the Accessibility Dimension and selecting **Add to map** from the context menu:
@@ -300,23 +472,3 @@ If the results do not immediately appear in the Layer Panel after processing the
- **Input Accuracy**: Ensure all input datasets are carefully entered/selected and correspond to the correct factors and/or subfactors. Incorrect data will impact the outputs and subsequent analysis.
- **Weight Adjustment**: Assign weights thoughtfully to reflect the importance of each factor in the overall analysis. After making changes, always balance the weights to ensure they sum up correctly.
-
-
-1 Thresholds
-
diff --git a/docs/userguide/datacollection.md b/docs/userguide/datacollection.md
index e4e658bf..8f21db31 100644
--- a/docs/userguide/datacollection.md
+++ b/docs/userguide/datacollection.md
@@ -4,6 +4,250 @@ This page provides guidance on finding and collecting relevant data for the GEES
## Data Sources for Saint Lucia
+**NEW:**
+
+
+
+ Humdata
+
+ or
+ [out:xml][timeout:25];{{geocodeArea:**country name**}}->.area_0;(node["amenity"="kindergarten"](area.area_0);way["amenity"="kindergarten"](area.area_0);relation["amenity"="kindergarten"](area.area_0););(._;>;);out body; in
+
+ OSM
+
+
+
+
+
🏫Location of primary schools
+
+
+ Humdata
+
+ or
+ [out:xml][timeout:25];{{geocodeArea:**country name**}}->.area_0;(node["amenity"="school"](area.area_0);way["amenity"="school"](area.area_0);relation["amenity"="school"](area.area_0););(._;>;);out body; in
+
+ OSM
+
+
+
+
+
🛒Location of groceries
+
+ [out:xml][timeout:25];{{geocodeArea:**country name**}}->.area_0;(node["shop"="greengrocer"](area.area_0);way["shop"="greengrocer"](area.area_0);relation["shop"="greengrocer"](area.area_0););(._;>;);out body; in
+
+ OSM
+
+
+
+
+
💊Location of pharmacies
+
+ [out:xml][timeout:25];{{geocodeArea:**country name**}}->.area_0;(node["amenity"="pharmacy"](area.area_0);way["amenity"="pharmacy"](area.area_0);relation["amenity"="pharmacy"](area.area_0););(._;>;);out body;
+
+ OSM
+
+
+
+
+
🌳Location of green spaces
+
+ [out:xml][timeout:25];{{geocodeArea:**country name**}}->.area_0;(node["leisure"="park"](area.area_0);node["boundary"="national_park"](area.area_0);way["leisure"="park"](area.area_0);way["boundary"="national_park"](area.area_0);relation["leisure"="park"](area.area_0);relation["boundary"="national_park"](area.area_0););(._;>;);out body; in
+
+ OSM
+
+
+
+
+
🚌Access to Public Transport
+
Location of public transportation stops, including maritime
+
+ [out:xml][timeout:25];{{geocodeArea:**country name**}}->.area_0;(node["public_transport"="stop_position"](area.area_0);node["public_transport"="platform"](area.area_0);node["public_transport"="station"](area.area_0);node["public_transport"="stop_area"](area.area_0);node["highway"="bus_stop"](area.area_0);node["highway"="platform"](area.area_0);way["public_transport"="stop_position"](area.area_0);way["public_transport"="platform"](area.area_0);way["public_transport"="station"](area.area_0);way["public_transport"="stop_area"](area.area_0);way["highway"="bus_stop"](area.area_0);way["highway"="platform"](area.area_0);relation["public_transport"="stop_position"](area.area_0);relation["public_transport"="platform"](area.area_0);relation["public_transport"="station"](area.area_0);relation["public_transport"="stop_area"](area.area_0);relation["highway"="bus_stop"](area.area_0);relation["highway"="platform"](area.area_0);node["amenity"="ferry_terminal"](area.area_0);way["amenity"="ferry_terminal"](area.area_0);relation["amenity"="ferry_terminal"](area.area_0););(._;>;);out body; in
+
+ OSM
+
+
-The final aggregation of all three dimensions is carried out by applying weights to each dimension, ensuring a balanced and comprehensive representation. This tab provides users with deeper insights by identifying regions where conditions are optimal—or at least favorable—for women to secure employment in specific sectors or to enhance existing job sites. The outputs from this tab assign a combined classification score to the input raster and extract aggregated polygons or administrative units intersecting these regions. Additionally, GEEST highlights key infrastructure investments that could boost women's participation in the workforce. Population data can also be incorporated into the analysis to provide more nuanced insights that account for both population levels and enablement. This process integrates proximity data, classification scores, population data and weighted dimensions to support informed decision-making.
+The final aggregation of all three dimensions is carried out by applying weights to each dimension, ensuring a balanced and comprehensive representation. This tab provides users with deeper insights by identifying regions where conditions are optimal—or at least favorable—for women to access job opportunities in a specific sector. The outputs from this tab assign a combined classification score to the input raster, aggregate results at desired adminstrative level and extract aggregated polygons or administrative units intersecting these regions. Additionally, GEEST highlights key infrastructure investments that could boost women's participation in the workforce. Population data can also be incorporated into the analysis to provide more nuanced insights that account for both population levels and enablement. This process integrates proximity data, classification scores, population data and weighted dimensions to support informed decision-making.
-### Processing all 3 dimensions (WEE Score)
+### Computing the Women’s Enablement Environments Indicator (WEE)
---
Before accessing the full insights tab, it is necessary to aggregate all the factors processed in the previous steps. This is achieved by assigning weights to each dimension, resulting in the WEE (Women’s Enablement Environments) output. The output is categorized into five classes: highly enabling, enabling, moderately enabling, low enablement and very low enablement.
@@ -103,14 +103,12 @@ After completing the process, the outputs are automatically added to the Layer P
The outputs consist of all WEE score outputs (aggreagated at administrative level, by population or by job distribution). All scores are assessed on a scale from 0 to 15, categorized as follows: 14 - 15: Highly enabling, high population | 13 - 14: Highly enabling, medium population | 12 - 13: Highly enabling, low population | 11 - 12: Enabling, high population | 10 - 11: Enabling, medium population | 9 - 10: Enabling, low population | 8 - 9: Moderately enabling, high population | 7 - 8: Moderately enabling, medium population | 6 - 7: Moderately enabling, low population | 5 - 6: Low enablement, high population | 4 - 5: Low enablement, medium population | 3 - 4: Low enablement, low population | 2 - 3: Very low enablement, high population | 1 - 2: Very low enablement, medium population | 0 - 1: Very low enablement, low population.
-[Not working - Need to be amended]
-
The outputs are stored within the project folder created during the setup phase as raster files. These files can be shared and further utilized for various purposes, such as visualization in QGIS or other GIS software, integration into reports, overlaying with other spatial datasets, or performing advanced geospatial analyses, such as identifying priority areas or conducting trend analysis based on the scores.
If the results do not immediately appear in the Layer Panel after processing the WEE Scores, you can resolve this by either adding them manually from the folder path or by right-clicking on the WEE Score and selecting **Add to map** from the context menu.
-**🖥️ Key Features of WEE Score**
+**🖥️ Key attributes of WEE Score tab**
thresholds1 are listed in the footnote.
-
For certain factors, **multiple data input options** are available depending on the data's format and availability.
### Input Place Characterization factors
@@ -46,6 +44,16 @@ For certain factors, **multiple data input options** are available depending on
onclick="window.open(this.src, '_blank')">
+Active transport factor is calculated based on the four subfactors averaged across the raster cells:
+
+| Subfactor | Score 0 | Score 1 | Score 2 | Score 3 | Score 4 | Score 5 |
+|----------------------|------------------|-----------------------|-------------------------|-------------------------|-------------------------|-------------------------|
+| **Street Crossings** | None | N/A | N/A | 1 crossing | N/A | 2+ crossings |
+| **Cycle Paths** | None | N/A | N/A | 1 cycle path | N/A | 2+ paths |
+| **Footpaths** | None | N/A | N/A | 1 path | N/A | 2+ paths |
+| **Block Sizes** | None | >1 km | 751m - 1 km | 501m - 750m | 251m - 500m | <250m |
+
+
**Process Active Transport factors**
Back in the Data Processing Interface:
@@ -96,7 +104,7 @@ The successful completion of the process is indicated by the green checkmark wid
onclick="window.open(this.src, '_blank')">
-> - 3️⃣ Using **Nighttime Lights data** as input; VIIRS Nighttime Lights raster may be used as proxy data for streetlight locations; select the layer already loaded in the QGIS Layer Panel from the dropdown menu or manually enter the file path for the (**raster data**) corresponding to the streetlights data by clicking the three-dot button; this layer will be used for processing:
+> - 3️⃣ Using **Nighttime Lights data** as input; VIIRS Nighttime Lights raster may be used as proxy data for presence of area illumination at night time; select the layer already loaded in the QGIS Layer Panel from the dropdown menu or manually enter the file path for the (**raster data**) corresponding to NTL by clicking the three-dot button; this layer will be used for processing:
- 🚫 **Exclude Unused Factors (optional)**: If this factor is not intended to be included in the process, uncheck the **Use** button associated with it.
> - ✅ **Finalize**: Once all settings are configured, click OK to confirm and proceed to the next step.
+Safety is calculated by generating 20-meter buffers around streetlights using the default thresholds:
+
+| Factor | Score 0 | Score 1 | Score 2 | Score 3 | Score 4 | Score 5 |
+|----------|------------------------|-------------------------|-------------------------|-------------------------|-------------------------|-------------------------|
+| **Safety** | No overlap | 1-19% intersection | 20-39% intersection | 40-59% intersection | 60-79% intersection | 80-100% intersection |
+
+Note: Use nighttime light data only if streetlight data is unavailable.
+
**Process Safety factor**
Back in the Data Processing Interface:
@@ -159,7 +175,7 @@ The successful completion of the process is indicated by the green checkmark wid
onclick="window.open(this.src, '_blank')">
-> - 2️⃣ Using **ACLED data** as input; select ACLED data in CSV format representing fragility, conflict, and violence events; a buffer is required to estimate the spatial impact of these events, with a default radius of 5000m; if the specific impact radius of an event is known, it should be applied instead; a pop-up will appear to validate the CSV format.
+> - 2️⃣ Using **ACLED data** as input; select ACLED data in CSV format representing fragility, conflict, and violence events; this indicator is structured by assigning scores to rasters based on their overlap with buffers indicating different types of events. Using point locations of FCV events, generate circular buffers with a default radius of 5 km to estimate the spatial impact of these events. If the impact radius of an event is known, it should be used instead; a pop-up will appear to validate the CSV format.
- 🚫 **Exclude Unused Factor (optional)**: If this factor is not intended to be included in the process, uncheck the **Use** button associated with it.
> - ✅ **Finalize**: Once all settings are configured, click OK to confirm and proceed to the next step.
+FCV is structured by assigning scores to raster cells based on their overlap with buffers representing different types of events. Using point locations of FCV (Fragility, Conflict, and Violence) events, create circular buffers with a radius of 5 km to estimate the spatial impact. If a specific event's impact radius is known, it should be applied instead. Raster cells intersecting with these default buffers are scored as follows:
+
+| Factor | Score 0 | Score 1 | Score 2 | Score 3 | Score 4 | Score 5 |
+|----------|----------------------|---------------------------|---------------------------------|---------------------|-----------------------------|------------------------------|
+| **FCV** | battles and explosions | explosions and remote violence | violence against civilians | not applicable | protests and riots | no overlap with any event |
+
+
**Process FCV factor**
Back in the Data Processing Interface:
@@ -227,6 +250,8 @@ The successful completion of the process is indicated by the green checkmark wid
> - 🚫 **Exclude Unused Factor (optional)**: If this factor is not intended to be included in the process, uncheck the **Use** button associated with it.
> - ✅ **Finalize**: Once all settings are configured, click OK to confirm and proceed to the next step.
+Education reclassifies the input data to a standardized scale from 0 to 5 using a linear scaling process. In this scale, a score of 5 represents areas where all women have a university degree, while a score of 0 represents areas where no women have a university degree.
+
**Process Education factor**
Back in the Data Processing Interface:
@@ -281,6 +306,8 @@ The successful completion of the process is indicated by the green checkmark wid
> - 🚫 **Exclude Unused Factor (optional)**: If this factor is not intended to be included in the process, uncheck the **Use** button associated with it.
> - ✅ **Finalize**: Once all settings are configured, click OK to confirm and proceed to the next step.
+Digital Inclusion reclassifies input data to a standardized scale of 0 to 5 using a linear scaling process, where 5 represents areas where 100% of households have internet access, and 0 represents areas where no households have internet access.
+
**Process Digital Inclusion factor**
Back in the Data Processing Interface:
@@ -311,7 +338,7 @@ This factor is composed, by default, of five subfactors representing different t
If data for one or more hazard types is not available, these subfactors can be excluded from the processing. In such cases, the tool will automatically adjust the weights of the remaining subfactors to ensure accurate aggregation.
-The thresholds for defining hazard levels are based on a predefined scoring list (**provided in the footnote**). The input data relies on globally available open data sources and is reclassified for use within the tool. However, if more precise and localized data is available, users are encouraged to incorporate it into the processing. In doing so, users should align the data with the thresholds provided to maintain consistency and reliability.
+The thresholds for defining hazard levels are based on a predefined scoring list. The input data relies on globally available open data sources and is reclassified for use within the tool. However, if more precise and localized data is available, users are encouraged to incorporate it into the processing. In doing so, users should align the data with the thresholds provided to maintain consistency and reliability.
**Locate Environmental Hazards Section**
@@ -332,6 +359,17 @@ The thresholds for defining hazard levels are based on a predefined scoring list
onclick="window.open(this.src, '_blank')">
+Environmental Hazards reclassifies input data to a standardized scale of 0 to 5 using a linear scaling process, where 5 represents areas with no environmental hazards and 0 represents areas with the highest level of hazard.
+
+| Factor | Class 0 | Class 1 | Class 2 | Class 3 | Class 4 | Class 5 |
+|------------------------------------------|--------------------|----------------------|----------------------|----------------------|----------------------|----------------------|
+| **Number of Fires per km²** | >8 | 5–8 | 2–5 | 1–2 | 0–1 | 0 or No Data |
+| **Floods Data** | 720–900 cm | 540–720 cm | 360–540 cm | 180–360 cm | <180 cm | No Data or 0 |
+| **Landslide Data** | Severe | High-Moderate2 (4) | Moderate (3) | Low-Moderate1 (2) | Slight (1) | No Data or 0 |
+| **Tropical Cyclone Frequency (100 Years)** | >100 events | 75–100 events | 50–75 events | 25–50 events | <25 events | No Data or 0 |
+| **Drought Data** | 4–5 | 3–4 | 2–3 | 1–2 | 0–1 | No Data or 0 |
+
+
**Process Environmental Hazards factors**
Back in the Data Processing Interface:
@@ -373,6 +411,12 @@ The successful completion of the process is indicated by the green checkmark wid
onclick="window.open(this.src, '_blank')">
+Water Sanitation is assessed based on the presence of water and sanitation facilities within a raster cell, applying a default 1000m buffer. The scoring is as follows:
+
+| Factor | Score 0 | Score 1 | Score 2 | Score 3 | Score 4 | Score 5 |
+|-------------------------|---------------------------|---------|---------|-----------------------------|---------|--------------------------------|
+| **Water Sanitation** | No water points | N/A | N/A | 1 water point | N/A | 2 or more water points |
+
**Process Water sanitation factor**
Back in the Data Processing Interface:
@@ -432,8 +476,6 @@ After completing the process, the outputs are automatically added to the Layer P
The outputs consist of all factors and subfactors, as well as the aggregation of these into the final Place Characterization output. All scores are assessed on a scale from 0 to 5, categorized as follows: ≤ 0.5 (Not Enabling) | 0.5–1.5 (Very Low Enablement) | 1.5–2.5 (Low Enablement) | 2.5–3.5 (Moderately Enabling) | 3.5–4.5 (Enabling) | 4.5–5.0 (Highly Enabling).
-[Not working - Need to be amended]
-
The outputs are stored under the Place Characterization folder within the project folder created during the setup phase as raster files. These files can be shared and further utilized for various purposes, such as visualization in QGIS or other GIS software, integration into reports, overlaying with other spatial datasets, or performing advanced geospatial analyses, such as identifying priority areas or conducting trend analysis based on the scores.
If the results do not immediately appear in the Layer Panel after processing the Place Characterization Dimension, you can resolve this by either adding them manually from the folder path or by right-clicking on the Place Characterization Dimension and selecting **Add to map** from the context menu:
@@ -466,50 +508,3 @@ If the results do not immediately appear in the Layer Panel after processing the
- **Input Accuracy**: Ensure all input datasets are carefully entered/selected and correspond to the correct factors and/or subfactors. Incorrect data will impact the outputs and subsequent analysis.
- **Weight Adjustment**: Assign weights thoughtfully to reflect the importance of each factor in the overall analysis. After making changes, always balance the weights to ensure they sum up correctly.
-
-
- 1: Default thresholds
-
-
- Active transport factor is calculated based on four factors averaged across the raster cells:
-
- Street Crossings scores: (score 0 = none, score 3 = 1 crossing, score 5 = 2+ crossings)
- Cycle Paths scores: (score 0 = none, score 3 = 1 cycle path, score 5 = 2+ paths)
- Footpaths scores: (score 0 = none, score 3 = 1 path, score 5 = 2+ paths)
- Block Sizes scores: (score 0 = none, score 1 = >1 km, score 2 = 751m-1 km, score 3 = 501m-750m, score 4 = 251m-500m, score 5 = <250m)
-
- Safety is calculated by generating 20-meter buffers around streetlights. Raster cells where 80-100% of their area intersects with these buffers are assigned a score of 5. Cells with 60-79% intersection receive a score of 4, 40-59% a score of 3, 20-39% a score of 2, and 1-19% a score of 1. Cells with no overlap are scored as 0. Note: Use nighttime light data only if streetlight data is unavailable.
-
- FCV is structured by assigning scores to raster cells based on their overlap with buffers representing different types of events. Using point locations of FCV (Fragility, Conflict, and Violence) events, create circular buffers with a radius of 5 km to estimate the spatial impact. If a specific event's impact radius is known, it should be applied instead. Raster cells intersecting with these buffers are scored as follows:
-
-
Rasters overlapping with buffers for battles and explosions: score 0
-
Rasters overlapping with buffers for explosions and remote violence: score 1
-
Rasters overlapping with buffers for violence against civilians: score 2
-
Rasters overlapping with buffers for protests and riots: score 4
-
Areas with no overlap with any event: score 5
-
-
- Education reclassifies the input data to a standardized scale from 0 to 5 using a linear scaling process. In this scale, a score of 5 represents areas where all women have a university degree, while a score of 0 represents areas where no women have a university degree.
-
- Digital Inclusion reclassifies input data to a standardized scale of 0 to 5 using a linear scaling process, where 5 represents areas where 100% of households have internet access, and 0 represents areas where no households have internet access.
-
- Environmental Hazards reclassifies input data to a standardized scale of 0 to 5 using a linear scaling process, where 5 represents areas with no environmental hazards and 0 represents areas with the highest level of hazard.
-
-Number of Fires per km² is classified into GEEST classes as follows: 0 or No Data: class 5 | 0 to 1: class 4 | 1 to 2: class 3 | 2 to 5: class 2 | 5 to 8: class 1 | Greater than 8: class 0
-
-Floods data is classified into GEEST classes as follows: No Data or 0: class 5 | Less than 180 cm: class 4 | 180–360 cm: class 3 | 360–540 cm: class 2 | 540–720 cm: class 1 | 720–900 cm: class 0
-
-Landslide data is classified into GEEST classes as follows: No Data or 0: class 5 | Slight (1): class 4 | Low Moderate1 (2): class 3 | Moderate (3): class 2 | High-Moderate2 (4): class 1 | Severe (5): class 0
-
-Tropical Cyclone frequency per 100 years is classified into GEEST classes as follows: No Data or 0: class 5 | Less than 25 events: class 4 | 25–50 events: class 3 | 50–75 events: class 2 | 75–100 events: class 1 | More than 100 events: class 0
-
-Drought data is classified into GEEST classes as follows: No Data or 0: class 5 | 0–1: class 4 | 1–2: class 3 | 2–3: class 2 | 3–4: class 1 | 4–5: class 0
-
- Water Sanitation is assessed based on the presence of water and sanitation facilities within a raster cell, applying a default 1000m buffer. The scoring is as follows:
-
-
Raster cell with no water points: score 0
-
Raster cell with 1 water point: score 3
-
Raster cell with 2 or more water points: score 5
-
-
-
diff --git a/geest/LICENSE b/geest/LICENSE
new file mode 100644
index 00000000..6f64e751
--- /dev/null
+++ b/geest/LICENSE
@@ -0,0 +1,220 @@
+# GNU GENERAL PUBLIC LICENSE
+### Version 3, 29 June 2007
+
+[GNU General Public License](https://www.gnu.org/licenses/gpl-3.0.html) (GPL-3.0-only)
+
+## Preamble
+
+The GNU General Public License is a free, copyleft license for software and other kinds of works.
+
+The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute, and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and modification follow.
+
+## TERMS AND CONDITIONS
+
+### 0. Definitions.
+
+"This License" refers to version 3 of the GNU General Public License.
+
+"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based on the Program.
+
+To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
+
+### 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
+
+A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same work.
+
+### 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
+
+### 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive**`LICENSE.md`** continued:
+```markdown
+terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
+
+### 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
+
+- a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
+- b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
+- c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
+- d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
+
+### 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
+
+- a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
+- b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
+- c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
+- d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
+- e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
+
+"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
+
+The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
+
+### 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
+
+- a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
+- b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
+- c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
+- d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
+- e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
+- f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the**`LICENSE.md`** continued:
+```markdown
+above requirements apply either way.
+
+### 8. Termination.
+
+You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
+
+### 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
+
+### 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
+
+### 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
+
+A contributor's essential patent claims are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant sublicenses in a manner consistent with the requirements of this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
+
+### 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
+
+### 13. Use with the GNU Affero General Public License.
+
+Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
+
+### 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
+
+Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
+
+### 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+### 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVE**`LICENSE.md`** continued:
+```markdown
+YS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+### 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
+
+## END OF TERMS AND CONDITIONS
+
+### How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
+
+
diff --git a/geest/__init__.py b/geest/__init__.py
index 34096155..eda3d3b0 100644
--- a/geest/__init__.py
+++ b/geest/__init__.py
@@ -36,7 +36,7 @@
# Import your plugin components here
from .core import setting # , JSONValidator
-from .utilities import resources_path, log_message
+from .utilities import resources_path, log_message, version
from .gui import GeestOptionsFactory, GeestDock
import datetime
import logging
@@ -61,6 +61,8 @@
date = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
log_message(f"»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»", force=True)
log_message(f"Geest2 started at {date}", force=True)
+version = version()
+log_message(f"Geest Version: {version}")
log_message(f"Logging output to: {log_file_path}", force=True)
log_message(f"log_path_env: {log_path_env}", force=True)
log_message(f"»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»»", force=True)
diff --git a/geest/core/reports/study_area_report.py b/geest/core/reports/study_area_report.py
new file mode 100644
index 00000000..e64cc191
--- /dev/null
+++ b/geest/core/reports/study_area_report.py
@@ -0,0 +1,484 @@
+from collections import defaultdict
+
+from qgis.core import (
+ QgsProject,
+ QgsVectorLayer,
+ QgsLayout,
+ QgsLayoutItemLabel,
+ QgsLayoutPoint,
+ QgsUnitTypes,
+ QgsLayoutExporter,
+ QgsLayoutItemMap,
+ QgsLayoutSize,
+ QgsLayoutItemPage,
+ QgsLayoutMeasurement,
+ QgsRectangle,
+ QgsLayoutItemLabel,
+ QgsLayoutItemMap,
+ QgsReadWriteContext,
+ QgsLayoutExporter,
+ QgsVectorLayer,
+ QgsLayoutItemMapGrid,
+ QgsUnitTypes,
+ QgsCoordinateReferenceSystem,
+)
+from qgis.PyQt.QtXml import QDomDocument
+from qgis.PyQt.QtGui import QFont, QColor
+from qgis.PyQt.QtCore import Qt
+from geest.utilities import log_message, resources_path
+
+
+class StudyAreaReport:
+ """
+ A class to generate a PDF report from a GeoPackage table of study area creation status data.
+
+ The report computes summary statistics (based on the field "geom_total_duration_secs")
+ and creates a QGIS layout (report) that is then exported to PDF.
+ """
+
+ def __init__(self, gpkg_path: str, report_name="Study Area Creation Report"):
+ """
+ Initialize the report.
+
+ Parameters:
+ layer_input (str): A file path to the GeoPackage (from which the
+ layer "study_area_creation_status" and other layers will be loaded).
+ report_name (str): The title to use for the report.
+
+ Raises:
+ ValueError: If the layer cannot be loaded from the given file path.
+ TypeError: If layer_input is neither a string nor a QgsVectorLayer.
+ """
+
+ uri = f"{gpkg_path}|layername=study_area_creation_status"
+ self.gpkg_path = gpkg_path
+ layer = QgsVectorLayer(uri, "study_area_creation_status", "ogr")
+ if not layer.isValid():
+ raise ValueError("Failed to load layer from the given file path.")
+
+ self.report_name = report_name
+ self.layout = None # Will hold the QgsLayout for the report
+ self.layers = self.load_layers_from_gpkg()
+ self.template_path = resources_path(
+ "resources", "qpt", f"study_area_report_template.qpt"
+ )
+ self.page_descriptions = {}
+ self.page_descriptions[
+ "study_area_bbox"
+ ] = """
+ The study area bounding box (bbox) is the outer extent of the entire study area.
+ The bounding box width and height is guaranteed to be a factor of the
+ analysis dimension. All other data products are then aligned to this bbox.
+ """
+ self.page_descriptions[
+ "study_area_bboxes"
+ ] = """
+ The study area bboxes are a set of smaller bounding boxes that surround each
+ polygon in the study area. They are grid aligned such that the origin and
+ furthest corners are guaranteed to be a factor of the analysis dimension
+ apart.
+ """
+ self.page_descriptions[
+ "study_area_polygons"
+ ] = """
+ The study area polygons are the single part form of all polygons in the
+ study area. Any invalid geometries will have been discarded.
+ """
+ self.page_descriptions[
+ "study_area_grid"
+ ] = """
+ The study area grid is a set of polygon squares that each have the
+ x and y dimension of the analysis cell size. They are guaranteed to
+ be aligned to the study area bbox and bboxes layers. The grid is used
+ to create a version of the study_area_polygons that have been expanded
+ out so that the edges align exactly to the grid.
+
+ The grid is also used to perform certain types of spatial analysis such as
+ the Active Transport layer analyses.
+ """
+ self.page_descriptions[
+ "chunks"
+ ] = """
+ The chunks are the result of splitting the study area grid into smaller
+ chunks that are used to process the study area more efficiently. Each chunk
+ is labelled as to whether it is inside, on the edge of, or outside the
+ geometry of a study area polygon. Grid cells in chunks that are 'inside' can be processed
+ more efficiently as we can skip the intersection test with the study area polygons.
+ """
+ self.page_descriptions[
+ "study_area_clip_polygons"
+ ] = """
+ The study area clip polygons are the original polygon areas but expanded so that the edges
+ of the polygon exactly coincide with the edges of the grid. This will ensure that all analysis
+ results are coherant with the grid."""
+ self.page_descriptions[
+ "study_area_creation_status"
+ ] = """
+ The study area creation status is a record of the time taken to process each part of the study area.
+ """
+
+ def __del__(self):
+ """
+ Destructor to clean up layers from the QGIS project.
+ """
+ for layer_name, layer in self.layers.items():
+ if layer:
+ del layer
+ log_message(f"Layer '{layer_name}' deleted.")
+
+ def load_layers_from_gpkg(self):
+ """
+ Load all vector layers from the specified GeoPackage.
+
+ Returns:
+ dict: A dictionary mapping layer names to QgsVectorLayer objects.
+ """
+ layers = {}
+ # Create a temporary layer to access the data provider
+ temp_layer = QgsVectorLayer(self.gpkg_path, "temp", "ogr")
+ if not temp_layer.isValid():
+ log_message(f"Failed to load GeoPackage: {self.gpkg_path}")
+ return layers
+
+ # Retrieve subLayers information
+ sub_layers = temp_layer.dataProvider().subLayers()
+ for sub_layer in sub_layers:
+ # sub_layer is a string in the format "layer_id!!::!!layer_name"
+ log_message(f"Loading layer: {sub_layer}")
+ parts = sub_layer.split("!!::!!")
+ layer_id = parts[0]
+ layer_name = parts[1]
+ uri = f"{self.gpkg_path}|layername={layer_name}"
+ layer = QgsVectorLayer(uri, layer_name, "ogr")
+ if layer.isValid():
+ layers[layer_name] = layer
+ else:
+ log_message(f"Failed to load layer: {layer_name}")
+ return layers
+
+ def compute_statistics(self, layer):
+ """
+ Compute summary statistics for a given vector layer.
+
+ Parameters:
+ layer (QgsVectorLayer): The vector layer to analyze.
+
+ Returns:
+ dict: A dictionary containing summary statistics.
+ """
+ area_counts = defaultdict(int)
+ total_count = 0
+
+ for feature in layer.getFeatures():
+ area_name = feature["area_name"]
+ area_counts[area_name] += 1
+ total_count += 1
+
+ return {"area_counts": dict(area_counts), "total_count": total_count}
+
+ def compute_study_area_creation_statistics(
+ self, field_name="geom_total_duration_secs"
+ ):
+ """
+ Compute statistical summary for a given field in the layer.
+
+ Parameters:
+ field_name (str): The attribute field on which to compute statistics.
+
+ Returns:
+ dict: A dictionary containing 'count', 'min', 'max', 'mean', 'sum', and 'std_dev'.
+ """
+ values = []
+ uri = f"{self.gpkg_path}|layername=study_area_creation_status"
+ layer = QgsVectorLayer(uri, "study_area_creation_status", "ogr")
+ for feat in layer.getFeatures():
+ val = feat[field_name]
+ if val is not None:
+ values.append(val)
+ if not values:
+ raise ValueError(f"No valid data found for field '{field_name}'.")
+
+ count_val = len(values)
+ sum_val = sum(values)
+ min_val = min(values)
+ max_val = max(values)
+ mean_val = sum_val / count_val
+ # Compute population standard deviation
+ var = sum((x - mean_val) ** 2 for x in values) / count_val
+ std_dev = var**0.5
+
+ return {
+ "count": count_val,
+ "min": min_val,
+ "max": max_val,
+ "mean": mean_val,
+ "sum": sum_val,
+ "std_dev": std_dev,
+ }
+
+ def create_layout(self):
+ """
+ Create a QGIS layout (report) that includes a title and a label with summary statistics.
+
+ The layout is stored in the attribute self.layout.
+ """
+ project = QgsProject.instance()
+ self.layout = QgsLayout(project)
+ self.layout.initializeDefaults()
+
+ self.layout.initializeDefaults()
+
+ # Load the QPT template
+ try:
+ with open(self.template_path, "r") as template_file:
+ template_content = template_file.read()
+ except IOError:
+ raise FileNotFoundError(
+ f"Template file '{self.template_path}' not found or cannot be read."
+ )
+
+ document = QDomDocument()
+ if not document.setContent(template_content):
+ raise ValueError(
+ f"Failed to parse the template content from '{self.template_path}'."
+ )
+
+ context = QgsReadWriteContext()
+ if not self.layout.loadFromTemplate(document, context):
+ raise ValueError(
+ f"Failed to load the template into the layout from '{self.template_path}'."
+ )
+
+ # Compute statistics and add a summary label
+ stats = self.compute_study_area_creation_statistics()
+ summary_text = (
+ f"Total parts: {stats['count']}\n"
+ f"Minimum processing time: {stats['min']:.3f} sec\n"
+ f"Maximum processing time: {stats['max']:.3f} sec\n"
+ f"Average processing time: {stats['mean']:.3f} sec\n"
+ f"Total processing time: {stats['sum']:.3f} sec\n"
+ f"Standard Deviation: {stats['std_dev']:.3f} sec"
+ )
+ summary_label = QgsLayoutItemLabel(self.layout)
+ summary_label.setText(summary_text)
+ summary_label.setFont(QFont("Arial", 12))
+ summary_label.adjustSizeToText()
+ summary_label.attemptMove(
+ QgsLayoutPoint(80, 200, QgsUnitTypes.LayoutMillimeters), page=0
+ )
+ self.layout.addLayoutItem(summary_label)
+
+ # Compute and add summary statistics for each layer on separate pages
+ current_page = 1
+ for page_number, (layer_name, layer) in enumerate(self.layers.items()):
+ # Add a new page for each layer
+ page = QgsLayoutItemPage(self.layout)
+ page.setPageSize("A4", QgsLayoutItemPage.Portrait)
+ self.layout.pageCollection().addPage(page)
+ # Add a title label
+ title = QgsLayoutItemLabel(self.layout)
+ # Put title in title case
+ title_text = layer_name.replace("_", " ").title()
+ title.setText(title_text)
+ title.setFont(QFont("Arial", 20))
+ title.setFixedSize(QgsLayoutSize(200, 40, QgsUnitTypes.LayoutMillimeters))
+ title.attemptMove(
+ QgsLayoutPoint(20, 20, QgsUnitTypes.LayoutMillimeters),
+ page=current_page,
+ )
+ self.layout.addLayoutItem(title)
+ # Compute statistics for the current layer
+ try:
+ summary_text = f"Layer: {layer_name}\n"
+ stats = self.compute_statistics(layer)
+ # feature_count = 0
+ # for area_name, count in stats["area_counts"].items():
+ # feature_count += count
+ summary_text += f"Total count: {stats['total_count']} features"
+ except Exception as e:
+ log_message(f"Error computing statistics for layer '{layer_name}': {e}")
+
+ description_text = self.page_descriptions.get(layer_name, "")
+ # Add description label to the current page
+ description_label = QgsLayoutItemLabel(self.layout)
+ description_label.setText(description_text)
+ description_label.setFont(QFont("Arial", 12))
+ description_label.adjustSizeToText()
+ description_label.setMode(QgsLayoutItemLabel.ModeHtml)
+
+ # Position the label on the current page
+ description_label.attemptMove(
+ QgsLayoutPoint(20, 40, QgsUnitTypes.LayoutMillimeters),
+ page=current_page,
+ )
+ description_label.setFixedSize(
+ QgsLayoutSize(80, 40, QgsUnitTypes.LayoutMillimeters)
+ )
+ description_label.setHAlign(Qt.AlignJustify)
+ self.layout.addLayoutItem(description_label)
+
+ # Add summary label to the current page
+ summary_label = QgsLayoutItemLabel(self.layout)
+ summary_label.setText(summary_text)
+ summary_label.setFont(QFont("Arial", 12))
+ summary_label.adjustSizeToText()
+ # Position the label on the current page
+ summary_label.attemptMove(
+ QgsLayoutPoint(120, 60, QgsUnitTypes.LayoutMillimeters),
+ page=current_page,
+ )
+ self.layout.addLayoutItem(summary_label)
+
+ # Add a map item for the current layer
+ map_item = QgsLayoutItemMap(self.layout)
+ map_item.setLayers([layer])
+ map_item.attemptMove(
+ QgsLayoutPoint(20, 110, QgsUnitTypes.LayoutMillimeters),
+ page=current_page,
+ )
+ map_width_mm = 170
+ map_height_mm = 100
+ map_item.attemptResize(
+ # 170mm width x 100mm height
+ QgsLayoutSize(
+ map_width_mm, map_height_mm, QgsUnitTypes.LayoutMillimeters
+ )
+ )
+ # if the extent does not have the same aspect ratio as
+ # the map item, the extent will be expanded to fit the map item
+ # Calculate the aspect ratio of the map item
+ map_aspect_ratio = map_width_mm / map_height_mm
+ # ---------------------------
+ # Set up a grid over the map
+ # ---------------------------
+ # Create a new map grid for the map item
+ grid = QgsLayoutItemMapGrid("Grid 1", map_item)
+ grid.setEnabled(True)
+ grid.setCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
+
+ # Specify that the grid is a graticule (i.e. based on geographic coordinates)
+ # grid.setGridType(QgsLayoutItemMapGrid.Graticule)
+
+ # Define a grid interval of 1 degree.
+ grid.setIntervalX(1)
+ grid.setIntervalY(1)
+
+ grid.setAnnotationDirection(
+ QgsLayoutItemMapGrid.Vertical, QgsLayoutItemMapGrid.Bottom
+ )
+ grid.setAnnotationDirection(
+ QgsLayoutItemMapGrid.Vertical, QgsLayoutItemMapGrid.Top
+ )
+
+ # (Optional) Enable and configure annotations for the grid lines
+ grid.setAnnotationEnabled(True)
+ # Example format: degrees and minutes (you can customize this format as needed)
+ # grid.setAnnotationFormat("dd° mm'")
+
+ # Add the grid to the map item. The map_item.grids() returns a list;
+ # append our configured grid to it.
+ map_item.grids().addGrid(grid)
+
+ # If needed, refresh or update your layout to see the grid applied.
+
+ # Get the current extent of the layer
+ layer_extent = layer.extent()
+
+ # Calculate the aspect ratio of the layer's extent
+ layer_aspect_ratio = layer_extent.width() / layer_extent.height()
+
+ # Initialize variables for the new extent
+ new_extent = QgsRectangle(layer_extent)
+
+ # Adjust the extent to match the map item's aspect ratio
+ if layer_aspect_ratio > map_aspect_ratio:
+ # Layer is wider than the map item; adjust height
+ new_height = layer_extent.width() / map_aspect_ratio
+ height_diff = new_height - layer_extent.height()
+ new_extent.setYMinimum(layer_extent.yMinimum() - height_diff / 2)
+ new_extent.setYMaximum(layer_extent.yMaximum() + height_diff / 2)
+ else:
+ # Layer is taller than the map item; adjust width
+ new_width = layer_extent.height() * map_aspect_ratio
+ width_diff = new_width - layer_extent.width()
+ new_extent.setXMinimum(layer_extent.xMinimum() - width_diff / 2)
+ new_extent.setXMaximum(layer_extent.xMaximum() + width_diff / 2)
+
+ # Set the new extent to the map item
+ map_item.setExtent(new_extent)
+
+ map_item.refresh()
+ self.layout.addLayoutItem(map_item)
+ # Add a black frame around the map item
+ map_item.setFrameEnabled(True)
+ map_item.setFrameStrokeColor(QColor(0, 0, 0))
+ map_item.setFrameStrokeWidth(QgsLayoutMeasurement(0.5))
+ # Add the page footer
+ self.add_header_and_footer(page_number=current_page)
+ current_page += 1
+
+ def add_header_and_footer(self, page_number):
+ """_summary_
+
+ Args:
+ page_number (_type_): _description_
+ """
+ footer_text = """
+
This plugin is built with support from the Canada Clean Energy and
+ Forest Climate Facility (CCEFCF), the Geospatial Operational
+ Support Team (GOST, DECSC) for the project Geospatial Assessment of
+ Women Employment and Business Opportunities in the Renewable Energy Sector.
+ This project is open source; you can download the code at
+ https://github.com/worldbank/GEEST.
+"""
+ credits_text = """Developed by Kartoza for and
+ with The World Bank."""
+ # Add summary label to the current page
+ footer_label = QgsLayoutItemLabel(self.layout)
+ footer_label.setText(footer_text)
+ footer_label.setFont(QFont("Arial", 8))
+ footer_label.setFixedSize(
+ QgsLayoutSize(160, 40, QgsUnitTypes.LayoutMillimeters)
+ )
+ # Use html mode
+ footer_label.setMode(QgsLayoutItemLabel.ModeHtml)
+ # Position the label on the current page
+ footer_label.attemptMove(
+ QgsLayoutPoint(20, 270, QgsUnitTypes.LayoutMillimeters), page=page_number
+ )
+ footer_label.setHAlign(Qt.AlignJustify)
+ self.layout.addLayoutItem(footer_label)
+
+ # Add credits label to the current page
+ credits_label = QgsLayoutItemLabel(self.layout)
+ credits_label.setText(credits_text)
+ credits_label.setFont(QFont("Arial", 8))
+ credits_label.setFixedSize(
+ QgsLayoutSize(160, 40, QgsUnitTypes.LayoutMillimeters)
+ )
+ # Use html mode
+ credits_label.setMode(QgsLayoutItemLabel.ModeHtml)
+ # Position the label on the current page
+ credits_label.attemptMove(
+ QgsLayoutPoint(20, 288, QgsUnitTypes.LayoutMillimeters), page=page_number
+ )
+ credits_label.setHAlign(Qt.AlignCenter)
+ self.layout.addLayoutItem(credits_label)
+
+ def export_pdf(self, output_path):
+ """
+ Export the current layout as a PDF file in raster mode.
+
+ Parameters:
+ output_path (str): The full file path (including filename) for the output PDF.
+
+ Returns:
+ bool: True if the export was successful, False otherwise.
+ """
+ if self.layout is None:
+ self.create_layout()
+ export_settings = QgsLayoutExporter.PdfExportSettings()
+ export_settings.rasterizeWholeImage = True
+ exporter = QgsLayoutExporter(self.layout)
+ result = exporter.exportToPdf(output_path, export_settings)
+ return result == QgsLayoutExporter.Success
diff --git a/geest/core/tasks/grid_chunker.py b/geest/core/tasks/grid_chunker.py
new file mode 100644
index 00000000..bc52d4a7
--- /dev/null
+++ b/geest/core/tasks/grid_chunker.py
@@ -0,0 +1,268 @@
+import os
+from osgeo import ogr, osr
+from geest.utilities import log_message
+
+
+class GridChunker:
+ """
+ A class to divide a bbox into chunks and process each chunk.
+
+ Attributes:
+ xmin (float): Minimum x-coordinate of the grid.
+ xmax (float): Maximum x-coordinate of the grid.
+ ymin (float): Minimum y-coordinate of the grid.
+ ymax (float): Maximum y-coordinate of the grid.
+ geom (ogr.Geometry): The geometry to check for intersections. (e.g. an island or land area)
+ cell_size (float): Size of each cell in the grid.
+ chunk_size (int): Number of cells in each chunk.
+ epsg (int): The EPSG code of the grid.
+
+ Methods:
+ log_message(message):
+ Logs a message to the console.
+
+ chunks():
+ Yields chunks of the grid with their bounding box coordinates.
+
+ total_cells_in_chunk():
+ Returns the total number of cells in a chunk.
+
+ total_chunks():
+ Returns the total number of chunks.
+
+ set_geometry(wkb_geometry):
+ Sets the geometry for the grid chunker.
+
+ create_layer_if_not_exists(gpkg_path):
+ Create a GPKG layer if it does not exist.
+
+ write_chunks_to_gpkg(gpkg_path):
+ Writes the chunk polygon boundaries to a GeoPackage.
+
+ Example:
+ grid_chunker = GridChunker(0, 100, 0, 100, 10, 5)
+ for chunk in grid_chunker.chunks():
+ print(chunk)
+
+ total_cells = grid_chunker.total_cells_in_chunk()
+ print(f"Total cells in each chunk: {total_cells}")
+ """
+
+ def __init__(
+ self,
+ xmin: float,
+ xmax: float,
+ ymin: float,
+ ymax: float,
+ cell_size: float,
+ chunk_size: int,
+ epsg: int,
+ geometry: bytes = None,
+ ):
+ """
+ Initializes the GridChunker with the given grid boundaries, cell size, and chunk size.
+
+ Args:
+ xmin (float): Minimum x-coordinate of the grid.
+ xmax (float): Maximum x-coordinate of the grid.
+ ymin (float): Minimum y-coordinate of the grid.
+ ymax (float): Maximum y-coordinate of the grid.
+ cell_size (float): Size of each cell in the grid.
+ chunk_size (int): Number of cells in each chunk.
+ epsg (int): The EPSG code of the grid.
+ geometry (bytes): The geometry in WKB format.
+ """
+ self.xmin = xmin
+ self.xmax = xmax
+ self.ymin = ymin
+ self.ymax = ymax
+ self.cell_size = cell_size # size in map units of each cell (typically meters)
+ self.chunk_size = (
+ chunk_size # number of cells in each chunk in both x and y directions
+ )
+ # e.g. chunk size of 5 would mean 5x5 cells in each chunk
+
+ self.x_range_count = int((xmax - xmin) / cell_size)
+ self.y_range_count = int((ymax - ymin) / cell_size)
+ self.epsg = epsg
+ self.set_geometry(geometry)
+ self.layer_name = "chunks"
+ self.gpkg_path = None # Initialize gpkg_path
+
+ def set_geometry(self, wkb_geometry):
+ """
+ Sets the geometry for the grid chunker.
+
+ Args:
+ wkb_geometry (bytes): The geometry in WKB format.
+ """
+ if wkb_geometry is None:
+ self.geometry = None
+ return
+
+ self.geometry = ogr.CreateGeometryFromWkb(wkb_geometry)
+
+ # If the geometry is a 3d geometry, convert it to 2d
+ if self.geometry.GetCoordinateDimension() == 3:
+ self.geometry.FlattenTo2D()
+
+ # Check the geom is a single part and if not, raise an error
+ if self.geometry.GetGeometryCount() > 1:
+ raise ValueError("The geometry must be a single part.")
+
+ # Check the geom is a polygon and if not, raise an error
+ if self.geometry.GetGeometryType() != ogr.wkbPolygon:
+ # Get the geomtery type name from the geometry type
+ geom_type_name = ogr.GeometryTypeToName(self.geometry.GetGeometryType())
+ raise ValueError(
+ f"The geometry must be a polygon. Received a geometry of type {geom_type_name}"
+ )
+
+ # check the geom is in the same projection as the grid by seeing if they intersect
+ if not self.geometry.Intersects(self.geometry):
+ raise ValueError("The geometry must be in the same projection as the grid.")
+
+ def create_layer_if_not_exists(self, gpkg_path):
+ """
+ Create a GPKG layer if it does not exist.
+ """
+
+ if not os.path.exists(gpkg_path):
+ # Create new GPKG
+ driver = ogr.GetDriverByName("GPKG")
+ driver.CreateDataSource(self.gpkg_path)
+
+ data_source = ogr.Open(gpkg_path, 1)
+ layer = data_source.GetLayerByName(self.layer_name)
+ if layer is not None:
+ data_source = None
+ return # Already exists
+ # Create the spatial reference, WGS84
+ srs = osr.SpatialReference()
+ srs.ImportFromEPSG(self.epsg)
+ # Create it
+ layer = data_source.CreateLayer(self.layer_name, srs, geom_type=ogr.wkbPolygon)
+ # Add fields
+ field_index = ogr.FieldDefn("index", ogr.OFTInteger)
+ layer.CreateField(field_index)
+ # Add a field to label the chunks as "inside" or "edge"
+ field_type = ogr.FieldDefn("type", ogr.OFTString)
+ layer.CreateField(field_type)
+ layer.SyncToDisk()
+
+ data_source = None
+
+ def write_chunks_to_gpkg(self, gpkg_path):
+ """
+ Writes the chunk polygon boundaries to a GeoPackage using the GDAL OGR API.
+
+ If self.geometry is not none, chunks that do not intersect with the geom
+ will be excluded. Additionally, chunks will be labelled as "inside" or "edge"
+ so that the user can easily filter out chunks that are completely inside the geometry.
+
+ Args:
+ gpkg_path (str): The file path to the GeoPackage.
+ """
+ self.create_layer_if_not_exists(gpkg_path=gpkg_path)
+ data_source = ogr.Open(gpkg_path, 1)
+ layer = data_source.GetLayerByName(self.layer_name)
+ if not layer:
+ raise RuntimeError(
+ f"Could not open target layer {self.layer_name} in {gpkg_path}"
+ )
+ # Create the feature and set values
+ layer.StartTransaction()
+
+ for chunk in self.chunks():
+ feature = ogr.Feature(layer.GetLayerDefn())
+ feature.SetField("index", chunk["index"])
+ polygon = chunk["geometry"]
+ feature.SetGeometry(polygon)
+ feature.SetField("type", chunk["type"])
+ layer.CreateFeature(feature)
+ feature = None
+ layer.CommitTransaction()
+ # Close the data source
+ data_source = None
+
+ def chunks(self):
+ """
+ Yields chunks of the grid with their bounding box coordinates.
+
+ Yields:
+ dict: A dictionary containing the index and bounding box coordinates of each chunk.
+ """
+ x_blocks = range(0, self.x_range_count, self.chunk_size)
+ y_blocks = range(0, self.y_range_count, self.chunk_size)
+ index = 0
+
+ for x_block_start in x_blocks:
+ log_message(f"Processing chunk (x) {x_block_start} of {self.x_range_count}")
+ x_block_end = min(x_block_start + self.chunk_size, self.x_range_count)
+
+ x_start_coord = self.xmin + x_block_start * self.cell_size
+ x_end_coord = self.xmin + x_block_end * self.cell_size
+
+ for y_block_start in y_blocks:
+ log_message(
+ f"Processing chunk (y) {y_block_start} of {self.y_range_count}"
+ )
+ y_block_end = min(y_block_start + self.chunk_size, self.y_range_count)
+
+ y_start_coord = self.ymin + y_block_start * self.cell_size
+ y_end_coord = self.ymin + y_block_end * self.cell_size
+
+ # Create polygon from bounding box coordinates
+ ring = ogr.Geometry(ogr.wkbLinearRing)
+ ring.AddPoint(x_start_coord, y_start_coord)
+ ring.AddPoint(x_end_coord, y_start_coord)
+ ring.AddPoint(x_end_coord, y_end_coord)
+ ring.AddPoint(x_start_coord, y_end_coord)
+ ring.AddPoint(x_start_coord, y_start_coord)
+
+ polygon = ogr.Geometry(ogr.wkbPolygon)
+ polygon.AddGeometry(ring)
+ chunk_position = None
+ # if the geometry is not none and the polygon intersects with it, add it to the layer
+ if self.geometry is not None and self.geometry.Intersects(polygon):
+ if self.geometry.Contains(polygon):
+ chunk_position = "inside"
+ else:
+ chunk_position = "edge"
+ else:
+ chunk_position = "undefined"
+ log_message(
+ f"Created Chunk bbox: {x_start_coord}, {x_end_coord}, {y_start_coord}, {y_end_coord}, {chunk_position}"
+ )
+ yield {
+ "index": index,
+ "x_start": x_start_coord,
+ "x_end": x_end_coord,
+ "y_start": y_start_coord,
+ "y_end": y_end_coord,
+ "geometry": polygon,
+ "type": chunk_position,
+ }
+ index += 1
+
+ def total_cells_in_chunk(self):
+ """
+ Returns the total number of cells in a chunk.
+
+ Returns:
+ int: The total number of cells in a chunk.
+ """
+ return self.chunk_size * self.chunk_size
+
+ def total_chunks(self):
+ """
+ Returns the total number of chunks.
+
+ Returns:
+ int: The total number of chunks.
+ """
+ count = int(self.x_range_count / self.chunk_size) * int(
+ self.y_range_count / self.chunk_size
+ )
+ log_message(f"Total chunks: {count}")
+ return count
diff --git a/geest/core/tasks/study_area.py b/geest/core/tasks/study_area.py
index 1bd93168..c4278c51 100644
--- a/geest/core/tasks/study_area.py
+++ b/geest/core/tasks/study_area.py
@@ -17,6 +17,7 @@
)
from geest.utilities import log_message
from .grid_from_bbox import GridFromBbox
+from .grid_chunker import GridChunker
from geest.core import setting
@@ -79,6 +80,7 @@ def __init__(
self.valid_feature_count = 0
self.current_geom_actual_cell_count = 0
self.current_geom_cell_count_estimate = 0
+ self.error_count = 0
self.total_cells = 0
self.write_lock = False
# Make sure output directory exists
@@ -277,6 +279,9 @@ def run(self):
log_message(
f"Processing complete. Valid: {self.valid_feature_count}, Fixed: {fixed_feature_count}, Invalid: {invalid_feature_count}"
)
+ log_message(
+ f"Areas that could not be processed due to errors: {self.error_count}"
+ )
log_message(f"Total cells generated: {self.total_cells}")
# 4) Create a VRT of all generated raster masks
@@ -412,6 +417,20 @@ def process_singlepart_geometry(self, geom, normalized_name, area_name):
normalized_name, "timestamp_start", now_str
)
+ # Check we have a single part geom
+ geom_type = ogr.GT_Flatten(geom.GetGeometryType())
+ if geom_type != ogr.wkbPolygon:
+ log_message(
+ f"Skipping non-polygon geometry type {geom_type} for {normalized_name}."
+ )
+ return
+ # check it has only one part
+ if geom.GetGeometryCount() > 1:
+ log_message(
+ f"Skipping multi-part geometry for {normalized_name}.",
+ level="WARNING",
+ )
+ return
# Compute aligned bounding box in target CRS
# (We already have a coordinate transformation if the source has a known SRS)
geometry_bbox = geom.GetEnvelope() # (xmin, xmax, ymin, ymax)
@@ -478,7 +497,10 @@ def process_multipart_geometry(self, geom, normalized_name, area_name):
for i in range(count):
part_geom = geom.GetGeometryRef(i)
part_name = f"{normalized_name}_part{i}"
- self.process_singlepart_geometry(part_geom, part_name, area_name)
+ try:
+ self.process_singlepart_geometry(part_geom, part_name, area_name)
+ except:
+ self.error_count += 1
##########################################################################
# BBox handling
@@ -627,20 +649,23 @@ def create_and_save_grid(self, normalized_name, geom, bbox):
xmin, xmax, ymin, ymax = bbox
cell_size = self.cell_size_m
+ # size is squared so 5 will make a 5x5 cell chunk
+ chunk_size = int(setting(key="chunk_size", default=50))
- log_message(
- f"Creating grid for extents: xmin {xmin}, xmax {xmax}, ymin {ymin}, ymax {ymax}"
+ chunker = GridChunker(
+ xmin,
+ xmax,
+ ymin,
+ ymax,
+ cell_size,
+ chunk_size=chunk_size,
+ epsg=self.epsg_code,
+ geometry=geom.ExportToWkb(),
)
+ chunker.write_chunks_to_gpkg(self.gpkg_path)
- # Approx count for logging
- x_range_count = int((xmax - xmin) / cell_size)
- y_range_count = int((ymax - ymin) / cell_size)
- self.current_geom_cell_count_estimate = x_range_count * y_range_count
- if self.current_geom_cell_count_estimate == 0:
- log_message("No cells to generate.")
- return
log_message(
- f"Estimated total cells to generate: {self.current_geom_cell_count_estimate}"
+ f"Creating grid for extents: xmin {xmin}, xmax {xmax}, ymin {ymin}, ymax {ymax}"
)
# OGR geometry intersection can be slow for large grids.
@@ -658,43 +683,63 @@ def create_and_save_grid(self, normalized_name, geom, bbox):
feedback = QgsFeedback()
# 1. Chunk the bounding box
- # size is squared so 5 will make a 5x5 cell chunk
- chunk_size = int(setting(key="chunk_size", default=50))
- bbox_chunks = list(
- self.chunk_bbox(xmin, xmax, ymin, ymax, cell_size, chunk_size)
- )
+
# print out all the chunk bboxes
- log_message(f"Chunk count: {len(bbox_chunks)}")
+ chunk_count = chunker.total_chunks()
+ log_message(f"Chunk count: {chunk_count}")
log_message(f"Chunk size: {chunk_size}")
- chunk_count = len(bbox_chunks)
- for idx, chunk in enumerate(bbox_chunks):
- log_message(f"Chunk {idx}: {chunk}")
self.feedback.setProgress(0)
- for idx, chunk in enumerate(bbox_chunks):
+ counter = (
+ 1 # We cant use the chunk index as it includes chunks outside the geometry
+ )
+ for chunk in chunker.chunks():
start_time = (
time.time()
) # used for both create chunk start and total chunk start
- task = GridFromBbox(idx, chunk, geom, cell_size, feedback)
- self.track_time("Creating chunks", start_time)
- # Not running in thread for now, see note below
- task.run()
-
- self.write_chunk(layer, task, normalized_name)
- # We use the progress object to notify of progress in the subtask
- # And the QgsTask progressChanged signal to track the main task
- current_progress = int(idx + 1 / (chunk_count * chunk_size))
- log_message(f"XXXXXX Chunks Progress: {current_progress}% XXXXXX")
- self.feedback.setProgress(current_progress)
+ index = chunk["index"]
+ relationship = chunk["type"] # inside, edge or undefined
+ if relationship != "undefined":
+ task = GridFromBbox(
+ index,
+ (
+ chunk["x_start"],
+ chunk["x_end"],
+ chunk["y_start"],
+ chunk["y_end"],
+ ),
+ geom,
+ cell_size,
+ feedback,
+ )
+ self.track_time("Creating chunks", start_time)
+ # Not running in thread for now, see note below
+ task.run()
+
+ self.write_chunk(layer, task, normalized_name)
+ # We use the progress object to notify of progress in the subtask
+ # And the QgsTask progressChanged signal to track the main task
+ else:
+ log_message(f"Chunk {index} is outside the geometry.")
+ continue
+ try:
+ current_progress = int((counter / chunk_count) * 100)
+ log_message(
+ f"XXXXXX Chunks Progress: {counter} / {chunk_count} : {current_progress}% XXXXXX"
+ )
+ self.feedback.setProgress(current_progress)
+ except ZeroDivisionError:
+ pass
# This is blocking, but we're in a thread
# Crashes QGIS, needs to be refactored to use QgsTask subtasks
# task.taskCompleted.connect(write_grids)
# worker_tasks.append(task)
- # log_message(f"Task {idx} created for chunk {chunk}")
+ # log_message(f"Task {index} created for chunk {chunk}")
# log_message(f"{len(worker_tasks)} tasks queued.")
# task_manager.addTask(task)
self.track_time("Complete chunk", start_time)
+ counter += 1
ds = None
# ----------------------------
# Print out metrics summary
@@ -715,12 +760,13 @@ def write_chunk(self, layer, task, normalized_name):
# If write_lock is true, wait for the lock to be released
while self.write_lock:
log_message("Waiting for write lock...")
- time.sleep(0.1)
+ time.sleep(0.001)
log_message("Write lock released.")
log_message(f"Writing {len(task.features_out)} features to layer.")
self.track_time("Preparing chunks", task.run_time)
self.write_lock = True
feat_defn = layer.GetLayerDefn()
+ layer.StartTransaction()
try:
for geometry in task.features_out:
feature = ogr.Feature(feat_defn)
@@ -729,31 +775,20 @@ def write_chunk(self, layer, task, normalized_name):
feature.SetGeometry(geometry)
layer.CreateFeature(feature)
feature = None
-
self.current_geom_actual_cell_count += 1
- if self.current_geom_actual_cell_count % 10000 == 0:
- try:
- log_message(
- f" Cell count: {self.current_geom_actual_cell_count}"
- )
- log_message(
- f" Total cells: {self.current_geom_cell_count_estimate}"
- )
- percent = (
- self.current_geom_actual_cell_count
- / float(self.current_geom_cell_count_estimate)
- ) * 100.0
- log_message(f" Percent complete: {percent:.2f}%")
- log_message(
- f"Grid creation for part {normalized_name}: {self.current_geom_actual_cell_count}/{self.current_geom_cell_count_estimate} ({percent:.2f}%)"
- )
- except ZeroDivisionError:
- pass
+ if self.current_geom_actual_cell_count % 20000 == 0:
+ log_message(
+ f" Cell count: {self.current_geom_actual_cell_count}"
+ )
+ log_message(f" Grid creation for part {normalized_name}")
# commit changes
- layer.SyncToDisk()
+ layer.CommitTransaction()
+ layer.StartTransaction()
+ layer.CommitTransaction() # Final commit
self.track_time("Writing chunks", start_time)
self.write_lock = False
except Exception as e:
+ layer.RollbackTransaction() # Rollback on error
log_message(f"write_grids: {str(e)}")
log_message(f"write_grids: {traceback.format_exc()}")
self.write_lock = False
diff --git a/geest/gui/geest_settings.py b/geest/gui/geest_settings.py
index 42bb540c..59cef3c0 100644
--- a/geest/gui/geest_settings.py
+++ b/geest/gui/geest_settings.py
@@ -34,7 +34,7 @@ def __init__(self, parent=None):
# of CPU cores you have would be a good conservative approach
# You could probably run 100 or more on a decently specced machine
self.spin_thread_pool_size.setValue(
- int(setting(key="render_thread_pool_size", default=1))
+ int(setting(key="concurrent_tasks", default=1))
)
# This is intended for developers to attach to the plugin using a
@@ -77,7 +77,7 @@ def apply(self):
.. note:: This is called on OK click.
"""
set_setting(
- key="render_thread_pool_size",
+ key="concurrent_tasks",
value=self.spin_thread_pool_size.value(),
)
diff --git a/geest/gui/panels/create_project_panel.py b/geest/gui/panels/create_project_panel.py
index cc972e16..dc1787dc 100644
--- a/geest/gui/panels/create_project_panel.py
+++ b/geest/gui/panels/create_project_panel.py
@@ -25,6 +25,8 @@
from geest.core import WorkflowQueueManager
from geest.utilities import log_message
from geest.gui.widgets import CustomBannerLabel
+from geest.core.reports.study_area_report import StudyAreaReport
+import platform
FORM_CLASS = get_ui_class("create_project_panel_base.ui")
@@ -255,7 +257,26 @@ def on_task_completed(self):
)
self.progress_bar.setVisible(False)
self.child_progress_bar.setVisible(False)
+ gpkg_path = os.path.join(self.working_dir, "study_area", "study_area.gpkg")
+ report = StudyAreaReport(gpkg_path=gpkg_path, report_name="Study Area Summary")
+ report.create_layout()
+ report.export_pdf(os.path.join(self.working_dir, "study_area_report.pdf"))
+ # open the pdf using the system PDF viewer
+ # Windows
+ if os.name == "nt": # Windows
+ os.startfile(os.path.join(self.working_directory, "study_area_report.pdf"))
+ else: # macOS and Linux
+ system = platform.system().lower()
+ if system == "darwin": # macOS
+ os.system(
+ f'open "{os.path.join(self.working_dir, "study_area_report.pdf")}"'
+ )
+ else: # Linux
+ os.system(
+ f'xdg-open "{os.path.join(self.working_dir, "study_area_report.pdf")}"'
+ )
self.enable_widgets()
+
self.switch_to_next_tab.emit()
def update_recent_projects(self, directory):
diff --git a/geest/gui/panels/tree_panel.py b/geest/gui/panels/tree_panel.py
index 26133a73..e6d05d08 100644
--- a/geest/gui/panels/tree_panel.py
+++ b/geest/gui/panels/tree_panel.py
@@ -4,6 +4,8 @@
import traceback
from logging import getLogger
from typing import Union, Dict, List
+import platform
+
from qgis.PyQt.QtWidgets import (
QAction,
QApplication,
@@ -61,6 +63,7 @@
OpportunitiesByWeeScoreProcessingTask,
OpportunitiesByWeeScorePopulationProcessingTask,
)
+from geest.core.reports.study_area_report import StudyAreaReport
from geest.utilities import log_message
@@ -74,7 +77,7 @@ def __init__(self, parent=None, json_file=None):
# Initialize the QueueManager
self.working_directory = None
- pool_size = int(setting(key="render_thread_pool_size", default=1))
+ pool_size = int(setting(key="concurrent_tasks", default=1))
self.queue_manager = WorkflowQueueManager(pool_size=pool_size)
self.json_file = json_file
self.tree_view_visible = True
@@ -553,6 +556,12 @@ def update_action_text():
add_study_area_layers_action.triggered.connect(self.add_study_area_to_map)
menu.addAction(add_study_area_layers_action)
+ add_study_area_report_action = QAction("Show Study Area Report", self)
+ add_study_area_report_action.triggered.connect(
+ self.generate_study_area_report
+ )
+ menu.addAction(add_study_area_report_action)
+
open_log_file_action = QAction("Open Log File", self)
open_log_file_action.triggered.connect(self.open_log_file)
menu.addAction(open_log_file_action)
@@ -640,6 +649,29 @@ def update_action_text():
# Show the menu at the cursor's position
menu.exec_(self.treeView.viewport().mapToGlobal(position))
+ def generate_study_area_report(self):
+ """Add a report showing population information for the study area."""
+ gpkg_path = os.path.join(
+ self.working_directory, "study_area", "study_area.gpkg"
+ )
+ report = StudyAreaReport(gpkg_path=gpkg_path, report_name="Study Area Summary")
+ report.create_layout()
+ report.export_pdf(os.path.join(self.working_directory, "study_area_report.pdf"))
+ # open the pdf using the system PDF viewer
+ # Windows
+ if os.name == "nt": # Windows
+ os.startfile(os.path.join(self.working_directory, "study_area_report.pdf"))
+ else: # macOS and Linux
+ system = platform.system().lower()
+ if system == "darwin": # macOS
+ os.system(
+ f'open "{os.path.join(self.working_directory, "study_area_report.pdf")}"'
+ )
+ else: # Linux
+ os.system(
+ f'xdg-open "{os.path.join(self.working_directory, "study_area_report.pdf")}"'
+ )
+
def add_masked_scores_to_map(self, item):
"""Add the masked scores to the map."""
self.add_to_map(
diff --git a/geest/resources/qpt/study_area_report_template.qpt b/geest/resources/qpt/study_area_report_template.qpt
new file mode 100644
index 00000000..84fd0167
--- /dev/null
+++ b/geest/resources/qpt/study_area_report_template.qpt
@@ -0,0 +1,613 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/geest/test-old/__init__.py b/geest/test-old/__init__.py
deleted file mode 100644
index 884794d9..00000000
--- a/geest/test-old/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""
-import qgis libs so that we set the correct sip api version
-"""
-
-import qgis # NOQA
diff --git a/geest/test-old/qgis_interface.py b/geest/test-old/qgis_interface.py
deleted file mode 100644
index 2f4e5285..00000000
--- a/geest/test-old/qgis_interface.py
+++ /dev/null
@@ -1,226 +0,0 @@
-# coding=utf-8
-"""QGIS plugin implementation.
-
-.. note:: This program is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
-
-.. note:: This source code was copied from the 'postgis viewer' application
- with original authors:
- Copyright (c) 2010 by Ivan Mincik, ivan.mincik@gista.sk
- Copyright (c) 2011 German Carrillo, geotux_tuxman@linuxmail.org
- Copyright (c) 2014 Tim Sutton, tim@linfiniti.com
-
-"""
-
-__author__ = "tim@linfiniti.com"
-__revision__ = "$Format:%H$"
-__date__ = "10/01/2011"
-__copyright__ = (
- "Copyright (c) 2010 by Ivan Mincik, ivan.mincik@gista.sk and "
- "Copyright (c) 2011 German Carrillo, geotux_tuxman@linuxmail.org"
- "Copyright (c) 2014 Tim Sutton, tim@linfiniti.com"
-)
-
-import logging
-from typing import List
-from PyQt5.QtCore import QObject, pyqtSlot, pyqtSignal, QSize
-from qgis.PyQt.QtWidgets import QDockWidget
-from qgis.core import QgsProject, QgsMapLayer
-from qgis.gui import QgsMapCanvas, QgsMessageBar
-
-LOGGER = logging.getLogger("QGIS")
-
-
-# noinspection PyMethodMayBeStatic,PyPep8Naming
-class QgisInterface(QObject):
- """Class to expose QGIS objects and functions to plugins.
-
- This class is here for enabling us to run unit tests only,
- so most methods are simply stubs.
- """
-
- currentLayerChanged = pyqtSignal(QgsMapLayer)
-
- def __init__(self, canvas: QgsMapCanvas):
- """Constructor
- :param canvas:
- """
- QObject.__init__(self)
- self.canvas = canvas
- # Set up slots so we can mimic the behaviour of QGIS when layers
- # are added.
- LOGGER.debug("Initialising canvas...")
- # noinspection PyArgumentList
- QgsProject.instance().layersAdded.connect(self.addLayers)
- # noinspection PyArgumentList
- QgsProject.instance().layerWasAdded.connect(self.addLayer)
- # noinspection PyArgumentList
- QgsProject.instance().removeAll.connect(self.removeAllLayers)
-
- # For processing module
- self.destCrs = None
-
- self.message_bar = QgsMessageBar()
-
- def addLayers(self, layers: List[QgsMapLayer]):
- """Handle layers being added to the registry so they show up in canvas.
-
- :param layers: list list of map layers that were added
-
- .. note:: The QgsInterface api does not include this method,
- it is added here as a helper to facilitate testing.
- """
- # LOGGER.debug('addLayers called on qgis_interface')
- # LOGGER.debug('Number of layers being added: %s' % len(layers))
- # LOGGER.debug('Layer Count Before: %s' % len(self.canvas.layers()))
- current_layers = self.canvas.layers()
- final_layers = []
- for layer in current_layers:
- final_layers.append(layer)
- for layer in layers:
- final_layers.append(layer)
-
- self.canvas.setLayers(final_layers)
- # LOGGER.debug('Layer Count After: %s' % len(self.canvas.layers()))
-
- def addLayer(self, layer: QgsMapLayer):
- """Handle a layer being added to the registry so it shows up in canvas.
-
- :param layer: list list of map layers that were added
-
- .. note: The QgsInterface api does not include this method, it is added
- here as a helper to facilitate testing.
-
- .. note: The addLayer method was deprecated in QGIS 1.8 so you should
- not need this method much.
- """
- pass # pylint: disable=unnecessary-pass
-
- @pyqtSlot()
- def removeAllLayers(self): # pylint: disable=no-self-use
- """Remove layers from the canvas before they get deleted."""
- self.canvas.setLayers([])
-
- def newProject(self): # pylint: disable=no-self-use
- """Create new project."""
- # noinspection PyArgumentList
- QgsProject.instance().clear()
-
- # ---------------- API Mock for QgsInterface follows -------------------
-
- def zoomFull(self):
- """Zoom to the map full extent."""
- pass # pylint: disable=unnecessary-pass
-
- def zoomToPrevious(self):
- """Zoom to previous view extent."""
- pass # pylint: disable=unnecessary-pass
-
- def zoomToNext(self):
- """Zoom to next view extent."""
- pass # pylint: disable=unnecessary-pass
-
- def zoomToActiveLayer(self):
- """Zoom to extent of active layer."""
- pass # pylint: disable=unnecessary-pass
-
- def addVectorLayer(self, path: str, base_name: str, provider_key: str):
- """Add a vector layer.
-
- :param path: Path to layer.
- :type path: str
-
- :param base_name: Base name for layer.
- :type base_name: str
-
- :param provider_key: Provider key e.g. 'ogr'
- :type provider_key: str
- """
- pass # pylint: disable=unnecessary-pass
-
- def addRasterLayer(self, path: str, base_name: str):
- """Add a raster layer given a raster layer file name
-
- :param path: Path to layer.
- :type path: str
-
- :param base_name: Base name for layer.
- :type base_name: str
- """
- pass # pylint: disable=unnecessary-pass
-
- def activeLayer(self) -> QgsMapLayer: # pylint: disable=no-self-use
- """Get pointer to the active layer (layer selected in the legend)."""
- # noinspection PyArgumentList
- layers = QgsProject.instance().mapLayers()
- for item in layers:
- return layers[item]
-
- def addToolBarIcon(self, action):
- """Add an icon to the plugins toolbar.
-
- :param action: Action to add to the toolbar.
- :type action: QAction
- """
- pass # pylint: disable=unnecessary-pass
-
- def removeToolBarIcon(self, action):
- """Remove an action (icon) from the plugin toolbar.
-
- :param action: Action to add to the toolbar.
- :type action: QAction
- """
- pass # pylint: disable=unnecessary-pass
-
- def addToolBar(self, name):
- """Add toolbar with specified name.
-
- :param name: Name for the toolbar.
- :type name: str
- """
- pass # pylint: disable=unnecessary-pass
-
- def mapCanvas(self) -> QgsMapCanvas:
- """Return a pointer to the map canvas."""
- return self.canvas
-
- def mainWindow(self):
- """Return a pointer to the main window.
-
- In case of QGIS it returns an instance of QgisApp.
- """
- pass # pylint: disable=unnecessary-pass
-
- def addDockWidget(self, area, dock_widget: QDockWidget):
- """Add a dock widget to the main window.
-
- :param area: Where in the ui the dock should be placed.
- :type area:
-
- :param dock_widget: A dock widget to add to the UI.
- :type dock_widget: QDockWidget
- """
- pass # pylint: disable=unnecessary-pass
-
- def legendInterface(self):
- """Get the legend."""
- return self.canvas
-
- def iconSize(self, dockedToolbar) -> int:
- """
- Returns the toolbar icon size.
- :param dockedToolbar: If True, the icon size
- for toolbars contained within docks is returned.
- """
- if dockedToolbar:
- return QSize(16, 16)
-
- return QSize(24, 24)
-
- def messageBar(self) -> QgsMessageBar:
- """
- Return the message bar of the main app
- """
- return self.message_bar
diff --git a/geest/test-old/tenbytenraster.asc b/geest/test-old/tenbytenraster.asc
deleted file mode 100644
index 96a0ee1f..00000000
--- a/geest/test-old/tenbytenraster.asc
+++ /dev/null
@@ -1,19 +0,0 @@
-NCOLS 10
-NROWS 10
-XLLCENTER 1535380.000000
-YLLCENTER 5083260.000000
-DX 10
-DY 10
-NODATA_VALUE -9999
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-0 1 2 3 4 5 6 7 8 9
-CRS
-NOTES
diff --git a/geest/test-old/tenbytenraster.asc.aux.xml b/geest/test-old/tenbytenraster.asc.aux.xml
deleted file mode 100644
index cfb15788..00000000
--- a/geest/test-old/tenbytenraster.asc.aux.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
- Point
-
-
-
- 9
- 4.5
- 0
- 2.872281323269
-
-
-
diff --git a/geest/test-old/tenbytenraster.keywords b/geest/test-old/tenbytenraster.keywords
deleted file mode 100644
index 8be3f615..00000000
--- a/geest/test-old/tenbytenraster.keywords
+++ /dev/null
@@ -1 +0,0 @@
-title: Tenbytenraster
diff --git a/geest/test-old/tenbytenraster.lic b/geest/test-old/tenbytenraster.lic
deleted file mode 100644
index 83455332..00000000
--- a/geest/test-old/tenbytenraster.lic
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
- Tim Sutton, Linfiniti Consulting CC
-
-
-
- tenbytenraster.asc
- 2700044251
- Yes
- Tim Sutton
- Tim Sutton (QGIS Source Tree)
- Tim Sutton
- This data is publicly available from QGIS Source Tree. The original
- file was created and contributed to QGIS by Tim Sutton.
-
-
-
diff --git a/geest/test-old/tenbytenraster.prj b/geest/test-old/tenbytenraster.prj
deleted file mode 100644
index a30c00a5..00000000
--- a/geest/test-old/tenbytenraster.prj
+++ /dev/null
@@ -1 +0,0 @@
-GEOGCS["GCS_WGS_1984",DATUM["D_WGS_1984",SPHEROID["WGS_1984",6378137,298.257223563]],PRIMEM["Greenwich",0],UNIT["Degree",0.017453292519943295]]
\ No newline at end of file
diff --git a/geest/test-old/tenbytenraster.qml b/geest/test-old/tenbytenraster.qml
deleted file mode 100644
index 85247d44..00000000
--- a/geest/test-old/tenbytenraster.qml
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 0
-
diff --git a/geest/test-old/test_animation_controller.py b/geest/test-old/test_animation_controller.py
deleted file mode 100644
index e8307ca7..00000000
--- a/geest/test-old/test_animation_controller.py
+++ /dev/null
@@ -1,1317 +0,0 @@
-# coding=utf-8
-"""GUI Utils Test.
-
-.. note:: This program is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
-
-"""
-
-# pylint: disable=too-many-lines
-
-__author__ = "(C) 2018 by Nyall Dawson"
-__date__ = "20/04/2018"
-__copyright__ = "Copyright 2018, North Road"
-# This will get replaced with a git SHA1 when you do a git archive
-__revision__ = "$Format:%H$"
-
-import unittest
-
-from qgis.PyQt.QtCore import QSize, QEasingCurve
-from qgis.core import (
- QgsMapSettings,
- QgsRectangle,
- QgsCoordinateReferenceSystem,
- QgsReferencedRectangle,
- QgsVectorLayer,
- QgsFeature,
- QgsGeometry,
- QgsPointXY,
-)
-
-from animation_workbench.core import AnimationController, MapMode
-from .utilities import get_qgis_app
-
-QGIS_APP = get_qgis_app()
-
-
-class AnimationControllerTest(unittest.TestCase):
- """Test AnimationController works."""
-
- # pylint: disable=too-many-statements
-
- def test_fixed_extent(self):
- """
- Test a fixed extent job
- """
- map_settings = QgsMapSettings()
- map_settings.setExtent(QgsRectangle(1, 2, 3, 4))
- map_settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
- map_settings.setOutputSize(QSize(400, 300))
- extent = QgsReferencedRectangle(
- map_settings.extent(), map_settings.destinationCrs()
- )
- controller = AnimationController.create_fixed_extent_controller(
- map_settings=map_settings,
- output_mode="1280:720",
- feature_layer=None,
- output_extent=extent,
- total_frames=5,
- frame_rate=10,
- )
-
- it = controller.create_jobs()
- # should be 5 frames
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 0)
- self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1122330,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 0
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 5
- )
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 1)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1122330,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 5
- )
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 2)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1122330,
- delta=1200003,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 5
- )
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 3)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1122330,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 3
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 5
- )
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 1122330, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 4)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1122330,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 4
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 5
- )
-
- with self.assertRaises(StopIteration):
- next(it)
-
- def test_fixed_extent_with_layer(self):
- """
- Test a fixed extent job with a layer
- """
-
- vl = QgsVectorLayer("Point?crs=EPSG:4326&field=name:string", "vl", "memory")
- self.assertTrue(vl.isValid())
-
- f = QgsFeature(vl.fields())
- f["name"] = "f1"
- f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(1, 2)))
- self.assertTrue(vl.dataProvider().addFeature(f))
-
- f["name"] = "f2"
- f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(10, 20)))
- self.assertTrue(vl.dataProvider().addFeature(f))
-
- map_settings = QgsMapSettings()
- map_settings.setExtent(QgsRectangle(1, 2, 3, 4))
- map_settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
- map_settings.setOutputSize(QSize(400, 300))
- extent = QgsReferencedRectangle(
- map_settings.extent(), map_settings.destinationCrs()
- )
- controller = AnimationController.create_fixed_extent_controller(
- map_settings=map_settings,
- output_mode=None, # Will use map canvas dimensions
- feature_layer=vl,
- output_extent=extent,
- total_frames=2,
- frame_rate=10,
- )
-
- it = controller.create_jobs()
- # should be 4 frames
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 0)
- self.assertAlmostEqual(job.map_settings.scale(), 2693593, delta=120000)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 2693593,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 0
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 0,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(),
- 1,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"),
- 1,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 1)
- # self.assertEqual(job.map_settings.expressionContext().variable('total_frame_count'), 4)
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 2693593, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 1)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 2693593,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 1,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(),
- 1,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"),
- 1,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 1)
- # self.assertEqual(job.map_settings.expressionContext().variable('total_frame_count'), 4)
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 2693593, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 2)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 2693593,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 0,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature_id"), 1
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- # self.assertEqual(job.map_settings.expressionContext().variable('total_frame_count'), 4)
- job = next(it)
- self.assertEqual(job.map_settings.extent(), map_settings.extent())
- self.assertAlmostEqual(job.map_settings.scale(), 2693593, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 10)
- self.assertEqual(job.map_settings.currentFrame(), 3)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 2693593,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 3
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_rate"), 10
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 1,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature_id"), 1
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- # self.assertEqual(job.map_settings.expressionContext().variable('total_frame_count'), 4)
-
- with self.assertRaises(StopIteration):
- next(it)
-
- def test_planar(self):
- """
- Test a planar job
- """
-
- vl = QgsVectorLayer("Point?crs=EPSG:4326&field=name:string", "vl", "memory")
- self.assertTrue(vl.isValid())
-
- f = QgsFeature(vl.fields())
- f["name"] = "f1"
- f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(1, 2)))
- self.assertTrue(vl.dataProvider().addFeature(f))
-
- f["name"] = "f2"
- f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(10, 20)))
- self.assertTrue(vl.dataProvider().addFeature(f))
-
- map_settings = QgsMapSettings()
- map_settings.setExtent(QgsRectangle(1, 2, 3, 4))
- map_settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
- map_settings.setOutputSize(QSize(400, 300))
- controller = AnimationController.create_moving_extent_controller(
- map_settings=map_settings,
- output_mode=None, # Will use map canvas dimensions
- mode=MapMode.PLANAR,
- feature_layer=vl,
- travel_duration=2,
- hover_duration=1,
- min_scale=2000000,
- max_scale=1000000,
- pan_easing=QEasingCurve(QEasingCurve.Type.Linear),
- zoom_easing=QEasingCurve(QEasingCurve.Type.Linear),
- frame_rate=2,
- )
-
- it = controller.create_jobs()
-
- job = next(it)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 0)
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 0
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"), 1
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature_id"), 2
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("from_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("from_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("to_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("to_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 0,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_travel_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("travel_frames")
- )
-
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 1)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
- job = next(it)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- # Changed from 44
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 1)
- # Changed from 44
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 1
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"), 1
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature_id"), 2
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("from_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("from_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("to_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("to_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 1,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_travel_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("travel_frames")
- )
-
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 1)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
-
- # now we start panning
- job = next(it)
- # Changed from 44
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 2)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 2
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 2
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 0
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- job = next(it)
- self.assertAlmostEqual(job.map_settings.scale(), 1599999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 4, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 8, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 3)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1599999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 3
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 2
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 1
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- job = next(it)
- # was 16666
- self.assertAlmostEqual(job.map_settings.scale(), 1599999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 7, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 14, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 4)
- # Was 1666666
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1599999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 2
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 2
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- job = next(it)
- # was 10
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 5)
- # was 10
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 5
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("previous_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 2
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 3
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- # back to hovering
-
- job = next(it)
- # was 10
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 6)
- # was 19
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 6
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature_id"), 1
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("from_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("from_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("to_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("to_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 0,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_travel_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("travel_frames")
- )
-
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
-
- job = next(it)
- # was 10
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 7)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 7
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature_id"), 1
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("next_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("next_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("from_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("from_feature_id")
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("to_feature"))
- self.assertIsNone(
- job.map_settings.expressionContext().variable("to_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_hover_frame"),
- 1,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_travel_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_frames"),
- 2,
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("travel_frames")
- )
-
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 8
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Hovering",
- )
-
- with self.assertRaises(StopIteration):
- next(it)
-
- def test_planar_loop(self):
- """
- Test a planar job with looping
- """
-
- vl = QgsVectorLayer("Point?crs=EPSG:4326&field=name:string", "vl", "memory")
- self.assertTrue(vl.isValid())
-
- f = QgsFeature(vl.fields())
- f["name"] = "f1"
- f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(1, 2)))
- self.assertTrue(vl.dataProvider().addFeature(f))
-
- f["name"] = "f2"
- f.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(10, 20)))
- self.assertTrue(vl.dataProvider().addFeature(f))
-
- map_settings = QgsMapSettings()
- map_settings.setExtent(QgsRectangle(1, 2, 3, 4))
- map_settings.setDestinationCrs(QgsCoordinateReferenceSystem("EPSG:4326"))
- map_settings.setOutputSize(QSize(400, 300))
- controller = AnimationController.create_moving_extent_controller(
- map_settings=map_settings,
- output_mode=None, # Will use map canvas dimensions
- mode=MapMode.PLANAR,
- feature_layer=vl,
- travel_duration=2,
- hover_duration=1,
- min_scale=2000000,
- max_scale=1000000,
- pan_easing=QEasingCurve(QEasingCurve.Type.Linear),
- zoom_easing=QEasingCurve(QEasingCurve.Type.Linear),
- frame_rate=2,
- loop=True,
- )
-
- it = controller.create_jobs()
-
- job = next(it)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 0)
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("hover_feature_id"), 1
- )
- # make sure previous_feature is set to wrap around back to start
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature_id"), 2
- )
-
- job = next(it)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 1)
-
- # now we start panning
- job = next(it)
- # Changed from 44
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 2)
-
- job = next(it)
- self.assertAlmostEqual(job.map_settings.scale(), 1599999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 4, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 8, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 3)
-
- job = next(it)
- self.assertAlmostEqual(job.map_settings.scale(), 1600000, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 7, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 14, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 4)
-
- job = next(it)
- # Changed from 44
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 5)
-
- # back to hovering
-
- job = next(it)
- # Changed from 44
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 6)
-
- job = next(it)
- # Was 44444
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 7)
-
- # make sure next_feature is set to wrap around back to start
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("previous_feature_id"), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("next_feature_id"), 1
- )
-
- # travel from last to first
- job = next(it)
- # was 15999994
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 10, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 20, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 8)
- # Was 10
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 8
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 1
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 0
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 12
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- job = next(it)
- # was 16
- self.assertAlmostEqual(job.map_settings.scale(), 1599999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 7, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 14, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 9)
- # was 16
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1599999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 9
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 1
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 1
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 12
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- job = next(it)
- # was 16
- self.assertAlmostEqual(job.map_settings.scale(), 1599999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 4, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 8, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 10)
- # was 16
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 1599999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 10
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 1
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 2
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 12
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- job = next(it)
- self.assertAlmostEqual(job.map_settings.scale(), 959999, delta=120000)
- self.assertAlmostEqual(job.map_settings.extent().center().x(), 1, 2)
- self.assertAlmostEqual(job.map_settings.extent().center().y(), 2, 2)
- self.assertEqual(job.map_settings.frameRate(), 2)
- self.assertEqual(job.map_settings.currentFrame(), 11)
- self.assertAlmostEqual(
- job.map_settings.expressionContext().variable("map_scale"),
- 959999,
- delta=120000,
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("frame_number"), 11
- )
- self.assertEqual(job.map_settings.expressionContext().variable("frame_rate"), 2)
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature")
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("hover_feature_id")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature").id(), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("from_feature_id"), 2
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature").id(), 1
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("to_feature_id"), 1
- )
- self.assertIsNone(
- job.map_settings.expressionContext().variable("current_hover_frame")
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_travel_frame"), 3
- )
- self.assertIsNone(job.map_settings.expressionContext().variable("hover_frames"))
- self.assertEqual(
- job.map_settings.expressionContext().variable("travel_frames"), 4
- )
- self.assertEqual(job.map_settings.expressionContext().feature().id(), 2)
- self.assertEqual(
- job.map_settings.expressionContext().variable("total_frame_count"), 12
- )
- self.assertEqual(
- job.map_settings.expressionContext().variable("current_animation_action"),
- "Travelling",
- )
-
- with self.assertRaises(StopIteration):
- next(it)
-
-
-if __name__ == "__main__":
- suite = unittest.makeSuite(AnimationControllerTest)
- runner = unittest.TextTestRunner(verbosity=2)
- runner.run(suite)
diff --git a/geest/test-old/test_init.py b/geest/test-old/test_init.py
deleted file mode 100644
index 358e0568..00000000
--- a/geest/test-old/test_init.py
+++ /dev/null
@@ -1,75 +0,0 @@
-# coding=utf-8
-"""Tests QGIS plugin init.
-
-.. note:: This program is free software; you can redistribute it and/or modify
-it under the terms of the GNU General Public License as published by
-the Free Software Foundation; either version 2 of the License, or
-(at your option) any later version.
-"""
-
-__author__ = "Nyall Dawson "
-__revision__ = "$Format:%H$"
-__date__ = "20/04/2018"
-__license__ = "GPL"
-__copyright__ = "Copyright 2018, LINZ"
-
-
-import os
-import unittest
-import logging
-import configparser
-
-
-LOGGER = logging.getLogger("QGIS")
-
-
-class TestInit(unittest.TestCase):
- """Test that the plugin init is usable for QGIS.
-
- Based heavily on the validator class by Alessandro
- Passoti available here:
-
- http://github.com/qgis/qgis-django/blob/master/qgis-app/
- plugins/validator.py
-
- """
-
- def test_read_init(self):
- """Test that the plugin __init__ will validate on plugins.qgis.org."""
-
- # You should update this list according to the latest in
- # https://github.com/qgis/qgis-django/blob/master/qgis-app/
- # plugins/validator.py
-
- required_metadata = [
- "name",
- "description",
- "version",
- "qgisMinimumVersion",
- "email",
- "author",
- ]
-
- file_path = os.path.abspath(
- os.path.join(os.path.dirname(__file__), os.pardir, "metadata.txt")
- )
- LOGGER.info(file_path)
- metadata = []
- parser = configparser.ConfigParser()
- parser.optionxform = str
- parser.read(file_path)
- message = 'Cannot find a section named "general" in %s' % file_path
- assert parser.has_section("general"), message
- metadata.extend(parser.items("general"))
-
- for expectation in required_metadata:
- message = 'Cannot find metadata "%s" in metadata source (%s).' % (
- expectation,
- file_path,
- )
-
- self.assertIn(expectation, dict(metadata), message)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/geest/test-old/test_movie_creator.py b/geest/test-old/test_movie_creator.py
deleted file mode 100644
index f9894dda..00000000
--- a/geest/test-old/test_movie_creator.py
+++ /dev/null
@@ -1,217 +0,0 @@
-# coding=utf-8
-"""Movie creator test.
-
-.. note:: This program is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
-
-"""
-
-__author__ = "(C) 2018 by Nyall Dawson"
-__date__ = "20/04/2018"
-__copyright__ = "Copyright 2018, North Road"
-# This will get replaced with a git SHA1 when you do a git archive
-__revision__ = "$Format:%H$"
-
-import unittest
-
-from animation_workbench.core import MovieCommandGenerator, MovieFormat
-from .utilities import get_qgis_app
-
-QGIS_APP = get_qgis_app()
-
-
-class MovieCreatorTest(unittest.TestCase):
- """Test MovieCommandGenerator works."""
-
- # pylint: disable=too-many-statements
-
- def test_mp4(self):
- """
- Test mp4 command generation
- """
- generator = MovieCommandGenerator(
- output_file="/home/me/videos/test.mp4",
- output_mode="1920:1080",
- intro_command=None,
- outro_command=None,
- music_command=None,
- output_format=MovieFormat.MP4,
- work_directory="/tmp/movies",
- frame_filename_prefix="frames",
- framerate=90,
- temp_dir="/tmp",
- )
-
- commands = generator.as_commands()
- self.assertEqual(
- commands,
- [
- (
- "/usr/bin/ffmpeg",
- [
- "-hide_banner",
- "-y",
- "-framerate",
- "90",
- "-i",
- "/tmp/movies/frames-%010d.png",
- "-vf",
- "pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white",
- "-c:v",
- "libx264",
- "-pix_fmt",
- "yuv420p",
- "/tmp/main.mp4",
- ],
- ),
- (
- "/usr/bin/ffmpeg",
- [
- "-y",
- "-f",
- "concat",
- "-safe",
- "0",
- "-i",
- "/tmp/list.txt",
- "-c",
- "copy",
- "-vf",
- "pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white,scale=1920:1080,setsar=1:1",
- "-c:v",
- "libx264",
- "-pix_fmt",
- "yuv420p",
- "/home/me/videos/test.mp4",
- ],
- ),
- ],
- )
-
- def test_mp4_with_music(self):
- """
- Test mp4 command generation
- """
- self.maxDiff = None
- generator = MovieCommandGenerator(
- output_file="/home/me/videos/test.mp4",
- output_mode="1920:1080",
- intro_command=None,
- outro_command=None,
- music_command=None,
- output_format=MovieFormat.MP4,
- work_directory="/tmp/movies",
- frame_filename_prefix="frames",
- framerate=90,
- temp_dir="/tmp",
- )
-
- commands = generator.as_commands()
- self.assertEqual(
- commands,
- [
- (
- "/usr/bin/ffmpeg",
- [
- "-hide_banner",
- "-y",
- "-framerate",
- "90",
- "-i",
- "/tmp/movies/frames-%010d.png",
- "-vf",
- "pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white",
- "-c:v",
- "libx264",
- "-pix_fmt",
- "yuv420p",
- "/tmp/main.mp4",
- ],
- ),
- (
- "/usr/bin/ffmpeg",
- [
- "-y",
- "-f",
- "concat",
- "-safe",
- "0",
- "-i",
- "/tmp/list.txt",
- "-c",
- "copy",
- "-vf",
- "pad=ceil(iw/2)*2:ceil(ih/2)*2:color=white,"
- "scale=1920:1080,setsar=1:1",
- "-c:v",
- "libx264",
- "-pix_fmt",
- "yuv420p",
- "/home/me/videos/test.mp4",
- ],
- ),
- ],
- )
-
- def test_gif(self):
- """
- Test gif command generation
- """
- generator = MovieCommandGenerator(
- output_file="/home/me/videos/test.gif",
- output_mode="720p",
- intro_command=None,
- outro_command=None,
- music_command=None,
- output_format=MovieFormat.GIF,
- work_directory="/tmp/movies",
- frame_filename_prefix="frames",
- framerate=90,
- temp_dir="/tmp/",
- )
-
- commands = generator.as_commands()
- self.assertEqual(
- commands,
- [
- (
- "/usr/bin/convert",
- [
- "-delay",
- "1.1111111111111112",
- "-loop",
- "0",
- "/tmp/movies/frames-*.png",
- "/home/me/videos/test.gif",
- ],
- ),
- (
- "/usr/bin/convert",
- [
- "/home/me/videos/test.gif",
- "-coalesce",
- "-scale",
- "600x600",
- "-fuzz",
- "2%",
- "+dither",
- "-remap",
- "/home/me/videos/test.gif[20]",
- "+dither",
- "-colors",
- "14",
- "-layers",
- "Optimize",
- "/tmp/movies/animation_small.gif",
- ],
- ),
- ],
- )
-
-
-if __name__ == "__main__":
- suite = unittest.makeSuite(MovieCreatorTest)
- runner = unittest.TextTestRunner(verbosity=2)
- runner.run(suite)
diff --git a/geest/test-old/test_qgis_environment.py b/geest/test-old/test_qgis_environment.py
deleted file mode 100644
index c35308b0..00000000
--- a/geest/test-old/test_qgis_environment.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# coding=utf-8
-"""Tests for QGIS functionality.
-
-.. note:: This program is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
-
-"""
-__author__ = "tim@linfiniti.com"
-__date__ = "20/01/2011"
-__copyright__ = "(C) 2012, Australia Indonesia Facility for Disaster Reduction"
-
-import unittest
-from qgis.core import QgsProviderRegistry
-from .utilities import get_qgis_app
-
-
-QGIS_APP = get_qgis_app()
-
-
-class QGISTest(unittest.TestCase):
- """Test the QGIS Environment"""
-
- def test_qgis_environment(self):
- """QGIS environment has the expected providers"""
-
- r = QgsProviderRegistry.instance()
- self.assertIn("gdal", r.providerList())
- self.assertIn("ogr", r.providerList())
- self.assertIn("postgres", r.providerList())
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/geest/test-old/test_translations.py b/geest/test-old/test_translations.py
deleted file mode 100644
index 9d8f5d95..00000000
--- a/geest/test-old/test_translations.py
+++ /dev/null
@@ -1,57 +0,0 @@
-# coding=utf-8
-"""Translations Test.
-
-.. note:: This program is free software; you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation; either version 2 of the License, or
- (at your option) any later version.
-
-"""
-
-__author__ = "ismailsunni@yahoo.co.id"
-__date__ = "12/10/2011"
-__copyright__ = "(C) 2012, Australia Indonesia Facility for Disaster Reduction"
-
-import unittest
-import os
-from qgis.PyQt.QtCore import QCoreApplication, QTranslator
-from .utilities import get_qgis_app
-
-QGIS_APP = get_qgis_app()
-
-
-class SafeTranslationsTest(unittest.TestCase):
- """Test translations work."""
-
- def setUp(self):
- """Runs before each test."""
- if "LANG" in os.environ:
- del os.environ["LANG"]
-
- def tearDown(self):
- """Runs after each test."""
- if "LANG" in os.environ:
- del os.environ["LANG"]
-
- def test_qgis_translations(self):
- """Test that translations work."""
- parent_path = os.path.join(__file__, os.path.pardir, os.path.pardir)
- dir_path = os.path.abspath(parent_path)
- file_path = os.path.join(dir_path, "i18n", "af.qm")
- self.assertTrue(
- os.path.isfile(file_path),
- "%s is not a valid translation file or it does not exist" % file_path,
- )
- translator = QTranslator()
- translator.load(file_path)
- QCoreApplication.installTranslator(translator)
-
- expected_message = "Goeie more"
- real_message = QCoreApplication.translate("@default", "Good morning")
- self.assertEqual(real_message, expected_message)
-
-
-if __name__ == "__main__":
- suite = unittest.makeSuite(SafeTranslationsTest)
- runner = unittest.TextTestRunner(verbosity=2)
- runner.run(suite)
diff --git a/geest/test-old/utilities.py b/geest/test-old/utilities.py
deleted file mode 100644
index 0903bb25..00000000
--- a/geest/test-old/utilities.py
+++ /dev/null
@@ -1,106 +0,0 @@
-# coding=utf-8
-"""Common functionality used by regression tests."""
-
-import sys
-import logging
-import os
-import atexit
-
-from qgis.core import QgsApplication
-from qgis.utils import iface
-from qgis.gui import QgsMapCanvas
-from qgis.PyQt.QtCore import QSize
-from qgis.PyQt.QtWidgets import QWidget
-
-from .qgis_interface import QgisInterface
-
-LOGGER = logging.getLogger("QGIS")
-QGIS_APP = None # Static variable used to hold hand to running QGIS app
-CANVAS = None
-PARENT = None
-IFACE = None
-
-
-def get_qgis_app(cleanup=True):
- """Start one QGIS application to test against.
-
- :returns: Handle to QGIS app, canvas, iface and parent. If there are any
- errors the tuple members will be returned as None.
- :rtype: (QgsApplication, CANVAS, IFACE, PARENT)
-
- If QGIS is already running the handle to that app will be returned.
- """
-
- global QGIS_APP, PARENT, IFACE, CANVAS # pylint: disable=W0603
-
- if iface:
- QGIS_APP = QgsApplication
- CANVAS = iface.mapCanvas()
- PARENT = iface.mainWindow()
- IFACE = iface
- return QGIS_APP, CANVAS, IFACE, PARENT
-
- global QGISAPP # pylint: disable=global-variable-undefined
-
- try:
- QGISAPP # pylint: disable=used-before-assignment
- except NameError:
- myGuiFlag = True # All test will run qgis in gui mode
-
- # In python3 we need to convert to a bytes object (or should
- # QgsApplication accept a QString instead of const char* ?)
- try:
- argvb = list(map(os.fsencode, sys.argv))
- except AttributeError:
- argvb = sys.argv
-
- # Note: QGIS_PREFIX_PATH is evaluated in QgsApplication -
- # no need to mess with it here.
- QGISAPP = QgsApplication(argvb, myGuiFlag)
-
- QGISAPP.initQgis()
- s = QGISAPP.showSettings()
- LOGGER.debug(s)
-
- def debug_log_message(message, tag, level):
- """
- Prints a debug message to a log
- :param message: message to print
- :param tag: log tag
- :param level: log message level (severity)
- :return:
- """
- print("{}({}): {}".format(tag, level, message))
-
- QgsApplication.instance().messageLog().messageReceived.connect(
- debug_log_message
- )
-
- if cleanup:
-
- @atexit.register
- def exitQgis(): # pylint: disable=unused-variable
- """
- Gracefully closes the QgsApplication instance
- """
- try:
- QGISAPP.exitQgis() # pylint: disable=used-before-assignment
- QGISAPP = None # pylint: disable=redefined-outer-name
- except NameError:
- pass
-
- if PARENT is None:
- # noinspection PyPep8Naming
- PARENT = QWidget()
-
- if CANVAS is None:
- # noinspection PyPep8Naming
- CANVAS = QgsMapCanvas(PARENT)
- CANVAS.resize(QSize(400, 400))
-
- if IFACE is None:
- # QgisInterface is a stub implementation of the QGIS plugin interface
- # noinspection PyPep8Naming
- IFACE = QgisInterface(CANVAS)
-
- return QGISAPP, CANVAS, IFACE, PARENT
diff --git a/geest/ui/create_project_panel_base.ui b/geest/ui/create_project_panel_base.ui
index bcf64b1e..d43cc902 100644
--- a/geest/ui/create_project_panel_base.ui
+++ b/geest/ui/create_project_panel_base.ui
@@ -7,7 +7,7 @@
00604
- 938
+ 1009
@@ -344,14 +344,14 @@
- QgsMapLayerComboBox
+ QgsFieldComboBoxQComboBox
- qgis.gui
+ qgsfieldcombobox.h
- QgsFieldComboBox
+ QgsMapLayerComboBoxQComboBox
- qgis.gui
+ qgsmaplayercombobox.h
diff --git a/geest/ui/credits_panel_base.ui b/geest/ui/credits_panel_base.ui
index a44ed7fe..54be50a4 100644
--- a/geest/ui/credits_panel_base.ui
+++ b/geest/ui/credits_panel_base.ui
@@ -154,6 +154,9 @@
+
+ background-color: rgba(0, 0, 0, 0);
+ QFrame::NoFrame
@@ -166,9 +169,12 @@
00542
- 629
+ 643
+
+ background-color: rgba(0, 0, 0, 0);
+ 0
@@ -190,6 +196,9 @@
0
+
+ background-color: rgba(0, 0, 0, 0);
+ This plugin is built with support from the **Canada Clean Energy and Forest Climate Facility (CCEFCFy)**, the **Geospatial Operational Support Team (GOST, DECSC)** for the project Geospatial Assessment of Women Employment and Business Opportunities in the Renewable Energy Sector.
diff --git a/geest/ui/intro_panel_base.ui b/geest/ui/intro_panel_base.ui
index 4549db4b..ef8b20f5 100644
--- a/geest/ui/intro_panel_base.ui
+++ b/geest/ui/intro_panel_base.ui
@@ -78,7 +78,7 @@
- <html><head/><body><p align="center"><span style=" font-size:16pt; font-weight:600;">Welcome to GEEST</span></p></body></html>
+ <html><head/><body><p align="center"><span style=" font-size:16pt; font-weight:600;">Welcome to GEEST22</span></p></body></html>Qt::RichText
@@ -93,6 +93,9 @@
+
+ background-color: rgba(0, 0, 0, 0);
+ QFrame::NoFrame
@@ -105,7 +108,7 @@
00556
- 655
+ 664
@@ -123,6 +126,9 @@
+
+ background-color: rgba(0, 0, 0, 0);
+ The Gender Enabling Environments Spatial Tool (GEEST), developed by the **World Bank**, evaluates locations based on how supportive they are of women’s employment and business opportunities.
diff --git a/geest/ui/ors_panel_base.ui b/geest/ui/ors_panel_base.ui
index 9a5fa360..42a1380a 100644
--- a/geest/ui/ors_panel_base.ui
+++ b/geest/ui/ors_panel_base.ui
@@ -117,6 +117,9 @@
+
+ background-color: rgba(0, 0, 0, 0);
+ QFrame::NoFrame
@@ -129,7 +132,7 @@
00612
- 655
+ 664
@@ -142,7 +145,9 @@
- This plugin makes use of the Open Route Service (ORS) platform for elements of the spatial analysis workflows. In order to use ORS, you need to obtain an API key. There is no charge to get your key. Click on [this link](https://openrouteservice.org/dev/#/signup) for the API Key sign up page. Once you have your API key, paste it into the box below.
+ This plugin makes use of the Open Route Service (ORS) platform for elements of the spatial analysis workflows. In order to use ORS, you need to obtain an API key. There is no charge to get your key. Click on [this link](https://openrouteservice.org/dev/#/signup) for the API Key sign up page. Once you have your API key, paste it into the box below.
+
+Please note that you can write to ORS for a collaborator key if you experience issues with ORS timeouts during the analysis (there is no charge for this).Qt::MarkdownText
diff --git a/remove_pycache.sh b/remove_pycache.sh
new file mode 100755
index 00000000..815a51ac
--- /dev/null
+++ b/remove_pycache.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# This script recursively removes all __pycache__ directories in the 'geest' folder.
+
+TARGET_DIR="geest"
+
+# Check if the target directory exists
+if [ ! -d "$TARGET_DIR" ]; then
+ echo "Error: Directory '$TARGET_DIR' does not exist."
+ exit 1
+fi
+
+echo "Searching for __pycache__ directories in '$TARGET_DIR'..."
+
+# Find and remove all __pycache__ directories
+find "$TARGET_DIR" -type d -name "__pycache__" -exec rm -rf {} +
+
+echo "All __pycache__ directories have been removed from '$TARGET_DIR'."
+
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 574ca914..6a71af6e 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -14,7 +14,7 @@ httpcore == 0.13.7
httpx == 0.20.0
idna==3.8
iniconfig==2.0.0
-Jinja2==3.1.4
+Jinja2==3.1.5
jsonschema==4.21.1
jsonschema-specifications==2023.12.1
Markdown==3.7
@@ -51,5 +51,5 @@ six==1.16.0
toml == 0.10.2
typer == 0.4.0
urllib3==2.2.2
-virtualenv==20.25.3
+virtualenv==20.26.6
watchdog==5.0.2
diff --git a/shell.nix b/shell.nix
index 6443e98e..1f8a46fa 100644
--- a/shell.nix
+++ b/shell.nix
@@ -21,6 +21,9 @@ in pkgs.mkShell rec {
python3Packages.pandas
python3Packages.odfpy
python3Packages.psutil
+ python3Packages.httpx
+ python3Packages.toml
+ python3Packages.typer
# This executes some shell code to initialize a venv in $venvDir before
# dropping into the shell
diff --git a/test/test_grid_chunker.py b/test/test_grid_chunker.py
new file mode 100644
index 00000000..6fa98b33
--- /dev/null
+++ b/test/test_grid_chunker.py
@@ -0,0 +1,136 @@
+import unittest
+from geest.core.tasks.grid_chunker import GridChunker
+from osgeo import ogr, osr
+import os
+
+
+class TestGridChunker(unittest.TestCase):
+
+ def setUp(self):
+ self.grid_chunker = GridChunker(0, 100, 0, 100, 10, 5)
+
+ def tearDown(self):
+ self.grid_chunker = None
+ return super().tearDown()
+
+ def test_chunks(self):
+ chunks = list(self.grid_chunker.chunks())
+ self.assertEqual(len(chunks), 4)
+ expected_chunks = [
+ {"index": 0, "x_start": 0, "x_end": 50, "y_start": 0, "y_end": 50},
+ {"index": 1, "x_start": 0, "x_end": 50, "y_start": 50, "y_end": 100},
+ {"index": 2, "x_start": 50, "x_end": 100, "y_start": 0, "y_end": 50},
+ {"index": 3, "x_start": 50, "x_end": 100, "y_start": 50, "y_end": 100},
+ ]
+ for chunk, expected_chunk in zip(chunks, expected_chunks):
+ self.assertEqual(chunk, expected_chunk)
+
+ def test_total_cells_in_chunk(self):
+ self.assertEqual(self.grid_chunker.total_cells_in_chunk(), 25)
+
+ def test_write_chunks_to_gpkg(self):
+ gpkg_path = "test_chunks.gpkg"
+ self.grid_chunker.write_chunks_to_gpkg(gpkg_path)
+ self.assertTrue(os.path.exists(gpkg_path))
+
+ driver = ogr.GetDriverByName("GPKG")
+ data_source = driver.Open(gpkg_path, 0)
+ self.assertIsNotNone(data_source)
+
+ layer = data_source.GetLayer()
+ self.assertEqual(layer.GetFeatureCount(), 4)
+
+ for feature in layer:
+ self.assertIn(feature.GetField("index"), [0, 1, 2, 3])
+
+ data_source = None
+ os.remove(gpkg_path)
+
+ def test_set_geometry_valid(self):
+ # Create a simple square polygon in WKB format
+ ring = ogr.Geometry(ogr.wkbLinearRing)
+ ring.AddPoint(0, 0)
+ ring.AddPoint(100, 0)
+ ring.AddPoint(100, 100)
+ ring.AddPoint(0, 100)
+ ring.AddPoint(0, 0)
+ polygon = ogr.Geometry(ogr.wkbPolygon)
+ polygon.AddGeometry(ring)
+ wkb_geometry = polygon.ExportToWkb()
+
+ self.grid_chunker.set_geometry(wkb_geometry)
+ self.assertIsNotNone(self.grid_chunker.geometry)
+ self.assertTrue(self.grid_chunker.geometry.Intersects(polygon))
+
+ def test_set_geometry_invalid_multipolygon(self):
+ # Create a multipolygon in WKB format
+ ring1 = ogr.Geometry(ogr.wkbLinearRing)
+ ring1.AddPoint(0, 0)
+ ring1.AddPoint(50, 0)
+ ring1.AddPoint(50, 50)
+ ring1.AddPoint(0, 50)
+ ring1.AddPoint(0, 0)
+ polygon1 = ogr.Geometry(ogr.wkbPolygon)
+ polygon1.AddGeometry(ring1)
+
+ ring2 = ogr.Geometry(ogr.wkbLinearRing)
+ ring2.AddPoint(60, 60)
+ ring2.AddPoint(100, 60)
+ ring2.AddPoint(100, 100)
+ ring2.AddPoint(60, 100)
+ ring2.AddPoint(60, 60)
+ polygon2 = ogr.Geometry(ogr.wkbPolygon)
+ polygon2.AddGeometry(ring2)
+
+ multipolygon = ogr.Geometry(ogr.wkbMultiPolygon)
+ multipolygon.AddGeometry(polygon1)
+ multipolygon.AddGeometry(polygon2)
+ wkb_geometry = multipolygon.ExportToWkb()
+
+ with self.assertRaises(ValueError):
+ self.grid_chunker.set_geometry(wkb_geometry)
+
+ def test_set_geometry_invalid_non_polygon(self):
+ # Create a point in WKB format
+ point = ogr.Geometry(ogr.wkbPoint)
+ point.AddPoint(0, 0)
+ wkb_geometry = point.ExportToWkb()
+
+ with self.assertRaises(ValueError):
+ self.grid_chunker.set_geometry(wkb_geometry)
+
+ def test_write_chunks_to_gpkg_with_geometry(self):
+ # Create a simple square polygon in WKB format
+ ring = ogr.Geometry(ogr.wkbLinearRing)
+ ring.AddPoint(0, 0)
+ ring.AddPoint(100, 0)
+ ring.AddPoint(100, 100)
+ ring.AddPoint(0, 100)
+ ring.AddPoint(0, 0)
+ polygon = ogr.Geometry(ogr.wkbPolygon)
+ polygon.AddGeometry(ring)
+ wkb_geometry = polygon.ExportToWkb()
+
+ self.grid_chunker.set_geometry(wkb_geometry)
+
+ gpkg_path = "test_chunks_with_geometry.gpkg"
+ self.grid_chunker.write_chunks_to_gpkg(gpkg_path)
+ self.assertTrue(os.path.exists(gpkg_path))
+
+ driver = ogr.GetDriverByName("GPKG")
+ data_source = driver.Open(gpkg_path, 0)
+ self.assertIsNotNone(data_source)
+
+ layer = data_source.GetLayer()
+ self.assertEqual(layer.GetFeatureCount(), 4)
+
+ for feature in layer:
+ self.assertIn(feature.GetField("index"), [0, 1, 2, 3])
+ self.assertIn(feature.GetField("type"), ["inside", "edge"])
+
+ data_source = None
+ os.remove(gpkg_path)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_study_area_report.py b/test/test_study_area_report.py
new file mode 100644
index 00000000..12abbef4
--- /dev/null
+++ b/test/test_study_area_report.py
@@ -0,0 +1,80 @@
+import unittest
+from qgis.core import QgsVectorLayer, QgsField, QgsFeature
+from qgis.PyQt.QtCore import QVariant
+from geest.core.reports.study_area_report import StudyAreaReport
+
+# ================================
+# Test Suite for StudyAreaReport
+# ================================
+
+
+class TestStudyAreaReport(unittest.TestCase):
+ """
+ Test suite for the StudyAreaReport class.
+
+ This suite creates a memory layer with a 'geom_total_duration_secs' field and sample data,
+ then tests the computation of statistics, creation of layout, and PDF export functionality.
+ """
+
+ @classmethod
+ def setUpClass(cls):
+ """
+ Set up a memory layer with sample data for testing.
+ """
+ # Create a memory vector layer
+ cls.layer = QgsVectorLayer("Point?crs=EPSG:4326", "test_layer", "memory")
+ provider = cls.layer.dataProvider()
+ provider.addAttributes([QgsField("geom_total_duration_secs", QVariant.Double)])
+ cls.layer.updateFields()
+
+ # Add sample features with known processing times
+ features = []
+ sample_values = [0.5, 1.0, 2.0, 3.0, 4.0]
+ for val in sample_values:
+ feat = QgsFeature(cls.layer.fields())
+ feat.setAttribute("geom_total_duration_secs", val)
+ # Geometry is not used in the statistics so we leave it empty (None)
+ features.append(feat)
+ provider.addFeatures(features)
+ cls.layer.updateExtents()
+
+ # Instantiate the report using the memory layer
+ cls.report = StudyAreaReport(cls.layer, report_name="Test Report")
+
+ def test_compute_statistics(self):
+ """
+ Test that the compute_statistics method returns correct summary values.
+ """
+ stats = self.report.compute_statistics()
+ expected_sum = sum([0.5, 1.0, 2.0, 3.0, 4.0])
+ expected_mean = expected_sum / 5
+ self.assertEqual(stats["count"], 5)
+ self.assertAlmostEqual(stats["min"], 0.5)
+ self.assertAlmostEqual(stats["max"], 4.0)
+ self.assertAlmostEqual(stats["mean"], expected_mean)
+ self.assertAlmostEqual(stats["sum"], expected_sum)
+
+ def test_create_layout(self):
+ """
+ Test that the layout is created and contains at least two items (the title and summary).
+ """
+ self.report.create_layout()
+ self.assertIsNotNone(self.report.layout)
+ items = self.report.layout.items()
+ self.assertGreaterEqual(len(items), 2)
+
+ def test_export_pdf(self):
+ """
+ Test that the PDF export function creates a file successfully.
+ """
+ output_pdf = "test_report.pdf"
+ success = self.report.export_pdf(output_pdf)
+ self.assertTrue(success)
+ self.assertTrue(os.path.exists(output_pdf))
+ # Clean up the test PDF file.
+ os.remove(output_pdf)
+
+
+if __name__ == "__main__":
+ # Run the test suite without exiting the interpreter.
+ unittest.main(exit=False)