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
@@ -0,0 +1,35 @@
"""location geography index

Adds a gist index on (geometry::geography) so the radius search in fetch_locations() can use ST_DWithin instead of
ST_DistanceSphere(...) < radius. The latter is a plain function comparison that the planner cannot index, so it
seq-scanned the whole location table on every ?lat&lon&radius request.

PostGIS only, the index is skipped on other dialects.

Revision ID: a3f1c8d92b47
Revises: 76e048ecbc17
Create Date: 2026-07-26 00:00:00.000000

"""

from alembic import op

# revision identifiers, used by Alembic.
revision = 'a3f1c8d92b47'
down_revision = '76e048ecbc17'
branch_labels = None
depends_on = None


def upgrade():
if op.get_bind().dialect.name != 'postgresql':
return

op.execute('CREATE INDEX geography_index ON location USING gist ((geometry::geography))')


def downgrade():
if op.get_bind().dialect.name != 'postgresql':
return

op.execute('DROP INDEX geography_index')
80 changes: 80 additions & 0 deletions tests/unit/models/capability_bits_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""
Open ChargePoint DataBase OCPDB
Copyright (C) 2026 binary butterfly GmbH

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""

from webapp.models.charging_station import CAPABILITY_BIT_BY_MEMBER, CAPABILITY_BITS, Capability

# The bit value each capability is stored as in charging_station.capabilities. Written out literally so that any change
# to the persisted encoding has to be made here too, where it is obvious that existing database rows are affected.
EXPECTED_BITS: dict[str, int] = {
'CHARGING_PROFILE_CAPABLE': 1,
'CHARGING_PREFERENCES_CAPABLE': 2,
'CHIP_CARD_SUPPORT': 4,
'CONTACTLESS_CARD_SUPPORT': 8,
'CREDIT_CARD_PAYABLE': 16,
'DEBIT_CARD_PAYABLE': 32,
'PED_TERMINAL': 64,
'REMOTE_START_STOP_CAPABLE': 128,
'RESERVABLE': 256,
'RFID_READER': 512,
'TOKEN_GROUP_CAPABLE': 1024,
'UNLOCK_CAPABLE': 2048,
'PUBLIC': 4096,
'LOCAL_KEY': 8192,
'CASH': 16384,
'IEC15118': 32768,
'DIRECT_REMOTE': 65536,
}


class CapabilityBitsTest:
"""
Guards the bitmask encoding of ChargingStation.capabilities. The bit values are persisted, so changing one
reinterprets every existing row without any migration or error.
"""

@staticmethod
def test_bit_values_are_unchanged():
"""Every capability must keep the bit value it was stored with."""
assert {item.name: bit for bit, item in CAPABILITY_BITS} == EXPECTED_BITS

@staticmethod
def test_every_capability_has_a_bit():
"""A capability missing from the table would silently never be stored or returned."""
assert {item for _bit, item in CAPABILITY_BITS} == set(Capability)

@staticmethod
def test_bits_are_unique_and_single():
"""Each entry must be a distinct power of two, otherwise capabilities alias each other."""
bits = [bit for bit, _item in CAPABILITY_BITS]

assert len(set(bits)) == len(bits)
for bit in bits:
assert bit > 0
assert bit & (bit - 1) == 0

@staticmethod
def test_bit_by_member_matches_the_table():
"""The lookup used by the setter must agree with the table used by the getter."""
assert CAPABILITY_BIT_BY_MEMBER == {item: bit for bit, item in CAPABILITY_BITS}

@staticmethod
def test_table_is_ordered_by_bit_value():
"""The getter returns capabilities in table order, which callers expect to be ascending by bit."""
bits = [bit for bit, _item in CAPABILITY_BITS]

assert bits == sorted(bits)
82 changes: 82 additions & 0 deletions tests/unit/models/parking_restriction_bits_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
Open ChargePoint DataBase OCPDB
Copyright (C) 2026 binary butterfly GmbH

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.

You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""

from webapp.models.evse import (
PARKING_RESTRICTION_BIT_BY_MEMBER,
PARKING_RESTRICTION_BITS,
ParkingRestriction,
)

# The bit value each parking restriction is stored as in evse.parking_restrictions. Written out literally so that any
# change to the persisted encoding has to be made here too, where it is obvious that existing rows are affected.
EXPECTED_BITS: dict[str, int] = {
'EV_ONLY': 1,
'PLUGGED': 2,
'DISABLED': 4,
'CUSTOMERS': 8,
'MOTORCYCLES': 16,
'CARSHARING': 32,
'BICYCLE_ONLY': 64,
}


class ParkingRestrictionBitsTest:
"""
Guards the bitmask encoding of Evse.parking_restrictions. The bit values are persisted, so changing one
reinterprets every existing row without any migration or error.
"""

@staticmethod
def test_bit_values_are_unchanged():
"""Every parking restriction must keep the bit value it was stored with."""
assert {item.name: bit for bit, item in PARKING_RESTRICTION_BITS} == EXPECTED_BITS

@staticmethod
def test_every_parking_restriction_has_a_bit():
"""A restriction missing from the table would silently never be stored or returned."""
assert {item for _bit, item in PARKING_RESTRICTION_BITS} == set(ParkingRestriction)

@staticmethod
def test_bits_are_unique_and_single():
"""Each entry must be a distinct power of two, otherwise restrictions alias each other."""
bits = [bit for bit, _item in PARKING_RESTRICTION_BITS]

assert len(set(bits)) == len(bits)
for bit in bits:
assert bit > 0
assert bit & (bit - 1) == 0

@staticmethod
def test_bit_by_member_matches_the_table():
"""The lookup used by the setter must agree with the table used by the getter."""
assert PARKING_RESTRICTION_BIT_BY_MEMBER == {item: bit for bit, item in PARKING_RESTRICTION_BITS}

@staticmethod
def test_table_is_ordered_by_bit_value():
"""The getter returns restrictions in table order, which callers expect to be ascending by bit."""
bits = [bit for bit, _item in PARKING_RESTRICTION_BITS]

assert bits == sorted(bits)

@staticmethod
def test_bicycle_only_bit_used_by_the_tile_summary_query():
"""
fetch_locations_summary_by_bounds() counts bike chargepoints with a raw SQL bitmask test built from this
constant. It used to be a hardcoded 64.
"""
assert PARKING_RESTRICTION_BIT_BY_MEMBER[ParkingRestriction.BICYCLE_ONLY] == 64
34 changes: 29 additions & 5 deletions webapp/models/charging_station.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,33 @@ class Capability(Enum):
DIRECT_REMOTE = 'DIRECT_REMOTE'


# Bit value per capability, as stored in charging_station.capabilities.
#
# These values are persisted in the database, so they MUST NOT change: reordering, inserting or renumbering an entry
# silently reinterprets every stored bitmask. Add new capabilities at the end with the next free bit, and never reuse
# the bit of a removed one. capability_bits_test.py guards this.
CAPABILITY_BITS: list[tuple[int, Capability]] = [
(1 << 0, Capability.CHARGING_PROFILE_CAPABLE),
(1 << 1, Capability.CHARGING_PREFERENCES_CAPABLE),
(1 << 2, Capability.CHIP_CARD_SUPPORT),
(1 << 3, Capability.CONTACTLESS_CARD_SUPPORT),
(1 << 4, Capability.CREDIT_CARD_PAYABLE),
(1 << 5, Capability.DEBIT_CARD_PAYABLE),
(1 << 6, Capability.PED_TERMINAL),
(1 << 7, Capability.REMOTE_START_STOP_CAPABLE),
(1 << 8, Capability.RESERVABLE),
(1 << 9, Capability.RFID_READER),
(1 << 10, Capability.TOKEN_GROUP_CAPABLE),
(1 << 11, Capability.UNLOCK_CAPABLE),
(1 << 12, Capability.PUBLIC),
(1 << 13, Capability.LOCAL_KEY),
(1 << 14, Capability.CASH),
(1 << 15, Capability.IEC15118),
(1 << 16, Capability.DIRECT_REMOTE),
]
CAPABILITY_BIT_BY_MEMBER: dict[Capability, int] = {item: bit for bit, item in CAPABILITY_BITS}


class ChargingStation(BaseModel):
__tablename__ = 'charging_station'
parking_spaces_list_validator: list[ParkingSpace] = ListValidator(DataclassValidator(ParkingSpace))
Expand Down Expand Up @@ -130,18 +157,15 @@ def user_interface_languages(self, languages: list[str] | None) -> None:
def capabilities(self) -> list[Capability]:
if self._capabilities is None:
return []
return sorted(
[item for item in list(Capability) if (1 << list(Capability).index(item)) & self._capabilities],
key=lambda item: 1 << list(Capability).index(item),
)
return [item for bit, item in CAPABILITY_BITS if bit & self._capabilities]

@capabilities.setter
def capabilities(self, capabilities: list[Capability] | None) -> None:
self._capabilities = 0
if capabilities is None:
return
for capability in capabilities:
self._capabilities = self._capabilities | (1 << list(Capability).index(capability))
self._capabilities = self._capabilities | CAPABILITY_BIT_BY_MEMBER[capability]

@hybrid_property
def directions(self) -> list[dict[str, str]] | None:
Expand Down
30 changes: 20 additions & 10 deletions webapp/models/evse.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,23 @@ class ParkingRestriction(Enum):
BICYCLE_ONLY = 'BICYCLE_ONLY'


# Bit value per parking restriction, as stored in evse.parking_restrictions.
#
# These values are persisted in the database, so they MUST NOT change: reordering, inserting or renumbering an entry
# silently reinterprets every stored bitmask. Add new restrictions at the end with the next free bit, and never reuse
# the bit of a removed one. parking_restriction_bits_test.py guards this.
PARKING_RESTRICTION_BITS: list[tuple[int, ParkingRestriction]] = [
(1 << 0, ParkingRestriction.EV_ONLY),
(1 << 1, ParkingRestriction.PLUGGED),
(1 << 2, ParkingRestriction.DISABLED),
(1 << 3, ParkingRestriction.CUSTOMERS),
(1 << 4, ParkingRestriction.MOTORCYCLES),
(1 << 5, ParkingRestriction.CARSHARING),
(1 << 6, ParkingRestriction.BICYCLE_ONLY),
]
PARKING_RESTRICTION_BIT_BY_MEMBER: dict[ParkingRestriction, int] = {item: bit for bit, item in PARKING_RESTRICTION_BITS}


class Evse(BaseModel):
__tablename__ = 'evse'

Expand Down Expand Up @@ -126,23 +143,16 @@ class Evse(BaseModel):
def parking_restrictions(self) -> list[ParkingRestriction]:
if self._parking_restrictions is None:
return []
return sorted(
[
item
for item in list(ParkingRestriction)
if (1 << list(ParkingRestriction).index(item)) & self._parking_restrictions
],
key=lambda item: 1 << list(ParkingRestriction).index(item),
)
return [item for bit, item in PARKING_RESTRICTION_BITS if bit & self._parking_restrictions]

@parking_restrictions.setter
def parking_restrictions(self, parking_restrictions: list[ParkingRestriction] | None) -> None:
self._parking_restrictions = 0
if parking_restrictions is None:
return
for parking_restriction in parking_restrictions:
self._parking_restrictions = self._parking_restrictions | (
1 << list(ParkingRestriction).index(parking_restriction)
self._parking_restrictions = (
self._parking_restrictions | PARKING_RESTRICTION_BIT_BY_MEMBER[parking_restriction]
)

@hybrid_property
Expand Down
4 changes: 4 additions & 0 deletions webapp/models/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
Text,
event,
func,
text,
)
from sqlalchemy import (
Enum as SqlalchemyEnum,
Expand Down Expand Up @@ -125,6 +126,9 @@ class Location(BaseModel):
__table_args__ = (
Index('uid_source', 'uid', 'source'),
Index('geometry_index', 'geometry', postgresql_using='gist'),
# Used by the ST_DWithin radius search in LocationRepository. Declared here so that autogenerated migrations
# do not drop it again; it is created by migration a3f1c8d92b47.
Index('geography_index', text('(geometry::geography)'), postgresql_using='gist'),
Index('ix_country_official_region_code', 'country', 'official_region_code'),
)

Expand Down
12 changes: 8 additions & 4 deletions webapp/public_api/location_api/location_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,19 @@ def __init__(self, *args, location_repository: LocationRepository, **kwargs):
def get_locations(self, search_query: LocationApiSearchQuery, strict: bool = False) -> PaginatedResult[dict]:
locations = self.location_repository.fetch_locations(
search_query=search_query,
include_operators=False,
# Eager-load everything _map_location_to_ocpi() renders, otherwise each operator, connector and EVSE image
# is lazy-loaded per row, causing hundreds of N+1 queries per page.
include_operators=True,
include_logos=False,
include_location_images=False,
include_charging_stations=False,
include_charging_station_images=False,
include_evses=False,
include_evse_images=False,
include_connectors=False,
include_tariffs=True,
include_evse_images=True,
include_connectors=True,
# No tariffs: _map_location_to_ocpi() never renders them, and eager-loading them cost a third of the
# fetch time.
include_tariffs=False,
)
return locations.map(lambda location: self._map_location_to_ocpi(location, strict=strict))

Expand Down
8 changes: 6 additions & 2 deletions webapp/repositories/business_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""

from sqlalchemy.orm import joinedload
from validataclass_search_queries.pagination import PaginatedResult
from validataclass_search_queries.search_queries import BaseSearchQuery

Expand All @@ -28,10 +29,13 @@ class BusinessRepository(BaseRepository[Business]):
model_cls = Business

def fetch_by_id(self, business_id: int) -> Business:
return self.fetch_resource_by_id(business_id)
# _map_business_to_ocpi() reads business.logo, so eager-load it to avoid a lazy round-trip.
return self.fetch_resource_by_id(business_id, load_options=[joinedload(Business.logo)])

def fetch_businesses(self, search_query: BaseSearchQuery | None = None) -> PaginatedResult[Business]:
query = self.session.query(Business)
# _map_business_to_ocpi() reads business.logo per row; without this eager load every business in the page
# triggers an N+1 logo lookup.
query = self.session.query(Business).options(joinedload(Business.logo))
return self._search_and_paginate(query, search_query)

def fetch_business_by_name(self, name: str) -> Business:
Expand Down
Loading