Source code for blocksnet.method.provision

"""
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: print(gdf["demand_left"].sum(), gdf["capacity_left"].sum()) 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