Skip to content

fix: geometry valid in catchment area, constant as input capacity in 2sfca#3666

Merged
majkshkurti merged 7 commits intoplan4better:mainfrom
CyrineKamoun:2.4.0
Mar 11, 2026
Merged

fix: geometry valid in catchment area, constant as input capacity in 2sfca#3666
majkshkurti merged 7 commits intoplan4better:mainfrom
CyrineKamoun:2.4.0

Conversation

@CyrineKamoun
Copy link
Collaborator

No description provided.

Copilot AI review requested due to automatic review settings March 11, 2026 11:32
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates GOAT’s shared Python library (goatlib) to improve 2SFCA heatmap configuration (capacity sourcing + docs link/i18n) and to ensure catchment-area outputs are exported with valid polygon geometries.

Changes:

  • Update the Heatmap 2SFCA tool docs link and add i18n strings for new capacity options.
  • Extend the 2SFCA opportunity schema + SQL preparation to support capacity from constant / field / polygon expression.
  • Make exported catchment-area geometries valid (and extract polygons) when writing GeoParquet.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
packages/python/goatlib/src/goatlib/tools/registry.py Updates Heatmap 2SFCA docs_path to the full doc slug.
packages/python/goatlib/src/goatlib/i18n/translations/en.json Adds new capacity-related translation keys; tweaks demand field description.
packages/python/goatlib/src/goatlib/i18n/translations/de.json Adds new capacity-related translation keys; tweaks demand field description (DE).
packages/python/goatlib/src/goatlib/analysis/schemas/heatmap.py Adds capacity_type + related fields to Opportunity2SFCA and a validator.
packages/python/goatlib/src/goatlib/analysis/accessibility/two_step_catchment_area.py Implements capacity SQL selection logic and uses it when standardizing opportunities.
packages/python/goatlib/src/goatlib/analysis/accessibility/catchment_area.py Repairs invalid geometries on export and extracts polygon components.

"capacity_constant must be set when capacity_type is 'constant'."
)
elif self.capacity_type == PotentialType.expression:
if not self.potential_expression:
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.

validate_capacity_fields checks self.potential_expression in the capacity_type == expression branch, but Opportunity2SFCA defines capacity_expression (and does not define potential_expression). This will raise an AttributeError during validation and also fails to enforce the intended requirement. Update the validator to check self.capacity_expression instead.

Suggested change
if not self.potential_expression:
if not self.capacity_expression:

Copilot uses AI. Check for mistakes.
Comment on lines +444 to +485
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"},
),
)

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.
Comment on lines +259 to +281
# --- 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

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.
"demand_field": {
"label": "Demand Field",
"description": "Field with demand values"
"description": "Field with population or user number"
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.

English phrasing: "Field with population or user number" is awkward/unclear. Consider rewording to something like "Field with population or number of users" (or keep it generic if demand can be something else).

Suggested change
"description": "Field with population or user number"
"description": "Field with population or number of users"

Copilot uses AI. Check for mistakes.
Comment on lines 1388 to 1390
query = f"""
SELECT {select_cols}ST_GeomFromText(geometry) AS geometry
SELECT {select_cols}ST_CollectionExtract(ST_MakeValid(ST_GeomFromText(geometry)), 3) AS geometry
FROM gdf_table
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.

This applies ST_MakeValid to every geometry on export, which can be expensive even when geometries are already valid. Consider wrapping it in a CASE WHEN ST_IsValid(...) THEN ... ELSE ST_MakeValid(...) END (and then ST_CollectionExtract) to avoid unnecessary work.

Copilot uses AI. Check for mistakes.
Comment on lines 1435 to 1436
SELECT {select_cols}ST_CollectionExtract(ST_MakeValid(ST_GeomFromText(geometry)), 3) AS geometry
FROM '{temp_path}'
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.

Same as above: ST_MakeValid is run unconditionally during bytes->parquet conversion. Guard with ST_IsValid (or equivalent) so valid geometries don’t pay the full repair cost.

Suggested change
SELECT {select_cols}ST_CollectionExtract(ST_MakeValid(ST_GeomFromText(geometry)), 3) AS geometry
FROM '{temp_path}'
WITH src AS (
SELECT {select_cols}ST_GeomFromText(geometry) AS geom
FROM '{temp_path}'
)
SELECT {select_cols}ST_CollectionExtract(
CASE
WHEN NOT ST_IsValid(geom) THEN ST_MakeValid(geom)
ELSE geom
END,
3
) AS geometry
FROM src

Copilot uses AI. Check for mistakes.
CyrineKamoun and others added 3 commits March 11, 2026 11:59
…_step_catchment_area.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@majkshkurti majkshkurti merged commit e74825f into plan4better:main Mar 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants