Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1385,9 +1385,16 @@ def _save_geodataframe(self: Self, gdf: GeoDataFrame, output_path: str) -> Path:
select_cols += ", "

# Export with geometry converted to proper GEOMETRY type
query = f"""
SELECT {select_cols}ST_GeomFromText(geometry) AS geometry
FROM gdf_table
query = f"""(
WITH src AS (
SELECT {select_cols}ST_GeomFromText(geometry) AS geom
FROM gdftable
)
SELECT {select_cols}CASE
WHEN NOT ST_IsValid(geom) THEN ST_CollectionExtract(ST_MakeValid(geom), 3)
ELSE geom
END AS geometry
FROM src)
"""
write_optimized_parquet(
con,
Expand Down Expand Up @@ -1432,8 +1439,15 @@ def _save_bytes(self: Self, data: bytes, output_path: str) -> Path:
select_cols += ", "

query = f"""
SELECT {select_cols}ST_GeomFromText(geometry) AS geometry
FROM '{temp_path}'
(WITH src AS (
SELECT {select_cols}ST_GeomFromText(geometry) AS geom
FROM '{temp_path}'
)
SELECT {select_cols}CASE
WHEN NOT ST_IsValid(geom) THEN ST_CollectionExtract(ST_MakeValid(geom), 3)
ELSE geom
END AS geometry
FROM src)
"""
write_optimized_parquet(
con,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
Opportunity2SFCA,
ImpedanceFunction,
TwoSFCAType,
PotentialType,
)
from goatlib.io.utils import Metadata
from goatlib.models.io import DatasetMetadata
Expand Down Expand Up @@ -160,13 +161,15 @@ def _prepare_opportunity_table(
)
except Exception:
pass

capacity_sql = self._get_capacity_sql(opp, transform_to_4326, geom_type)

if "point" in geom_type:
query = f"""
CREATE OR REPLACE TEMP TABLE {output_table} AS
WITH features AS (
SELECT
{opp.capacity_field}::DOUBLE AS capacity,
{capacity_sql}::DOUBLE AS capacity,
{transform_to_4326} AS geom
FROM {table_name}
WHERE {geom_col} IS NOT NULL
Expand All @@ -191,7 +194,7 @@ def _prepare_opportunity_table(
CREATE OR REPLACE TEMP TABLE {output_table} AS
WITH features AS (
SELECT
{opp.capacity_field}::DOUBLE AS capacity,
{capacity_sql}::DOUBLE AS capacity,
{transform_to_4326} AS geom
FROM {table_name}
WHERE {geom_col} IS NOT NULL
Expand Down Expand Up @@ -237,6 +240,57 @@ def _prepare_opportunity_table(
self.con.execute(query)
return output_table

def _get_capacity_sql(
self: Self, opp: Opportunity2SFCA, wgs84_geom_sql: str, geom_type: str
) -> str:
"""
Determines the SQL expression for capacity.
Priority:
1. capacity_expression
2. capacity_constant
3. capacity_field
4. defaults to 1.0

Special rule:
- 'area' and 'perimeter' expressions are only valid for Polygon/MultiPolygon geometries.
"""
geom_type_lower = (geom_type or "").lower()

# --- Handle capacity_expression first ---
if opp.capacity_expression:
expr = opp.capacity_expression.lower().strip()

if expr in ("$area", "area"):
if "polygon" not in geom_type_lower:
raise ValueError(
f"Invalid capacity_expression='{expr}' for geometry type '{geom_type}'. "
"Area is only valid for Polygon or MultiPolygon geometries."
)
return f"ST_Area_Spheroid({wgs84_geom_sql})"

if expr in ("$perimeter", "perimeter"):
if "polygon" not in geom_type_lower:
raise ValueError(
f"Invalid capacity_expression='{expr}' for geometry type '{geom_type}'. "
"Perimeter is only valid for Polygon or MultiPolygon geometries."
)
return f"ST_Perimeter_Spheroid({wgs84_geom_sql})"

# Custom user expression (use as-is)
return expr

Comment on lines +259 to +281
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_get_capacity_sql lowercases and returns capacity_expression as a raw SQL snippet, but capacity_expression is a PotentialExpression enum (area/perimeter). The “custom user expression” branch is effectively unreachable, and the current precedence also allows capacity_expression to override capacity_type if both are set. Consider switching to logic driven strictly by capacity_type and mapping enum values directly, which will be clearer and prevent surprising overrides.

Copilot uses AI. Check for mistakes.
# --- Constant capacity ---
if opp.capacity_type == PotentialType.constant:
return str(float(opp.capacity_constant))

# --- Field-based capacity ---
if opp.capacity_field:
return f'"{opp.capacity_field}"'

# --- Default constant ---
return "1.0"


def _combine_opportunities(
self: Self, standardized_tables: list[tuple[str, str]]
) -> str:
Expand Down Expand Up @@ -502,7 +556,7 @@ def _impedance_sql(
"""
else:
raise ValueError(f"Unknown impedance function: {which}")

def _compute_cumulative_accessibility(
self: Self,
filtered_matrix: str,
Expand Down
88 changes: 74 additions & 14 deletions packages/python/goatlib/src/goatlib/analysis/schemas/heatmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -426,25 +426,12 @@ class HeatmapConnectivityParams(HeatmapCommon):

class Opportunity2SFCA(OpportunityBase):
"""Opportunity dataset parameters for 2SFCA heatmaps."""

capacity_field: str = Field(
...,
description="Field from the opportunity layer that contains the capacity value (e.g., number of beds, seats).",
json_schema_extra=ui_field(
section="opportunities",
field_order=5,
label_key="capacity_field",
widget="field-selector",
widget_options={"source_layer": "input_path", "field_types": ["number"]},
visible_when={"input_path": {"$ne": None}},
),
)
sensitivity: SensitivityValue = Field(
default=300000,
description="Sensitivity parameter for enhanced 2SFCA methods.",
json_schema_extra=ui_field(
section="opportunities",
field_order=6,
field_order=3,
visible_when={
"$and": [
{"input_path": {"$ne": None}},
Expand All @@ -454,6 +441,79 @@ class Opportunity2SFCA(OpportunityBase):
),
)

capacity_type: PotentialType = Field(
default=PotentialType.constant,
description="How to determine the capacity value for each opportunity.",
json_schema_extra=ui_field(
section="opportunities",
field_order=4,
visible_when={"input_path": {"$ne": None}},
widget_options={
# Only show "expression" option when input_path is a polygon layer
"enum_geometry_filter": {
"source_layer": "input_path",
"expression": ["Polygon", "MultiPolygon"],
}
},
),
)

capacity_constant: float | None = Field(
1.0,
gt=0.0,
description="Constant capacity value applied to all features.",
json_schema_extra=ui_field(
section="opportunities",
field_order=5,
widget="number",
visible_when={"input_path": {"$ne": None}, "capacity_type": "constant"},
),
)

capacity_field: str | None = Field(
None,
description="Field from the opportunity layer that contains the capacity value (e.g., number of beds, seats).",
json_schema_extra=ui_field(
section="opportunities",
field_order=6,
label_key="capacity_field",
widget="field-selector",
widget_options={"source_layer": "input_path", "field_types": ["number"]},
visible_when={"input_path": {"$ne": None}, "capacity_type": "field"},
),
)

Comment on lines +444 to +485
Copy link

Copilot AI Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

capacity_type defaults to constant, but existing callers that only provide capacity_field will now silently get a constant capacity (default 1.0) instead of using the field. This is a breaking behavioral change for 2SFCA. Consider defaulting capacity_type to field (to preserve current behavior) or adding a pre-validation step that infers capacity_type='field' when capacity_field is provided and capacity_type wasn’t explicitly set.

Copilot uses AI. Check for mistakes.
capacity_expression: PotentialExpression | None = Field(
None,
description="Expression to compute capacity for polygon layers (e.g. area or perimeter).",
json_schema_extra=ui_field(
section="opportunities",
field_order=7,
visible_when={"input_path": {"$ne": None}, "capacity_type": "expression"},
),
)

@model_validator(mode="after")
def validate_capacity_fields(self: Self) -> Self:
"""Validate that the correct capacity field is set based on capacity_type."""
if self.capacity_type == PotentialType.field:
if not self.capacity_field:
raise ValueError(
"capacity_field must be set when capacity_type is 'field'."
)
elif self.capacity_type == PotentialType.constant:
if self.capacity_constant is None:
raise ValueError(
"capacity_constant must be set when capacity_type is 'constant'."
)
elif self.capacity_type == PotentialType.expression:
if not self.capacity_expression:
raise ValueError(
"capacity_expression must be set when capacity_type is 'expression'."
)
return self


class TwoSFCAType(StrEnum):
"""Type of 2SFCA method."""
twosfca = "twosfca"
Expand Down
10 changes: 9 additions & 1 deletion packages/python/goatlib/src/goatlib/i18n/translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,18 @@
"label": "Konstantes Potenzial",
"description": "Verwenden Sie einen konstanten Wert für alle Ziele"
},
"capacity_constant": {
"label": "Konstante Kapazität",
"description": "Verwenden Sie einen konstanten Wert für alle Zielkapazitäten"
},
"potential_expression": {
"label": "Potenzialausdruck",
"description": "Ausdruck zur Potenzialberechnung (z.B. 'area', 'perimeter')"
},
"capacity_expression": {
"label": "Kapazitätsausdruck",
"description": "Ausdruck zur Kapazitätsberechnung (z.B. 'Fläche', 'Umfang')"
},
"n_destinations": {
"label": "Anzahl der Ziele",
"description": "Anzahl der nächsten Ziele zum Mitteln"
Expand All @@ -203,7 +211,7 @@
},
"demand_field": {
"label": "Nachfragefeld",
"description": "Feld mit den Nachfragewerten"
"description": "Feld mit Bevölkerungs- oder Benutzeranzahl"
},
"demand_path": {
"label": "Nachfrage-Layer",
Expand Down
10 changes: 9 additions & 1 deletion packages/python/goatlib/src/goatlib/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,18 @@
"label": "Constant Potential",
"description": "Use a constant value for all destinations"
},
"capacity_constant": {
"label": "Constant Capacity",
"description": "Use a constant value for all destination capacities"
},
"potential_expression": {
"label": "Potential Expression",
"description": "Expression to calculate potential (e.g., 'area', 'perimeter')"
},
"capacity_expression": {
"label": "Capacity Expression",
"description": "Expression to calculate capacity (e.g., 'area', 'perimeter')"
},
"n_destinations": {
"label": "Number of Destinations",
"description": "Number of closest destinations to average"
Expand All @@ -203,7 +211,7 @@
},
"demand_field": {
"label": "Demand Field",
"description": "Field with demand values"
"description": "Field with population or number of users"
},
"demand_path": {
"label": "Demand Layer",
Expand Down
2 changes: 1 addition & 1 deletion packages/python/goatlib/src/goatlib/tools/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ def get_output_geometry_type(self: Self) -> str | None:
"capacity",
"travel time",
),
docs_path="/toolbox/accessibility_indicators/2sfca",
docs_path="/toolbox/accessibility_indicators/two_step_floating_catchment_area",
),
ToolDefinition(
name="huff_model",
Expand Down
5 changes: 4 additions & 1 deletion packages/python/goatlib/tests/unit/analysis/test_2fsca.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def test_2sfca_schema_validation():
# Valid standard 2SFCA parameters
opportunity = Opportunity2SFCA(
input_path="opportunities.gpkg",
capacity_type="field",
capacity_field="beds",
max_cost=20,
)
Expand All @@ -34,6 +35,7 @@ def test_2sfca_schema_validation():
# Valid Enhanced 2SFCA parameters
enhanced_opportunity = Opportunity2SFCA(
input_path="hospitals.gpkg",
capacity_type="field",
capacity_field="beds",
max_cost=30,
sensitivity=500000, # Only visible when e2sfca/m2sfca
Expand Down Expand Up @@ -91,7 +93,8 @@ def test_2sfca_opportunity_standardization():
# Test opportunity processing components
opportunity = Opportunity2SFCA(
input_path="test_opportunities",
capacity_field="capacity",
capacity_type="field",
capacity_field="capacity",
max_cost=20,
)

Expand Down
Loading