"""
Service type provisions assessment module.
"""
from loguru import logger
import geopandas as gpd
import pandas as pd
from pulp import PULP_CBC_CMD, LpMaximize, LpProblem, LpVariable, lpSum, LpInteger
from ..models import ServiceType
from .base_method import BaseMethod
from enum import Enum
PROVISION_COLUMN = "provision"
PLOT_KWARGS = {"column": PROVISION_COLUMN, "cmap": "RdYlGn", "vmin": 0, "vmax": 1, "legend": True}
[docs]class ProvisionMethod(Enum):
"""
Enum for different methods of service provision assessment.
Attributes
----------
GREEDY : str
Greedy method for service provision.
GRAVITATIONAL : str
Linear method for service provision, where distances are taken into account as squares.
LINEAR : str
Linear method for service provision, where distances are taken into account linearly.
"""
GREEDY = "greedy"
GRAVITATIONAL = "gravitational"
LINEAR = "linear"
[docs]class Provision(BaseMethod):
"""
Class for assessing provision of services to urban areas.
This class provides various methods to evaluate the provision of services
such as healthcare, education, etc., for a given city model. The provision
can be assessed using multiple methods like gravitational models, greedy
allocation, and linear programming-based transportation problem solutions.
Methods
-------
plot(gdf: gpd.GeoDataFrame, linewidth: float = 0.1, figsize: tuple[int, int] = (10, 10)) -> None
Visualizes provision assessment results for a given GeoDataFrame.
stat(gdf: gpd.GeoDataFrame) -> dict[str, float]
Computes basic statistics of provision (mean, median, min, max) from a GeoDataFrame.
total(gdf: gpd.GeoDataFrame) -> float
Calculates the total provision ratio from the given GeoDataFrame.
get_bounds(service_type: ServiceType | str, update_df: pd.DataFrame = None) -> tuple[float, float]
Returns the lower and upper bounds of provision for a given service type.
calculate(service_type: ServiceType | str, update_df: pd.DataFrame | None = None, method: ProvisionMethod = ProvisionMethod.GRAVITATIONAL, self_supply: bool = False) -> gpd.GeoDataFrame
Performs provision assessment for a specified service type and method.
"""
[docs] def plot(self, gdf: gpd.GeoDataFrame, linewidth: float = 0.1, figsize: tuple[int, int] = (10, 10)):
"""
Visualizes provision assessment results for a given GeoDataFrame.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing spatial data with provision assessment results.
linewidth : float
Size of polygons' borders, by default 0.1.
figsize : tuple of int, optional
Size of the plot in inches, by default (10, 10).
Returns
-------
None
"""
ax = gdf.plot(color="#ddd", figsize=figsize)
gdf.plot(ax=ax, linewidth=linewidth, **PLOT_KWARGS)
ax.set_title("Provision: " + f"{self.total(gdf): .3f}")
ax.set_axis_off()
[docs] def _get_blocks_gdf(self, service_type: ServiceType, update_df: pd.DataFrame | None = None) -> gpd.GeoDataFrame:
"""
Generates a GeoDataFrame of city blocks with updated service capacities and demands.
Parameters
----------
service_type : ServiceType
The service type for which provision assessment is being calculated.
update_df : pandas.DataFrame, optional
DataFrame containing updates to population or capacity, by default None.
Returns
-------
geopandas.GeoDataFrame
GeoDataFrame containing the geometry, demand, and capacity for each block.
"""
capacity_column = f"capacity_{service_type.name}"
gdf = self.city_model.get_blocks_gdf()[["geometry", "population", capacity_column]].fillna(0)
gdf = gdf.rename(columns={capacity_column: "capacity"})
if update_df is not None:
if "population" in update_df.columns:
gdf["population"] = gdf["population"].add(update_df["population"].fillna(0), fill_value=0)
if service_type.name in update_df.columns:
gdf["capacity"] = gdf["capacity"].add(update_df[service_type.name].fillna(0), fill_value=0)
gdf["population"] = gdf["population"].apply(service_type.calculate_in_need)
gdf = gdf.rename(columns={"population": "demand"})
gdf["capacity_left"] = gdf["capacity"]
gdf["demand_left"] = gdf["demand"]
gdf["demand_within"] = 0
gdf["demand_without"] = 0
return gdf
[docs] @classmethod
def stat(cls, gdf: gpd.GeoDataFrame) -> dict[str, float]:
"""
Computes basic statistics of provision (mean, median, min, max) from a GeoDataFrame.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing provision data.
Returns
-------
dict
Dictionary with keys 'mean', 'median', 'min', and 'max' representing provision statistics.
"""
return {
"mean": gdf["provision"].mean(),
"median": gdf["provision"].median(),
"min": gdf["provision"].min(),
"max": gdf["provision"].max(),
}
[docs] @classmethod
def total(cls, gdf: gpd.GeoDataFrame) -> float:
"""
Calculates the total provision by dividing the sum of met demand
by the total demand for all blocks in the GeoDataFrame.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing the columns 'demand_within' and 'demand',
representing the met demand and total demand for each block.
Returns
-------
float
The ratio of total met demand to total demand, representing overall provision.
"""
return gdf["demand_within"].sum() / gdf["demand"].sum()
[docs] @staticmethod
def _get_lower_bound(gdf: gpd.GeoDataFrame) -> float:
"""
Calculates the lower bound of provision, assuming each block
meets its demand up to its capacity.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing 'capacity' and 'demand' columns.
Returns
-------
float
The lower bound of provision as the ratio of the total met demand
(limited by capacity) to the total demand.
"""
gdf = gdf.copy()
gdf["demand_within"] = gdf.apply(lambda x: min(x["capacity"], x["demand"]), axis=1)
return gdf["demand_within"].sum() / gdf["demand"].sum()
[docs] @staticmethod
def _get_upper_bound(gdf: gpd.GeoDataFrame) -> float:
"""
Calculates the upper bound of provision, assuming total capacity can be
fully allocated to meet total demand.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing 'capacity' and 'demand' columns.
Returns
-------
float
The upper bound of provision, calculated as the minimum of the total
capacity-to-demand ratio or 1 (full provision).
"""
gdf = gdf.copy()
capacity = gdf["capacity"].sum()
demand = gdf["demand"].sum()
return min(capacity / demand, 1)
[docs] def get_bounds(self, service_type: ServiceType | str, update_df: pd.DataFrame = None) -> tuple[float, float]:
"""
Returns the lower and upper bounds of provision for a given service type.
Parameters
----------
service_type : ServiceType or str
The service type for which provision bounds are calculated.
update_df : pandas.DataFrame, optional
DataFrame containing updates to population or capacity, by default None.
Returns
-------
tuple of float
A tuple (lower_bound, upper_bound) representing the lower and upper
bounds of provision.
"""
service_type: ServiceType = self.city_model[service_type]
gdf = self._get_blocks_gdf(service_type, update_df)
lower_bound = self._get_lower_bound(gdf)
upper_bound = self._get_upper_bound(gdf)
return lower_bound, upper_bound
[docs] def calculate(
self,
service_type: ServiceType | str,
update_df: pd.DataFrame | None = None,
method: ProvisionMethod = ProvisionMethod.GRAVITATIONAL,
self_supply: bool = False,
) -> gpd.GeoDataFrame:
"""
Performs provision assessment for a specified service type and method.
Parameters
----------
service_type : ServiceType or str
The type of service for which the provision is calculated.
update_df : pandas.DataFrame, optional
Updated DataFrame for blocks (demand or capacity changes), by default None.
method : ProvisionMethod, optional
The provision method to be used (GRAVITATIONAL by default).
self_supply : bool, optional
If True, blocks are allowed to meet their own demand directly using their
own capacity, by default False.
Returns
-------
geopandas.GeoDataFrame
GeoDataFrame with provision data, including 'demand_within', 'demand_left',
'capacity_left', and 'provision'.
"""
service_type: ServiceType = self.city_model[service_type]
gdf = self._get_blocks_gdf(service_type, update_df)
if self_supply:
supply: pd.Series = gdf.apply(lambda x: min(x["demand"], x["capacity"]), axis=1)
gdf["demand_within"] += supply
gdf["demand_left"] -= supply
gdf["capacity_left"] -= supply
if method == ProvisionMethod.GREEDY:
gdf = self._greedy_provision(gdf, service_type)
else:
gdf = self._lp_provision(gdf, service_type, method)
gdf["provision"] = gdf["demand_within"] / gdf["demand"]
if self.verbose:
logger.success("Provision assessment finished")
return gdf
[docs] def _lp_provision(
self,
gdf: gpd.GeoDataFrame,
service_type: ServiceType,
method: ProvisionMethod,
selection_range: float | None = None,
) -> gpd.GeoDataFrame:
"""
Solves the provision problem using a Linear Programming (LP) solver.
Loops itself till capacity or demand left meet 0.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing blocks with demand and capacity.
service_type : ServiceType
The type of service for which provision is being calculated.
method : ProvisionMethod
The method used to calculate provision (LINEAR or GRAVITATIONAL).
selection_range : float | None
The accessibility range defining maximum distance between living blocks and service blocks
in the current problem loop, default None (actually service type accessibility).
Returns
-------
geopandas.GeoDataFrame
Updated GeoDataFrame with provision results, including updates to
'demand_within', 'demand_without', 'demand_left', and 'capacity_left'.
"""
if selection_range is None:
selection_range = service_type.accessibility
def _get_distance(id1: int, id2: int):
distance = self.city_model.accessibility_matrix.loc[id1, id2]
return distance if distance > 1 else 1
def _get_weight(id1: int, id2: int):
distance = _get_distance(id1, id2)
if method == ProvisionMethod.LINEAR:
return 1 / distance
return 1 / (distance * distance)
demand = gdf.loc[gdf["demand_left"] > 0]
capacity = gdf.loc[gdf["capacity_left"] > 0]
if self.verbose:
logger.info(f"Setting an LP problem for accessibility = {selection_range} : {len(demand)}x{len(capacity)}")
prob = LpProblem("Provision", LpMaximize)
# Precompute distance and filter products
products = [
(i, j)
for i in demand.index
for j in capacity.index
if _get_distance(i, j) <= selection_range # service_type.accessibility * 2
]
# Create the decision variable dictionary
x = LpVariable.dicts("Route", products, 0, None, cat=LpInteger)
# Objective Function
prob += lpSum(_get_weight(n, m) * x[n, m] for n, m in products)
# Constraint dictionaries
demand_constraints = {n: [] for n in demand.index}
capacity_constraints = {m: [] for m in capacity.index}
for n, m in products:
demand_constraints[n].append(x[n, m])
capacity_constraints[m].append(x[n, m])
# Add Demand Constraints
for n in demand.index:
prob += lpSum(demand_constraints[n]) <= demand.loc[n, "demand_left"]
# Add Capacity Constraints
for m in capacity.index:
prob += lpSum(capacity_constraints[m]) <= capacity.loc[m, "capacity_left"]
if self.verbose:
logger.info("Solving the problem")
prob.solve(PULP_CBC_CMD(msg=False))
if self.verbose:
logger.info("Restoring values from variables")
for var in prob.variables():
value = var.value()
name = var.name.replace("(", "").replace(")", "").replace(",", "").split("_")
if name[2] == "dummy":
continue
a = int(name[1])
b = int(name[2])
distance = _get_distance(a, b)
if value > 0:
if distance <= service_type.accessibility:
gdf.loc[a, "demand_within"] += value
else:
gdf.loc[a, "demand_without"] += value
gdf.loc[a, "demand_left"] -= value
gdf.loc[b, "capacity_left"] -= value
if gdf["demand_left"].sum() > 0 and gdf["capacity_left"].sum() > 0:
return self._lp_provision(gdf, service_type, method, selection_range * 2)
return gdf
[docs] def _greedy_provision(self, gdf: gpd.GeoDataFrame, service_type: ServiceType) -> gpd.GeoDataFrame:
"""
Iteratively assigns demand to the closest available capacity using a greedy method.
Parameters
----------
gdf : geopandas.GeoDataFrame
GeoDataFrame containing blocks with demand and capacity.
service_type : ServiceType
The type of service for which provision is being calculated.
Returns
-------
geopandas.GeoDataFrame
Updated GeoDataFrame with provision results, including updates to
'demand_within', 'demand_without', 'demand_left', and 'capacity_left'.
"""
demand_gdf = gdf.loc[gdf["demand_left"] > 0]
capacity_gdf = gdf.loc[gdf["capacity_left"] > 0]
demand_blocks = [self.city_model[i] for i in demand_gdf.index]
capacity_blocks = [self.city_model[i] for i in capacity_gdf.index]
while gdf["demand_left"].sum() > 0 and gdf["capacity_left"].sum() > 0:
for demand_block in demand_blocks:
if len(capacity_blocks) == 0:
break
capacity_block = min(
capacity_blocks, key=lambda capacity_block: self.city_model[demand_block, capacity_block]
)
gdf.loc[demand_block.id, "demand_left"] -= 1
distance = self.city_model[demand_block, capacity_block]
if distance <= service_type.accessibility:
gdf.loc[demand_block.id, "demand_within"] += 1
else:
gdf.loc[demand_block.id, "demand_without"] += 1
# remove block if it's demand_left is empty
if gdf.loc[demand_block.id, "demand_left"] == 0:
demand_blocks.remove(demand_block)
gdf.loc[capacity_block.id, "capacity_left"] -= 1
# remove block if its capacity_left is empty
if gdf.loc[capacity_block.id, "capacity_left"] == 0:
capacity_blocks.remove(capacity_block)
return gdf