Skip to content

Commit e74825f

Browse files
authored
Merge pull request #3666 from CyrineKamoun/2.4.0
fix: geometry valid in catchment area, constant as input capacity in 2sfca
2 parents 734fcfc + 5cbd844 commit e74825f

File tree

7 files changed

+173
-26
lines changed

7 files changed

+173
-26
lines changed

packages/python/goatlib/src/goatlib/analysis/accessibility/catchment_area.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,9 +1385,16 @@ def _save_geodataframe(self: Self, gdf: GeoDataFrame, output_path: str) -> Path:
13851385
select_cols += ", "
13861386

13871387
# Export with geometry converted to proper GEOMETRY type
1388-
query = f"""
1389-
SELECT {select_cols}ST_GeomFromText(geometry) AS geometry
1390-
FROM gdf_table
1388+
query = f"""(
1389+
WITH src AS (
1390+
SELECT {select_cols}ST_GeomFromText(geometry) AS geom
1391+
FROM gdftable
1392+
)
1393+
SELECT {select_cols}CASE
1394+
WHEN NOT ST_IsValid(geom) THEN ST_CollectionExtract(ST_MakeValid(geom), 3)
1395+
ELSE geom
1396+
END AS geometry
1397+
FROM src)
13911398
"""
13921399
write_optimized_parquet(
13931400
con,
@@ -1432,8 +1439,15 @@ def _save_bytes(self: Self, data: bytes, output_path: str) -> Path:
14321439
select_cols += ", "
14331440

14341441
query = f"""
1435-
SELECT {select_cols}ST_GeomFromText(geometry) AS geometry
1436-
FROM '{temp_path}'
1442+
(WITH src AS (
1443+
SELECT {select_cols}ST_GeomFromText(geometry) AS geom
1444+
FROM '{temp_path}'
1445+
)
1446+
SELECT {select_cols}CASE
1447+
WHEN NOT ST_IsValid(geom) THEN ST_CollectionExtract(ST_MakeValid(geom), 3)
1448+
ELSE geom
1449+
END AS geometry
1450+
FROM src)
14371451
"""
14381452
write_optimized_parquet(
14391453
con,

packages/python/goatlib/src/goatlib/analysis/accessibility/two_step_catchment_area.py

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
Opportunity2SFCA,
1010
ImpedanceFunction,
1111
TwoSFCAType,
12+
PotentialType,
1213
)
1314
from goatlib.io.utils import Metadata
1415
from goatlib.models.io import DatasetMetadata
@@ -160,13 +161,15 @@ def _prepare_opportunity_table(
160161
)
161162
except Exception:
162163
pass
164+
165+
capacity_sql = self._get_capacity_sql(opp, transform_to_4326, geom_type)
163166

164167
if "point" in geom_type:
165168
query = f"""
166169
CREATE OR REPLACE TEMP TABLE {output_table} AS
167170
WITH features AS (
168171
SELECT
169-
{opp.capacity_field}::DOUBLE AS capacity,
172+
{capacity_sql}::DOUBLE AS capacity,
170173
{transform_to_4326} AS geom
171174
FROM {table_name}
172175
WHERE {geom_col} IS NOT NULL
@@ -191,7 +194,7 @@ def _prepare_opportunity_table(
191194
CREATE OR REPLACE TEMP TABLE {output_table} AS
192195
WITH features AS (
193196
SELECT
194-
{opp.capacity_field}::DOUBLE AS capacity,
197+
{capacity_sql}::DOUBLE AS capacity,
195198
{transform_to_4326} AS geom
196199
FROM {table_name}
197200
WHERE {geom_col} IS NOT NULL
@@ -237,6 +240,57 @@ def _prepare_opportunity_table(
237240
self.con.execute(query)
238241
return output_table
239242

243+
def _get_capacity_sql(
244+
self: Self, opp: Opportunity2SFCA, wgs84_geom_sql: str, geom_type: str
245+
) -> str:
246+
"""
247+
Determines the SQL expression for capacity.
248+
Priority:
249+
1. capacity_expression
250+
2. capacity_constant
251+
3. capacity_field
252+
4. defaults to 1.0
253+
254+
Special rule:
255+
- 'area' and 'perimeter' expressions are only valid for Polygon/MultiPolygon geometries.
256+
"""
257+
geom_type_lower = (geom_type or "").lower()
258+
259+
# --- Handle capacity_expression first ---
260+
if opp.capacity_expression:
261+
expr = opp.capacity_expression.lower().strip()
262+
263+
if expr in ("$area", "area"):
264+
if "polygon" not in geom_type_lower:
265+
raise ValueError(
266+
f"Invalid capacity_expression='{expr}' for geometry type '{geom_type}'. "
267+
"Area is only valid for Polygon or MultiPolygon geometries."
268+
)
269+
return f"ST_Area_Spheroid({wgs84_geom_sql})"
270+
271+
if expr in ("$perimeter", "perimeter"):
272+
if "polygon" not in geom_type_lower:
273+
raise ValueError(
274+
f"Invalid capacity_expression='{expr}' for geometry type '{geom_type}'. "
275+
"Perimeter is only valid for Polygon or MultiPolygon geometries."
276+
)
277+
return f"ST_Perimeter_Spheroid({wgs84_geom_sql})"
278+
279+
# Custom user expression (use as-is)
280+
return expr
281+
282+
# --- Constant capacity ---
283+
if opp.capacity_type == PotentialType.constant:
284+
return str(float(opp.capacity_constant))
285+
286+
# --- Field-based capacity ---
287+
if opp.capacity_field:
288+
return f'"{opp.capacity_field}"'
289+
290+
# --- Default constant ---
291+
return "1.0"
292+
293+
240294
def _combine_opportunities(
241295
self: Self, standardized_tables: list[tuple[str, str]]
242296
) -> str:
@@ -502,7 +556,7 @@ def _impedance_sql(
502556
"""
503557
else:
504558
raise ValueError(f"Unknown impedance function: {which}")
505-
559+
506560
def _compute_cumulative_accessibility(
507561
self: Self,
508562
filtered_matrix: str,

packages/python/goatlib/src/goatlib/analysis/schemas/heatmap.py

Lines changed: 74 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -426,25 +426,12 @@ class HeatmapConnectivityParams(HeatmapCommon):
426426

427427
class Opportunity2SFCA(OpportunityBase):
428428
"""Opportunity dataset parameters for 2SFCA heatmaps."""
429-
430-
capacity_field: str = Field(
431-
...,
432-
description="Field from the opportunity layer that contains the capacity value (e.g., number of beds, seats).",
433-
json_schema_extra=ui_field(
434-
section="opportunities",
435-
field_order=5,
436-
label_key="capacity_field",
437-
widget="field-selector",
438-
widget_options={"source_layer": "input_path", "field_types": ["number"]},
439-
visible_when={"input_path": {"$ne": None}},
440-
),
441-
)
442429
sensitivity: SensitivityValue = Field(
443430
default=300000,
444431
description="Sensitivity parameter for enhanced 2SFCA methods.",
445432
json_schema_extra=ui_field(
446433
section="opportunities",
447-
field_order=6,
434+
field_order=3,
448435
visible_when={
449436
"$and": [
450437
{"input_path": {"$ne": None}},
@@ -454,6 +441,79 @@ class Opportunity2SFCA(OpportunityBase):
454441
),
455442
)
456443

444+
capacity_type: PotentialType = Field(
445+
default=PotentialType.constant,
446+
description="How to determine the capacity value for each opportunity.",
447+
json_schema_extra=ui_field(
448+
section="opportunities",
449+
field_order=4,
450+
visible_when={"input_path": {"$ne": None}},
451+
widget_options={
452+
# Only show "expression" option when input_path is a polygon layer
453+
"enum_geometry_filter": {
454+
"source_layer": "input_path",
455+
"expression": ["Polygon", "MultiPolygon"],
456+
}
457+
},
458+
),
459+
)
460+
461+
capacity_constant: float | None = Field(
462+
1.0,
463+
gt=0.0,
464+
description="Constant capacity value applied to all features.",
465+
json_schema_extra=ui_field(
466+
section="opportunities",
467+
field_order=5,
468+
widget="number",
469+
visible_when={"input_path": {"$ne": None}, "capacity_type": "constant"},
470+
),
471+
)
472+
473+
capacity_field: str | None = Field(
474+
None,
475+
description="Field from the opportunity layer that contains the capacity value (e.g., number of beds, seats).",
476+
json_schema_extra=ui_field(
477+
section="opportunities",
478+
field_order=6,
479+
label_key="capacity_field",
480+
widget="field-selector",
481+
widget_options={"source_layer": "input_path", "field_types": ["number"]},
482+
visible_when={"input_path": {"$ne": None}, "capacity_type": "field"},
483+
),
484+
)
485+
486+
capacity_expression: PotentialExpression | None = Field(
487+
None,
488+
description="Expression to compute capacity for polygon layers (e.g. area or perimeter).",
489+
json_schema_extra=ui_field(
490+
section="opportunities",
491+
field_order=7,
492+
visible_when={"input_path": {"$ne": None}, "capacity_type": "expression"},
493+
),
494+
)
495+
496+
@model_validator(mode="after")
497+
def validate_capacity_fields(self: Self) -> Self:
498+
"""Validate that the correct capacity field is set based on capacity_type."""
499+
if self.capacity_type == PotentialType.field:
500+
if not self.capacity_field:
501+
raise ValueError(
502+
"capacity_field must be set when capacity_type is 'field'."
503+
)
504+
elif self.capacity_type == PotentialType.constant:
505+
if self.capacity_constant is None:
506+
raise ValueError(
507+
"capacity_constant must be set when capacity_type is 'constant'."
508+
)
509+
elif self.capacity_type == PotentialType.expression:
510+
if not self.capacity_expression:
511+
raise ValueError(
512+
"capacity_expression must be set when capacity_type is 'expression'."
513+
)
514+
return self
515+
516+
457517
class TwoSFCAType(StrEnum):
458518
"""Type of 2SFCA method."""
459519
twosfca = "twosfca"

packages/python/goatlib/src/goatlib/i18n/translations/de.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,18 @@
189189
"label": "Konstantes Potenzial",
190190
"description": "Verwenden Sie einen konstanten Wert für alle Ziele"
191191
},
192+
"capacity_constant": {
193+
"label": "Konstante Kapazität",
194+
"description": "Verwenden Sie einen konstanten Wert für alle Zielkapazitäten"
195+
},
192196
"potential_expression": {
193197
"label": "Potenzialausdruck",
194198
"description": "Ausdruck zur Potenzialberechnung (z.B. 'area', 'perimeter')"
195199
},
200+
"capacity_expression": {
201+
"label": "Kapazitätsausdruck",
202+
"description": "Ausdruck zur Kapazitätsberechnung (z.B. 'Fläche', 'Umfang')"
203+
},
196204
"n_destinations": {
197205
"label": "Anzahl der Ziele",
198206
"description": "Anzahl der nächsten Ziele zum Mitteln"
@@ -203,7 +211,7 @@
203211
},
204212
"demand_field": {
205213
"label": "Nachfragefeld",
206-
"description": "Feld mit den Nachfragewerten"
214+
"description": "Feld mit Bevölkerungs- oder Benutzeranzahl"
207215
},
208216
"demand_path": {
209217
"label": "Nachfrage-Layer",

packages/python/goatlib/src/goatlib/i18n/translations/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,18 @@
189189
"label": "Constant Potential",
190190
"description": "Use a constant value for all destinations"
191191
},
192+
"capacity_constant": {
193+
"label": "Constant Capacity",
194+
"description": "Use a constant value for all destination capacities"
195+
},
192196
"potential_expression": {
193197
"label": "Potential Expression",
194198
"description": "Expression to calculate potential (e.g., 'area', 'perimeter')"
195199
},
200+
"capacity_expression": {
201+
"label": "Capacity Expression",
202+
"description": "Expression to calculate capacity (e.g., 'area', 'perimeter')"
203+
},
196204
"n_destinations": {
197205
"label": "Number of Destinations",
198206
"description": "Number of closest destinations to average"
@@ -203,7 +211,7 @@
203211
},
204212
"demand_field": {
205213
"label": "Demand Field",
206-
"description": "Field with demand values"
214+
"description": "Field with population or number of users"
207215
},
208216
"demand_path": {
209217
"label": "Demand Layer",

packages/python/goatlib/src/goatlib/tools/registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,7 @@ def get_output_geometry_type(self: Self) -> str | None:
381381
"capacity",
382382
"travel time",
383383
),
384-
docs_path="/toolbox/accessibility_indicators/2sfca",
384+
docs_path="/toolbox/accessibility_indicators/two_step_floating_catchment_area",
385385
),
386386
ToolDefinition(
387387
name="huff_model",

packages/python/goatlib/tests/unit/analysis/test_2fsca.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def test_2sfca_schema_validation():
1414
# Valid standard 2SFCA parameters
1515
opportunity = Opportunity2SFCA(
1616
input_path="opportunities.gpkg",
17+
capacity_type="field",
1718
capacity_field="beds",
1819
max_cost=20,
1920
)
@@ -34,6 +35,7 @@ def test_2sfca_schema_validation():
3435
# Valid Enhanced 2SFCA parameters
3536
enhanced_opportunity = Opportunity2SFCA(
3637
input_path="hospitals.gpkg",
38+
capacity_type="field",
3739
capacity_field="beds",
3840
max_cost=30,
3941
sensitivity=500000, # Only visible when e2sfca/m2sfca
@@ -91,7 +93,8 @@ def test_2sfca_opportunity_standardization():
9193
# Test opportunity processing components
9294
opportunity = Opportunity2SFCA(
9395
input_path="test_opportunities",
94-
capacity_field="capacity",
96+
capacity_type="field",
97+
capacity_field="capacity",
9598
max_cost=20,
9699
)
97100

0 commit comments

Comments
 (0)