From 41427150747ed3458c1dc1f0ff15b6b68757b179 Mon Sep 17 00:00:00 2001 From: Dmitry-Grachev Date: Fri, 16 May 2025 06:19:30 +0300 Subject: [PATCH 001/161] added pipy mirror config --- Dockerfile | 5 ++++- pip.conf | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 pip.conf diff --git a/Dockerfile b/Dockerfile index b8219db..3d36c8c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,9 @@ ENV PYTHONUNBUFFERED=1 # Enables env file ENV APP_ENV=development +#add pypi mirror to config +COPY pip.conf /etc/xdg/pip/pip.conf + # Install pip requirements COPY requirements.txt . RUN python -m pip install --upgrade pip @@ -23,4 +26,4 @@ WORKDIR /app COPY . /app # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug -CMD ["gunicorn", "--bind", "0.0.0.0:80", "-k", "uvicorn.workers.UvicornWorker", "--workers", "2", "app.main:app"] \ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:80", "-k", "uvicorn.workers.UvicornWorker", "--workers", "2", "app.main:app"] diff --git a/pip.conf b/pip.conf new file mode 100644 index 0000000..774069a --- /dev/null +++ b/pip.conf @@ -0,0 +1,4 @@ +[global] +index-url=http://10.32.1.108:3141/root/pypi/+simple/ +trusted-host=10.32.1.108 +timeout=120 From 36c2cf3aeae0edd1cc0db6dafbf5a66a23830f20 Mon Sep 17 00:00:00 2001 From: Dmitry-Grachev Date: Fri, 16 May 2025 06:40:17 +0300 Subject: [PATCH 002/161] build and deploy workflow added --- .github/workflows/build_and_deploy.yml | 45 ++++++++++++++++++++++++++ docker-compose.actions.yml | 10 ++++++ 2 files changed, 55 insertions(+) create mode 100644 .github/workflows/build_and_deploy.yml create mode 100644 docker-compose.actions.yml diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml new file mode 100644 index 0000000..ac55edf --- /dev/null +++ b/.github/workflows/build_and_deploy.yml @@ -0,0 +1,45 @@ +name: build_and_deploy +on: workflow_dispatch +env: + IMAGE_NAME: ${{secrets.REGISTRY}}/effects_api + CONTAINER_NAME: effects_api + +jobs: + build: + runs-on: 47_runner + outputs: + now: ${{steps.date.outputs.NOW}} + steps: + - name: Set current date as env variable + id: date + run: echo "NOW=$(date +'%Y-%m-%dT%H-%M-%S')" >> $GITHUB_OUTPUT + - name: checkout + uses: actions/checkout@v4 + - name: copy_env + env: + ENV_PATH: ${{secrets.ENV_PATH}} + run: cp "$ENV_PATH"/.env.development ./ + - name: build + env: + NOW: ${{steps.date.outputs.now}} + run: docker build -t "$IMAGE_NAME":"$NOW" . + - name: push_to_registry + env: + NOW: ${{steps.date.outputs.now}} + run: docker push "$IMAGE_NAME":"$NOW" + stop_container: + runs-on: 47_runner + needs: build + steps: + - name: stop_container + run: docker rm -f "$CONTAINER_NAME" + run_container: + runs-on: 47_runner + needs: [build, stop_container] + env: + NOW: ${{needs.build.outputs.now}} + steps: + - name: set env + run: echo "IMAGE=$IMAGE_NAME:$NOW" >> $GITHUB_ENV + - name: run + run: docker compose -f docker-compose.actions.yml up -d diff --git a/docker-compose.actions.yml b/docker-compose.actions.yml new file mode 100644 index 0000000..d9e5dcb --- /dev/null +++ b/docker-compose.actions.yml @@ -0,0 +1,10 @@ +services: + object_effects: + image: ${IMAGE} + container_name: ${CONTAINER_NAME} + ports: + - 5100:80 + env_file: + - .env.development + restart: always + From 3308d486db791e4060b78b2390198f209f6efc1e Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 26 May 2025 13:38:16 +0300 Subject: [PATCH 003/161] fix("blocksnet_service): - fixed zero roads errors --- .gitignore | 3 +- Dockerfile | 2 +- .../effects/services/blocksnet_service.py | 28 +++++++++++-------- .../effects/services/service_type_service.py | 2 +- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 39a19ca..1374b5c 100644 --- a/.gitignore +++ b/.gitignore @@ -125,8 +125,7 @@ celerybeat.pid *.sage.py # Environments -app/.env.development -.env.development +.env.* .venv env/ venv/ diff --git a/Dockerfile b/Dockerfile index 3d36c8c..ea13ed4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 # Enables env file -ENV APP_ENV=development +ENV APP_ENV=production #add pypi mirror to config COPY pip.conf /etc/xdg/pip/pip.conf diff --git a/app/api/routers/effects/services/blocksnet_service.py b/app/api/routers/effects/services/blocksnet_service.py index e01c585..0337106 100644 --- a/app/api/routers/effects/services/blocksnet_service.py +++ b/app/api/routers/effects/services/blocksnet_service.py @@ -33,14 +33,16 @@ def _get_water(scenario_gdf, physical_object_types): def _get_roads(scenario_gdf, physical_object_types): roads = _get_geoms_by_function('Дорога', physical_object_types, scenario_gdf) merged = roads.unary_union - if merged.geom_type == 'MultiLineString': - roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) - else: - roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) - roads = roads.explode(index_parts=False).reset_index(drop=True) - roads.geometry = momepy.close_gaps(roads, GAP_TOLERANCE) - roads = roads[roads.geom_type.isin(['LineString'])] - return roads + if merged: + if merged.geom_type == 'MultiLineString': + roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) + else: + roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) + roads = roads.explode(index_parts=False).reset_index(drop=True) + roads.geometry = momepy.close_gaps(roads, GAP_TOLERANCE) + roads = roads[roads.geom_type.isin(['LineString'])] + return roads + return gpd.GeoDataFrame() def _get_geoms_by_object_type_id(scenario_gdf, object_type_id): return scenario_gdf[scenario_gdf['physical_objects'].apply(lambda x: any(d.get('physical_object_type').get('id') == object_type_id for d in x))] @@ -107,7 +109,8 @@ def extract_services(row): def _roads_to_graph(roads): - roads.to_parquet(f'roads_{len(roads)}.parquet') + if not roads.empty: + roads.to_parquet(f'roads_{len(roads)}.parquet') graph = momepy.gdf_to_nx(roads) graph.graph['crs'] = CRS.to_epsg(roads.crs) graph = nx.DiGraph(graph) @@ -261,8 +264,11 @@ def fetch_city_model( # generating blocks layer blocks_gdf = _generate_blocks(boundaries_gdf, roads_gdf, scenario_gdf, physical_object_types) - # calculating accessibility matrix - acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) + #ToDo Revise project logic without roads + if len(blocks_gdf) == 1: + acc_mx = pd.DataFrame([[0]], index=[0], columns=[0]) + else: + acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) # initializing city model city = City( diff --git a/app/api/routers/effects/services/service_type_service.py b/app/api/routers/effects/services/service_type_service.py index 1e14866..0b363ca 100644 --- a/app/api/routers/effects/services/service_type_service.py +++ b/app/api/routers/effects/services/service_type_service.py @@ -71,7 +71,7 @@ def _form_source_params(sources: list[dict]) -> dict: source_data_df[source_data_df["source"] == "User"]["year"].idxmax() ].to_dict() else: - raise HTTPException(status_code=404, detail="Source type not found") + raise HTTPException(status_code=404, detail={"msg": "Pzz zones not found. Upload pzz firstly"}) zones_sources = requests.get( url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zone_sources", From 35f2d89950e9011011f5f12356b75ba5a09f399c Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Wed, 25 Jun 2025 23:35:18 +0300 Subject: [PATCH 004/161] refactor(WIP): moved some files, changed some logic, WIP --- .gitignore | 1 + app/api/routers/effects/effects_service.py | 323 ------------------ .../effects/services/blocksnet_service.py | 282 --------------- .../effects/services/project_service.py | 103 ------ .../effects/services/service_type_service.py | 86 ----- app/{api => common}/__init__.py | 0 app/common/api_handler.py | 245 +++++++++++++ app/{api/utils => common}/auth.py | 0 app/{api/utils => common}/decorators.py | 0 .../routers => common/exceptions}/__init__.py | 0 .../exceptions/http_exception_wrapper.py | 12 + app/dependencies.py | 27 ++ .../effects => effects_api}/__init__.py | 0 .../constants}/__init__.py | 0 .../utils => effects_api/constants}/const.py | 5 +- .../effects_controller.py | 71 +--- .../utils => effects_api/models}/__init__.py | 0 .../models}/effects_models.py | 0 app/effects_api/modules/__init__.py | 0 app/effects_api/modules/blocksnet_service.py | 316 +++++++++++++++++ app/effects_api/modules/context_service.py | 98 ++++++ app/effects_api/modules/effects_service.py | 121 +++++++ .../modules/service_type_service.py | 41 +++ .../modules}/task_api_service.py | 5 +- app/effects_api/modules/urban_api_gateway.py | 226 ++++++++++++ app/effects_api/schemas/__init__.py | 0 .../schemas}/task_schema.py | 0 app/main.py | 6 +- requirements.txt | Bin 302 -> 248 bytes testing.ipynb | 37 ++ 30 files changed, 1146 insertions(+), 859 deletions(-) delete mode 100644 app/api/routers/effects/effects_service.py delete mode 100644 app/api/routers/effects/services/blocksnet_service.py delete mode 100644 app/api/routers/effects/services/project_service.py delete mode 100644 app/api/routers/effects/services/service_type_service.py rename app/{api => common}/__init__.py (100%) create mode 100644 app/common/api_handler.py rename app/{api/utils => common}/auth.py (100%) rename app/{api/utils => common}/decorators.py (100%) rename app/{api/routers => common/exceptions}/__init__.py (100%) create mode 100644 app/common/exceptions/http_exception_wrapper.py create mode 100644 app/dependencies.py rename app/{api/routers/effects => effects_api}/__init__.py (100%) rename app/{api/routers/effects/services => effects_api/constants}/__init__.py (100%) rename app/{api/utils => effects_api/constants}/const.py (94%) rename app/{api/routers/effects => effects_api}/effects_controller.py (66%) rename app/{api/utils => effects_api/models}/__init__.py (100%) rename app/{api/routers/effects => effects_api/models}/effects_models.py (100%) create mode 100644 app/effects_api/modules/__init__.py create mode 100644 app/effects_api/modules/blocksnet_service.py create mode 100644 app/effects_api/modules/context_service.py create mode 100644 app/effects_api/modules/effects_service.py create mode 100644 app/effects_api/modules/service_type_service.py rename app/{api/routers/effects/services => effects_api/modules}/task_api_service.py (90%) create mode 100644 app/effects_api/modules/urban_api_gateway.py create mode 100644 app/effects_api/schemas/__init__.py rename app/{api/routers/effects => effects_api/schemas}/task_schema.py (100%) create mode 100644 testing.ipynb diff --git a/.gitignore b/.gitignore index 39a19ca..61f3563 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ gdf_with_obj.geojson boundaries.parquet roads.parquet water.parquet +*.ipynb diff --git a/app/api/routers/effects/effects_service.py b/app/api/routers/effects/effects_service.py deleted file mode 100644 index e639d00..0000000 --- a/app/api/routers/effects/effects_service.py +++ /dev/null @@ -1,323 +0,0 @@ -import os -import math -from typing import Literal - -import geopandas as gpd -import warnings -import pandas as pd -import numpy as np -from urllib3.exceptions import InsecureRequestWarning -from loguru import logger -from blocksnet import City, WeightedConnectivity, Connectivity, Provision -from ...utils import const -from . import effects_models as em -from .services import blocksnet_service as bs, project_service as ps, service_type_service as sts - -for warning in [pd.errors.PerformanceWarning, RuntimeWarning, pd.errors.SettingWithCopyWarning, InsecureRequestWarning, FutureWarning]: - warnings.filterwarnings(action='ignore', category=warning) - -PROVISION_COLUMNS = ['provision', 'demand', 'demand_within'] - -def _get_file_path(project_scenario_id: int, effect_type: em.EffectType, scale_type: em.ScaleType): - file_path = f'{project_scenario_id}_{effect_type.name}_{scale_type.name}' - return os.path.join(const.DATA_PATH, f'{file_path}.parquet') - -def _get_total_provision(gdf_orig, name): - gdf = gdf_orig.copy() - - for column in PROVISION_COLUMNS: - new_column = column.replace(f'{name}_', '') - gdf = gdf.rename(columns={f'{name}_{column}': new_column}) - - return round(Provision.total(gdf), 2) - -def _sjoin_gdfs(gdf_before : gpd.GeoDataFrame, gdf_after : gpd.GeoDataFrame): - gdf_before = gdf_before.to_crs(gdf_after.crs) - # set i to identify intersections - gdf_before['i'] = gdf_before.index - gdf_after['i'] = gdf_after.index - gdf_sjoin = gdf_after.sjoin(gdf_before, how='left', predicate='intersects', lsuffix='after', rsuffix='before') - # filter nans - gdf_sjoin = gdf_sjoin[~gdf_sjoin['i_before'].isna()] - gdf_sjoin = gdf_sjoin[~gdf_sjoin['i_after'].isna()] - # get intersections area and keep largest - gdf_sjoin['area'] = gdf_sjoin.apply(lambda s : gdf_before.loc[s['i_before'], 'geometry'].intersection(gdf_after.loc[s['i_after'], 'geometry']).area, axis=1) - gdf_sjoin = gdf_sjoin.sort_values(by='area') - return gdf_sjoin.drop_duplicates(subset=['i_after'], keep='last') - -def get_transport_layer(project_scenario_id: int, scale_type: em.ScaleType, token: str): - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - - # get both files - before_file_path = _get_file_path(based_scenario_id, em.EffectType.TRANSPORT, scale_type) - after_file_path = _get_file_path(project_scenario_id, em.EffectType.TRANSPORT, scale_type) - - gdf_before = gpd.read_parquet(before_file_path) - gdf_after = gpd.read_parquet(after_file_path) - - # calculate delta - gdf_delta = _sjoin_gdfs(gdf_before, gdf_after) - gdf_delta = gdf_delta.rename(columns={ - 'weighted_connectivity_before': 'before', - 'weighted_connectivity_after': 'after' - })[['geometry', 'before', 'after']] - gdf_delta['delta'] = gdf_delta['after'] - gdf_delta['before'] - - # round digits - for column in ['before', 'after', 'delta']: - gdf_delta[column] = gdf_delta[column].apply(lambda v : round(v,1)) - - return gdf_delta - -def get_transport_data(project_scenario_id: int, scale_type: em.ScaleType, token: str): - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - - # get both files - before_file_path = _get_file_path(based_scenario_id, em.EffectType.TRANSPORT, scale_type) - after_file_path = _get_file_path(project_scenario_id, em.EffectType.TRANSPORT, scale_type) - - gdf_before = gpd.read_parquet(before_file_path) - gdf_after = gpd.read_parquet(after_file_path) - - # calculate chart data - names_funcs = { - 'Среднее': np.mean, - 'Медиана': np.median, - 'Мин': np.min, - 'Макс': np.max - } - - items = [] - for name, func in names_funcs.items(): - before = func(gdf_before['weighted_connectivity']) - after = func(gdf_after['weighted_connectivity']) - delta = after - before - items.append({ - 'name': name, - 'before': round(before,1), - 'after': round(after,1), - 'delta': round(delta,1) - }) - return items - -def get_connectivity_layer(project_scenario_id: int, scale_type: em.ScaleType, token: str): - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - - # get both files - before_file_path = _get_file_path(based_scenario_id, em.EffectType.CONNECTIVITY, scale_type) - after_file_path = _get_file_path(project_scenario_id, em.EffectType.CONNECTIVITY, scale_type) - - gdf_before = gpd.read_parquet(before_file_path) - gdf_after = gpd.read_parquet(after_file_path) - - # calculate delta - gdf_delta = _sjoin_gdfs(gdf_before, gdf_after) - gdf_delta = gdf_delta.rename(columns={ - 'connectivity_before': 'before', - 'connectivity_after': 'after' - })[['geometry', 'before', 'after']] - gdf_delta['delta'] = gdf_delta['after'] - gdf_delta['before'] - - # round digits - for column in ['before', 'after', 'delta']: - gdf_delta[column] = gdf_delta[column].apply(lambda v : round(v,1)) - - return gdf_delta - -def get_connectivity_data(project_scenario_id: int, scale_type: em.ScaleType, token: str): - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - - # get both files - before_file_path = _get_file_path(based_scenario_id, em.EffectType.CONNECTIVITY, scale_type) - after_file_path = _get_file_path(project_scenario_id, em.EffectType.CONNECTIVITY, scale_type) - - gdf_before = gpd.read_parquet(before_file_path) - gdf_after = gpd.read_parquet(after_file_path) - - # calculate chart data - names_funcs = { - 'Среднее': np.mean, - 'Мин': np.min, - 'Макс': np.max - } - - items = [] - for name, func in names_funcs.items(): - before = func(gdf_before['connectivity']) - after = func(gdf_after['connectivity']) - delta = after - before - items.append({ - 'name': name, - 'before': round(before,1), - 'after': round(after,1), - 'delta': round(delta,1) - }) - print(items) - return items - -def get_provision_layer(project_scenario_id: int, scale_type: em.ScaleType, service_type_id: int, token: str): - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - - service_types = sts.get_bn_service_types(project_info['region_id']) - service_type = list(filter(lambda x: x.code == str(service_type_id), service_types))[0] - - before_file_path = _get_file_path(based_scenario_id, em.EffectType.PROVISION, scale_type) - after_file_path = _get_file_path(project_scenario_id, em.EffectType.PROVISION, scale_type) - - gdf_before = gpd.read_parquet(before_file_path) - gdf_after = gpd.read_parquet(after_file_path) - - provision_column = f'{service_type.name}_provision' - - # calculate delta - gdf_delta = _sjoin_gdfs(gdf_before, gdf_after) - gdf_delta = gdf_delta.rename(columns={ - f'{provision_column}_before': 'before', - f'{provision_column}_after': 'after' - })[['geometry', 'before', 'after']] - gdf_delta['delta'] = gdf_delta['after'] - gdf_delta['before'] - - for column in ['before', 'after', 'delta']: - gdf_delta[column] = gdf_delta[column].apply(lambda v : round(v,2)) - - return gdf_delta - - -def get_provision_data( - project_scenario_id: int, - scale_type: Literal["Проект", "Контекст"], - token: str -) -> list[em.ChartData]: - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - - before_file_path = _get_file_path(based_scenario_id, em.EffectType.PROVISION, scale_type) - after_file_path = _get_file_path(project_scenario_id, em.EffectType.PROVISION, scale_type) - - gdf_before = gpd.read_parquet(before_file_path) - gdf_after = gpd.read_parquet(after_file_path) - - service_types = sts.get_bn_service_types(project_info['region_id']) - results = [] - for st in service_types: - name = st.name - - before = _get_total_provision(gdf_before, name) - after = _get_total_provision(gdf_after, name) - delta = after - before - - results.append({ - 'name': name, - 'before': round(before,2) if not math.isnan(before) else None, - 'after': round(after,2) if not math.isnan(after) else None, - 'delta': round(delta,2) if not math.isnan(delta) else 0, - }) - return results - -def _evaluate_transport(project_scenario_id: int, city_model: City, scale: em.ScaleType): - logger.info('Evaluating transport') - conn = WeightedConnectivity(city_model=city_model, verbose=False) - conn_gdf = conn.calculate() - file_path = _get_file_path(project_scenario_id, em.EffectType.TRANSPORT, scale) - conn_gdf.to_parquet(file_path) - logger.success('Transport successfully evaluated!') - -def _evaluate_connectivity(project_scenario_id: int, city_model: City, scale: em.ScaleType): - logger.info('Evaluating connectivity') - conn = Connectivity(city_model=city_model, verbose=False) - conn_gdf = conn.calculate() - conn_gdf['connectivity'] = conn_gdf['connectivity'].astype('float32') - conn_gdf['connectivity'] = conn_gdf['connectivity'].apply(lambda v : np.nan if np.isinf(v) else v) - file_path = _get_file_path(project_scenario_id, em.EffectType.CONNECTIVITY, scale) - conn_gdf.to_parquet(file_path) - logger.success('Connectivity successfully evaluated!') - -def _evaluate_provision(project_scenario_id: int, city_model: City, scale: em.ScaleType): - logger.info('Evaluating provision') - blocks_gdf = city_model.get_blocks_gdf()[['geometry']] - - for st in city_model.service_types: - prov = Provision(city_model=city_model, verbose=False) - prov_gdf = prov.calculate(st) - for column in PROVISION_COLUMNS: - blocks_gdf[f'{st.name}_{column}'] = prov_gdf[column] - - file_path = _get_file_path(project_scenario_id, em.EffectType.PROVISION, scale) - blocks_gdf.to_parquet(file_path) - logger.success('Provision successfully evaluated!') - -def _evaluation_exists(project_scenario_id : int, token : str): - exists = True - for effect_type in list(em.EffectType): - for scale_type in list(em.ScaleType): - file_path = _get_file_path(project_scenario_id, effect_type, scale_type) - if not os.path.exists(file_path): - exists = False - return exists - -def delete_evaluation(project_scenario_id : int): - for effect_type in list(em.EffectType): - for scale_type in list(em.ScaleType): - file_path = _get_file_path(project_scenario_id, effect_type, scale_type) - if os.path.exists(file_path): - os.remove(file_path) - -def evaluate_effects(project_scenario_id : int, token: str, reevaluate : bool = True): - logger.info(f'Fetching {project_scenario_id} project info') - - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - # if scenario isnt based, evaluate the based scenario - if project_scenario_id != based_scenario_id: - evaluate_effects(based_scenario_id, token, reevaluate=False) - - # if scenario exists and doesnt require reevaluation, we return - exists = _evaluation_exists(project_scenario_id, token) - if exists and not reevaluate: - logger.info(f'{project_scenario_id} evaluation already exists') - return - - logger.info('Fetching region service types') - service_types = sts.get_bn_service_types(project_info['region_id']) - logger.info('Fetching physical object types') - physical_object_types = ps.get_physical_object_types() - logger.info('Fetching scenario objects') - scenario_gdf = ps.get_scenario_objects(project_scenario_id, token, project_info['project_id']) - - logger.info('Fetching project model') - project_model = bs.fetch_city_model( - project_info=project_info, - project_scenario_id=project_scenario_id, - service_types=service_types, - physical_object_types=physical_object_types, - scenario_gdf=scenario_gdf, - scale=em.ScaleType.PROJECT - ) - - logger.info('Fetching context model') - context_model = bs.fetch_city_model( - project_info=project_info, - project_scenario_id=project_scenario_id, - service_types=service_types, - physical_object_types=physical_object_types, - scenario_gdf=scenario_gdf, - scale=em.ScaleType.CONTEXT - ) - - # project_model.to_pickle(f'{project_scenario_id}_project.pickle') - # context_model.to_pickle(f'{project_scenario_id}_context.pickle') - - _evaluate_transport(project_scenario_id, project_model, em.ScaleType.PROJECT) - _evaluate_connectivity(project_scenario_id, project_model, em.ScaleType.PROJECT) - _evaluate_provision(project_scenario_id, project_model, em.ScaleType.PROJECT) - - _evaluate_transport(project_scenario_id, context_model, em.ScaleType.CONTEXT) - _evaluate_connectivity(project_scenario_id, context_model, em.ScaleType.CONTEXT) - _evaluate_provision(project_scenario_id, context_model, em.ScaleType.CONTEXT) - - logger.success(f'{project_scenario_id} evaluated successfully') diff --git a/app/api/routers/effects/services/blocksnet_service.py b/app/api/routers/effects/services/blocksnet_service.py deleted file mode 100644 index e01c585..0000000 --- a/app/api/routers/effects/services/blocksnet_service.py +++ /dev/null @@ -1,282 +0,0 @@ -import random - -import geopandas as gpd -import pandas as pd -import momepy -import networkx as nx -from blocksnet.models.city import Building, BlockService, Block -from pyproj.crs import CRS -from blocksnet import (AccessibilityProcessor, BlocksGenerator, City, ServiceType, LandUseProcessor) -from app.api.utils import const -from .. import effects_models as em -from app.api.routers.effects.services.service_type_service import get_zones - -SPEED_M_MIN = 60 * 1000 / 60 -GAP_TOLERANCE = 5 - -def _get_geoms_by_function(function_name, physical_object_types, scenario_gdf): - valid_type_ids = { - d['physical_object_type_id'] - for d in physical_object_types - if function_name in d['physical_object_function']['name'] - } - return scenario_gdf[scenario_gdf['physical_objects'].apply( - lambda x: any(d.get('physical_object_type').get('id') in valid_type_ids for d in x))] - -def _get_water(scenario_gdf, physical_object_types): - water = _get_geoms_by_function('Водный объект', physical_object_types, scenario_gdf) - water = water.explode(index_parts=True) - water = water.reset_index() - return water - - -def _get_roads(scenario_gdf, physical_object_types): - roads = _get_geoms_by_function('Дорога', physical_object_types, scenario_gdf) - merged = roads.unary_union - if merged.geom_type == 'MultiLineString': - roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) - else: - roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) - roads = roads.explode(index_parts=False).reset_index(drop=True) - roads.geometry = momepy.close_gaps(roads, GAP_TOLERANCE) - roads = roads[roads.geom_type.isin(['LineString'])] - return roads - -def _get_geoms_by_object_type_id(scenario_gdf, object_type_id): - return scenario_gdf[scenario_gdf['physical_objects'].apply(lambda x: any(d.get('physical_object_type').get('id') == object_type_id for d in x))] - -def _get_buildings(scenario_gdf, physical_object_types): - LIVING_BUILDINGS_ID = 4 - NON_LIVING_BUILDINGS_ID = 5 - living_building = _get_geoms_by_object_type_id(scenario_gdf, LIVING_BUILDINGS_ID) - living_building['is_living'] = True - # print(living_building) - non_living_buildings = _get_geoms_by_object_type_id(scenario_gdf, NON_LIVING_BUILDINGS_ID) - non_living_buildings['is_living'] = False - - buildings = gpd.GeoDataFrame( pd.concat( [living_building, non_living_buildings], ignore_index=True) ) - # print(buildings) - # buildings = _get_geoms_by_function('Здание', physical_object_types, scenario_gdf) - buildings['number_of_floors'] = 1 - # buildings['is_living'] = True - buildings['footprint_area'] = buildings.geometry.area - buildings['build_floor_area'] = buildings['footprint_area'] * buildings['number_of_floors'] - buildings['living_area'] = buildings.geometry.area - buildings['population'] = 0 - buildings['population'][buildings['is_living']] = 100 - buildings = buildings.reset_index() - buildings = buildings[buildings.geometry.type != 'Point'] - return buildings[['geometry', 'number_of_floors', 'footprint_area', 'build_floor_area', 'living_area', 'population']] - - -def _get_services(scenario_gdf) -> gpd.GeoDataFrame | None: - - def extract_services(row): - if isinstance(row['services'], list) and len(row['services']) > 0: - return [ - { - 'service_id': service['service_id'], - 'service_type_id': service['service_type']['id'], - 'name': service['name'], - 'capacity_real': service['capacity'], - 'geometry': row['geometry'] # Сохраняем геометрию - } - for service in row['services'] - if service.get('capacity') is not None and service['capacity'] > 0 - ] - return [] - - extracted_data = [] - for _, row in scenario_gdf.iterrows(): - extracted_data.extend(extract_services(row)) - - if len(extracted_data) == 0: - return None - - services_gdf = gpd.GeoDataFrame(extracted_data, crs=scenario_gdf.crs) - - services_gdf['capacity'] = services_gdf['capacity_real'] - services_gdf = services_gdf[['geometry', 'service_id', 'service_type_id', 'name', 'capacity']] - - services_gdf['area'] = services_gdf.geometry.area - services_gdf['area'] = services_gdf['area'].apply(lambda a : a if a > 1 else 1) - # services_gdf.loc[services_gdf.area == 0, 'area'] = 100 - # services_gdf['area'] = services_gdf - - return services_gdf - - -def _roads_to_graph(roads): - roads.to_parquet(f'roads_{len(roads)}.parquet') - graph = momepy.gdf_to_nx(roads) - graph.graph['crs'] = CRS.to_epsg(roads.crs) - graph = nx.DiGraph(graph) - for _, _, data in graph.edges(data=True): - geometry = data['geometry'] - data['time_min'] = geometry.length / SPEED_M_MIN - # data['weight'] = data['mm_len'] / 1000 / 1000 - # data['length_meter'] = data['mm_len'] / 1000 - for n, data in graph.nodes(data=True): - graph.nodes[n]['x'] = n[0] # Assign X coordinate to node - graph.nodes[n]['y'] = n[1] - - return graph - -def _get_boundaries(project_info : dict, scale : em.ScaleType) -> gpd.GeoDataFrame: - if scale == em.ScaleType.PROJECT: - boundaries = gpd.GeoDataFrame(geometry=[project_info['geometry']]) - else: - boundaries = gpd.GeoDataFrame(geometry=[project_info['context']]) - boundaries = boundaries.set_crs(const.DEFAULT_CRS) - local_crs = boundaries.estimate_utm_crs() - return boundaries.to_crs(local_crs) - -def _generate_blocks(boundaries_gdf : gpd.GeoDataFrame, roads_gdf : gpd.GeoDataFrame, scenario_gdf : gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: - water_gdf = _get_water(scenario_gdf, physical_object_types).to_crs(boundaries_gdf.crs) - - blocks_generator = BlocksGenerator( - boundaries=boundaries_gdf, - roads=roads_gdf if len(roads_gdf)>0 else None, - water=water_gdf if len(water_gdf)>0 else None - ) - blocks = blocks_generator.run() - blocks['land_use'] = None # TODO ЗАмнить на норм land_use?? >> здесь должен быть этап определения лендюза по тому что есть в бд - return blocks - -def _calculate_acc_mx(blocks_gdf : gpd.GeoDataFrame, roads_gdf : gpd.GeoDataFrame) -> pd.DataFrame: - accessibility_processor = AccessibilityProcessor(blocks=blocks_gdf) - graph = _roads_to_graph(roads_gdf) - accessibility_matrix = accessibility_processor.get_accessibility_matrix(graph=graph) - return accessibility_matrix - -def _update_buildings(city : City, scenario_gdf : gpd.GeoDataFrame, physical_object_types : dict) -> None: - buildings_gdf = _get_buildings(scenario_gdf, physical_object_types).copy().to_crs(city.crs) - buildings_gdf = buildings_gdf[buildings_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] - city.update_buildings(buildings_gdf) - -def _update_services(city : City, service_types : list[ServiceType], scenario_gdf : gpd.GeoDataFrame) -> None: - # reset service types - city._service_types = {} - for st in service_types: - city.add_service_type(st) - # filter services and add to the model if exist - services_gdf = _get_services(scenario_gdf) - if services_gdf is None: - return - services_gdf = services_gdf.to_crs(city.crs).copy() - service_type_dict = {service.code: service for service in service_types} - for service_type_code, st_gdf in services_gdf.groupby('service_type_id'): - gdf = st_gdf.copy().to_crs(city.crs) - gdf.geometry = gdf.representative_point() - service_type = service_type_dict.get(str(service_type_code), None) - if service_type is not None: - city.update_services(service_type, gdf) - -# ToDo handle no service case -def _update_landuse(city: City, zones: gpd.GeoDataFrame): - - def _process_zones(zones: gpd.GeoDataFrame): - db_zones = list(const.mapping.keys()) - return zones[zones.zone.isin(db_zones)] - - def _get_blocks_to_process(blocks, zones): - lup = LandUseProcessor(blocks=blocks, zones=zones, zone_to_land_use=const.mapping) - blocks_with_lu = lup.run(0.5) - return blocks_with_lu[~blocks_with_lu['land_use'].isna()] - - def _update_non_residential_block(block: Block): - for building in block.buildings: - building.population = 0 - - def _update_residential_block(block: Block, pop_per_ha: float, service_types: list[ServiceType]): - pop_per_m2 = pop_per_ha / SQ_M_IN_HA - area = block.site_area - population = round(pop_per_m2 * area) - # удаляем здания и сервисы - block.buildings = [] - block.services = [] - # добавляем dummy здание и даем ему наше население - dummy_building = Building( - block=block, - geometry=block.geometry.buffer(-0.01), - population=population, - **const.DUMMY_BUILDING_PARAMS - ) - block.buildings.append(dummy_building) - # добавляем по каждому типу сервиса большой dummy_service - for service_type in service_types: - capacity = service_type.calculate_in_need(population) - dummy_service = BlockService( - service_type=service_type, - capacity=capacity, - is_integrated=False, - block=block, - geometry=block.geometry.representative_point().buffer(0.01), - ) - block.services.append(dummy_service) - - def _update_block(block: Block, zone: str, service_types: list[ServiceType]): - if zone in const.residential_mapping: # если квартал жилой - pop_min, pop_max = const.residential_mapping[zone] - _update_residential_block(block, random.randint(pop_min, pop_max), service_types) - else: - _update_non_residential_block(block) - - def update_blocks(city: City, blocks_with_lu: gpd.GeoDataFrame, service_types: list[ServiceType]): - for block_id, row in blocks_with_lu.iterrows(): - zone = row['zone'] - block = city[block_id] - _update_block(block, zone, service_types) - - LU_SHARE = 0.5 - SQ_M_IN_HA = 10_000 - zones = _process_zones(zones) - blocks = city.get_blocks_gdf(True) - zones.to_crs(blocks.crs, inplace=True) - blocks_with_lu = _get_blocks_to_process(blocks, zones) - residential_sts = [city[st_name] for st_name in ['school', 'kindergarten', 'polyclinic'] if st_name in city.services] - update_blocks(city, blocks_with_lu, residential_sts) - return city - -# ToDo move zones to preprocessing and pass them to the function -def fetch_city_model( - project_info: dict, - project_scenario_id: int, - scenario_gdf: gpd.GeoDataFrame, - physical_object_types: dict, - service_types: list, - scale: em.ScaleType -): - - # getting boundaries for our model - boundaries_gdf = _get_boundaries(project_info, scale) - local_crs = boundaries_gdf.crs - - # clipping scenario objects - scenario_gdf = scenario_gdf.to_crs(local_crs) - scenario_gdf = scenario_gdf.clip(boundaries_gdf) - - roads_gdf = _get_roads(scenario_gdf, physical_object_types) - - # generating blocks layer - blocks_gdf = _generate_blocks(boundaries_gdf, roads_gdf, scenario_gdf, physical_object_types) - - # calculating accessibility matrix - acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) - - # initializing city model - city = City( - blocks=blocks_gdf, - acc_mx=acc_mx, - ) - - # updating buildings layer - _update_buildings(city, scenario_gdf, physical_object_types) - - # updating service types - _update_services(city, service_types, scenario_gdf) - - zones = get_zones(project_scenario_id) - city = _update_landuse(city, zones) - - return city diff --git a/app/api/routers/effects/services/project_service.py b/app/api/routers/effects/services/project_service.py deleted file mode 100644 index 25ab2c3..0000000 --- a/app/api/routers/effects/services/project_service.py +++ /dev/null @@ -1,103 +0,0 @@ -import json - -import requests -import shapely -import geopandas as gpd -from app.api.utils import const -from loguru import logger -from .. import effects_models as em - -def get_scenarios_by_project_id(project_id : int, token : str) -> dict: - res = requests.get(const.URBAN_API + f'/api/v1/projects/{project_id}/scenarios', headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - -def get_based_scenario_id(project_info, token): - scenarios = get_scenarios_by_project_id(project_info['project_id'], token) - based_scenario_id = list(filter(lambda x: x['is_based'], scenarios))[0]['scenario_id'] - return based_scenario_id - -def _get_scenario_objects( - scenario_id : int, - token : str, - scale_type : em.ScaleType, - project_id: int, - physical_object_type_id : int | None = None, - service_type_id : int | None = None, - physical_object_function_id : int | None = None, - urban_function_id : int | None = None, - ): - headers = {'Authorization': f'Bearer {token}'} - if scale_type == em.ScaleType.CONTEXT: - url = const.URBAN_API + f'/api/v1/projects/{project_id}/context/geometries_with_all_objects' - else: - url = const.URBAN_API + f'/api/v1/scenarios/{scenario_id}/geometries_with_all_objects' - res = requests.get(url, params={ - 'physical_object_type_id': physical_object_type_id, - 'service_type_id': service_type_id, - 'physical_object_function_id' : physical_object_function_id, - 'urban_function_id' : urban_function_id - }, headers=headers) - return res.json() - -def get_scenario_objects(scenario_id : int, token : str, project_id: int, *args, **kwargs) -> gpd.GeoDataFrame: - collections = [_get_scenario_objects(scenario_id, token, scale_type, project_id, *args, **kwargs) for scale_type in list(em.ScaleType)] - features = [feature for collection in collections for feature in collection['features']] - gdf = gpd.GeoDataFrame.from_features(features).set_crs(const.DEFAULT_CRS) - return gdf.drop_duplicates(subset=['object_geometry_id']) - -def get_physical_object_types(): - res = requests.get(const.URBAN_API + f'/api/v1/physical_object_types', verify=False) - return res.json() - -def _get_scenario_by_id(scenario_id : int, token : str) -> dict: - res = requests.get(const.URBAN_API + f'/api/v1/scenarios/{scenario_id}', headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - -def _get_project_territory_by_id(project_id : int, token : str) -> dict: - res = requests.get(const.URBAN_API + f'/api/v1/projects/{project_id}/territory', headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - -def _get_project_by_id(project_id : int, token : str) -> dict: - res = requests.get(const.URBAN_API + f'/api/v1/projects/{project_id}', headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - -def _get_territory_by_id(territory_id : int) -> dict: - res = requests.get(const.URBAN_API + f'/api/v1/territory/{territory_id}') - res.raise_for_status() - return res.json() - -def _get_context_geometry(territories_ids : list[int]): - geometries = [] - for territory_id in territories_ids: - territory = _get_territory_by_id(territory_id) - geom_json = json.dumps(territory['geometry']) - geometry = shapely.from_geojson(geom_json) - geometries.append(geometry) - return shapely.unary_union(geometries) - -def get_project_info(project_scenario_id : int, token : str) -> dict: - """ - Fetch project data - """ - scenario_info = _get_scenario_by_id(project_scenario_id, token) - is_based = scenario_info['is_based'] # является ли сценарий базовым для проекта - project_id = scenario_info['project']['project_id'] - - project_info = _get_project_by_id(project_id, token) - context_ids = project_info['properties']['context'] - - project_territory = _get_project_territory_by_id(project_id, token) - region_id = project_territory['project']['region']['id'] - project_geometry = json.dumps(project_territory['geometry']) - - return { - 'project_id' : project_id, - 'region_id': region_id, - 'is_based': is_based, - 'geometry': shapely.from_geojson(project_geometry), - 'context': _get_context_geometry(context_ids) - } diff --git a/app/api/routers/effects/services/service_type_service.py b/app/api/routers/effects/services/service_type_service.py deleted file mode 100644 index 1e14866..0000000 --- a/app/api/routers/effects/services/service_type_service.py +++ /dev/null @@ -1,86 +0,0 @@ -import pandas as pd -import geopandas as gpd -import requests -from fastapi import HTTPException - -from app.api.utils import const -from blocksnet.models import ServiceType - -def _get_service_types(region_id : int) -> pd.DataFrame: - res = requests.get(const.URBAN_API + f'/api/v1/territory/{region_id}/service_types') - res.raise_for_status() - df = pd.DataFrame(res.json()) - return df.set_index('service_type_id') - -def _get_normatives(region_id : int) -> pd.DataFrame: - res = requests.get(const.URBAN_API + f'/api/v1/territory/{region_id}/normatives', params={'year': const.NORMATIVES_YEAR}) - res.raise_for_status() - df = pd.DataFrame(res.json()) - df['service_type_id'] = df['service_type'].apply(lambda st : st['id']) - return df.set_index('service_type_id') - -def get_bn_service_types(region_id : int) -> list[ServiceType]: - """ - Befriend normatives and service types into BlocksNet format - """ - db_service_types_df = _get_service_types(region_id) - db_normatives_df = _get_normatives(region_id) - service_types_df = db_service_types_df.merge(db_normatives_df, left_index=True, right_index=True) - # filter by minutes not null - service_types_df = service_types_df[~service_types_df['time_availability_minutes'].isna()] - # filter by capacity not null - service_types_df = service_types_df[~service_types_df['services_capacity_per_1000_normative'].isna()] - - service_types = [] - for _, row in service_types_df.iterrows(): - service_type = ServiceType( - code=row['code'], - name=row['name'], - accessibility=row['time_availability_minutes'], - demand=row['services_capacity_per_1000_normative'], - land_use = [], #TODO - bricks = [] #TODO - ) - service_types.append(service_type) - return service_types - -def get_zones(scenario_id: int) -> gpd.GeoDataFrame: - """ - - Args: - scenario_id (int): scenario id - - Returns: - gpd.GeoDataFrame: geodataframe with zones - - """ - - def _form_source_params(sources: list[dict]) -> dict: - source_names = [i["source"] for i in sources] - source_data_df = pd.DataFrame(sources) - if "PZZ" in source_names: - return source_data_df.loc[ - source_data_df[source_data_df["source"] == "PZZ"]["year"].idxmax() - ].to_dict() - elif "OSM" in source_names: - return source_data_df.loc[ - source_data_df[source_data_df["source"] == "OSM"]["year"].idxmax() - ].to_dict() - elif "User" in source_names: - return source_data_df.loc[ - source_data_df[source_data_df["source"] == "User"]["year"].idxmax() - ].to_dict() - else: - raise HTTPException(status_code=404, detail="Source type not found") - - zones_sources = requests.get( - url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zone_sources", - ) - zones_params_request = _form_source_params(zones_sources.json()) - target_zones = requests.get( - url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zones", - params=zones_params_request, - ) - target_zones_gdf = gpd.GeoDataFrame.from_features(target_zones.json(), crs=4326) - target_zones_gdf["zone"] = target_zones_gdf["functional_zone_type"].apply(lambda x: x.get("name")) - return target_zones_gdf diff --git a/app/api/__init__.py b/app/common/__init__.py similarity index 100% rename from app/api/__init__.py rename to app/common/__init__.py diff --git a/app/common/api_handler.py b/app/common/api_handler.py new file mode 100644 index 0000000..2173412 --- /dev/null +++ b/app/common/api_handler.py @@ -0,0 +1,245 @@ +import aiohttp + +from app.common.exceptions.http_exception_wrapper import http_exception + + +class APIHandler: + + def __init__( + self, + base_url: str, + ) -> None: + """Initialisation function + + Args: + base_url (str): Base api url + Returns: + None + """ + + self.base_url = base_url + + @staticmethod + async def _check_response_status( + response: aiohttp.ClientResponse + ) -> list | dict | None: + """Function handles response + + Args: + response (aiohttp.ClientResponse): Response object + Returns: + list|dict: requested data with additional info, e.g. {"retry": True | False, "response": {response.json}} + Raises: + http_exception with response status code from API + """ + + if response.status in (200, 201): + return await response.json(content_type="application/json") + elif response.status == 500: + if response.content_type == "application/json": + response_info = await response.json() + if "reset by peer" in await response_info["error"]: + return None + else: + response_info = await response.text() + raise http_exception( + response.status, + "Couldn't get data from API", + _input=response.url.__str__(), + _detail=response_info, + ) + else: + raise http_exception( + response.status, + "Couldn't get data from API", + _input=response.url.__str__(), + _detail=await response.json(), + ) + + async def get( + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + session: aiohttp.ClientSession | None = None, + ) -> dict | list: + """Function to get data from api + + Args: + endpoint_url (str): Endpoint url + headers (dict | None): Headers + params (dict | None): Query parameters + session (aiohttp.ClientSession | None): Session to use + Returns: + dict | list: Response data as python object + """ + + if not session: + async with aiohttp.ClientSession() as session: + return await self.get( + endpoint_url=endpoint_url, + headers=headers, + params=params, + session=session, + ) + url = self.base_url + endpoint_url + async with session.get( + url=url, + headers=headers, + params=params + ) as response: + result = await self._check_response_status(response) + if isinstance(result, list): + return result + elif isinstance(result, dict): + return result + if not result: + if isinstance(result, list): + return result + elif isinstance(result, dict): + return result + return await self.get( + endpoint_url=endpoint_url, + headers=headers, + params=params, + session=session, + ) + return result + + async def post( + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, + ) -> dict | list: + """Function to post data from api + + Args: + endpoint_url (str): Endpoint url + headers (dict | None): Headers + params (dict | None): Query parameters + data (dict | None): Request data + session (aiohttp.ClientSession | None): Session to use + Returns: + dict | list: Response data as python object + """ + + if not session: + async with aiohttp.ClientSession() as session: + return await self.post( + endpoint_url=endpoint_url, + headers=headers, + params=params, + data=data, + session=session, + ) + url = self.base_url + endpoint_url + async with session.post( + url=url, + headers=headers, + params=params, + data=data, + ) as response: + result = await self._check_response_status(response) + if not result: + return await self.post( + endpoint_url=endpoint_url, + headers=headers, + params=params, + session=session, + ) + return result + + async def put( + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, + ) -> dict | list: + """Function to post data from api + + Args: + endpoint_url (str): Endpoint url + headers (dict | None): Headers + params (dict | None): Query parameters + data (dict | None): Request data + session (aiohttp.ClientSession | None): Session to use + Returns: + dict | list: Response data as python object + """ + + if not session: + async with aiohttp.ClientSession() as session: + return await self.put( + endpoint_url=endpoint_url, + headers=headers, + params=params, + data=data, + session=session, + ) + url = self.base_url + endpoint_url + async with session.put( + url=url, + headers=headers, + params=params, + data=data, + ) as response: + result = await self._check_response_status(response) + if not result: + return await self.put( + endpoint_url=endpoint_url, + headers=headers, + params=params, + session=session, + ) + return result + + async def delete( + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, + ) -> dict | list: + """Function to post data from api + + Args: + endpoint_url (str): Endpoint url + headers (dict | None): Headers + params (dict | None): Query parameters + data (dict | None): Request data + session (aiohttp.ClientSession | None): Session to use + Returns: + dict | list: Response data as python object + """ + + if not session: + async with aiohttp.ClientSession() as session: + return await self.delete( + endpoint_url=endpoint_url, + headers=headers, + params=params, + data=data, + session=session, + ) + url = self.base_url + endpoint_url + async with session.delete( + url=url, + headers=headers, + params=params, + data=data, + ) as response: + result = await self._check_response_status(response) + if not result: + return await self.delete( + endpoint_url=endpoint_url, + headers=headers, + params=params, + session=session, + ) + return result \ No newline at end of file diff --git a/app/api/utils/auth.py b/app/common/auth.py similarity index 100% rename from app/api/utils/auth.py rename to app/common/auth.py diff --git a/app/api/utils/decorators.py b/app/common/decorators.py similarity index 100% rename from app/api/utils/decorators.py rename to app/common/decorators.py diff --git a/app/api/routers/__init__.py b/app/common/exceptions/__init__.py similarity index 100% rename from app/api/routers/__init__.py rename to app/common/exceptions/__init__.py diff --git a/app/common/exceptions/http_exception_wrapper.py b/app/common/exceptions/http_exception_wrapper.py new file mode 100644 index 0000000..22ac213 --- /dev/null +++ b/app/common/exceptions/http_exception_wrapper.py @@ -0,0 +1,12 @@ +from fastapi import HTTPException + + +def http_exception(status_code: int, msg: str, _input=None, _detail=None) -> HTTPException: + return HTTPException( + status_code=status_code, + detail={ + "msg": msg, + "input": _input, + "detail": _detail + } + ) \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..22dfef4 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,27 @@ +import sys + +from loguru import logger +from iduconfig import Config + +from app.common.api_handler import APIHandler + +logger.remove() +logger.add(sys.stderr, level="INFO") +log_level = "INFO" +log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}" +logger.add( + sys.stderr, + format=log_format, + level=log_level, + colorize=True +) + +config = Config() + +logger.add( + f"{config.get('LOGS_FILE')}.log", + format=log_format, + level="INFO", +) + +urban_api_handler = APIHandler(config.get("URBAN_API")) diff --git a/app/api/routers/effects/__init__.py b/app/effects_api/__init__.py similarity index 100% rename from app/api/routers/effects/__init__.py rename to app/effects_api/__init__.py diff --git a/app/api/routers/effects/services/__init__.py b/app/effects_api/constants/__init__.py similarity index 100% rename from app/api/routers/effects/services/__init__.py rename to app/effects_api/constants/__init__.py diff --git a/app/api/utils/const.py b/app/effects_api/constants/const.py similarity index 94% rename from app/api/utils/const.py rename to app/effects_api/constants/const.py index bc22328..fb91531 100644 --- a/app/api/utils/const.py +++ b/app/effects_api/constants/const.py @@ -1,6 +1,6 @@ import os from iduconfig import Config -from blocksnet import LandUse +from blocksnet.enums import LandUse config = Config() @@ -35,13 +35,10 @@ 'agriculture': LandUse.AGRICULTURE, 'transport': LandUse.TRANSPORT, 'business': LandUse.BUSINESS, - # 'basic': , 'residential_individual': LandUse.RESIDENTIAL, 'residential_lowrise': LandUse.RESIDENTIAL, 'residential_midrise': LandUse.RESIDENTIAL, 'residential_multistorey': LandUse.RESIDENTIAL, - # 'unknown': '', - # 'mixed_use': '' } diff --git a/app/api/routers/effects/effects_controller.py b/app/effects_api/effects_controller.py similarity index 66% rename from app/api/routers/effects/effects_controller.py rename to app/effects_api/effects_controller.py index a7ffe88..b36084d 100644 --- a/app/api/routers/effects/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -3,14 +3,14 @@ from typing import Annotated from loguru import logger -from blocksnet.models import ServiceType +# from blocksnet.models import ServiceType from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from ...utils import auth, const, decorators -from . import effects_models as em -from . import effects_service as es -from .services import service_type_service as sts -from app.api.routers.effects.task_schema import TaskSchema, TaskStatusSchema, TaskInfoSchema -from app.api.routers.effects.services.task_api_service import get_scenario_info, get_all_project_info, get_project_id +from app.effects_api.constants import const +from app.common import auth, decorators +from app.effects_api.models import effects_models as em +from app.effects_api.modules import effects_service as es, service_type_service as sts +from app.effects_api.schemas.task_schema import TaskSchema, TaskStatusSchema, TaskInfoSchema +from app.effects_api.modules.task_api_service import get_scenario_info, get_all_project_info, get_project_id router = APIRouter(prefix='/effects', tags=['Effects']) @@ -68,47 +68,6 @@ def check_task_evaluation(scenario_id: int) -> None: } ) -@router.get('/service_types') -def get_service_types(region_id: int) -> list[ServiceType]: - return sts.get_bn_service_types(region_id) - -@router.get('/provision_layer') -@decorators.gdf_to_geojson -def get_provision_layer(project_scenario_id: int, scale_type: em.ScaleType, service_type_id: int, - token: str = Depends(auth.verify_token)): - check_task_evaluation(project_scenario_id) - return es.get_provision_layer(project_scenario_id, scale_type, service_type_id, token) - -@router.get('/provision_data') -def get_provision_data( - project_scenario_id: int, - scale_type: Annotated[em.ScaleTypeModel, Depends(em.ScaleTypeModel)], - token: str = Depends(auth.verify_token) -): - check_task_evaluation(project_scenario_id) - return es.get_provision_data(project_scenario_id, scale_type.scale_type, token) - -@router.get('/transport_layer') -@decorators.gdf_to_geojson -def get_transport_layer(project_scenario_id: int, scale_type: em.ScaleType, token: str = Depends(auth.verify_token)): - check_task_evaluation(project_scenario_id) - return es.get_transport_layer(project_scenario_id, scale_type, token) - -@router.get('/transport_data') -def get_transport_data(project_scenario_id: int, scale_type: em.ScaleType, token: str = Depends(auth.verify_token)): - check_task_evaluation(project_scenario_id) - return es.get_transport_data(project_scenario_id, scale_type, token) - -@router.get('/connectivity_layer') -@decorators.gdf_to_geojson -def get_connectivity_layer(project_scenario_id: int, scale_type: em.ScaleType, token: str = Depends(auth.verify_token)): - check_task_evaluation(project_scenario_id) - return es.get_connectivity_layer(project_scenario_id, scale_type, token) - -@router.get('/connectivity_data') -def get_connectivity_data(project_scenario_id: int, scale_type: em.ScaleType, token: str = Depends(auth.verify_token)): - check_task_evaluation(project_scenario_id) - return es.get_connectivity_data(project_scenario_id, scale_type, token) #ToDo rewrite to check token firstly def check_or_set_status(project_scenario_id: int, token) -> dict: @@ -171,10 +130,9 @@ def check_or_set_status(project_scenario_id: int, token) -> dict: ) return {"action": "continue"} -def _evaluate_effects_task(project_scenario_id: int, token: str): - +def _evaluate_master_plan_task(project_scenario_id: int, token: str = Depends(auth.verify_token), is_context: bool = False): try: - es.evaluate_effects(project_scenario_id, token) + es.evaluate_f22(project_scenario_id, token, is_context=is_context) tasks[project_scenario_id].task_status.task_status = "success" except Exception as e: logger.error(e) @@ -182,14 +140,15 @@ def _evaluate_effects_task(project_scenario_id: int, token: str): tasks[project_scenario_id].task_status.task_status = 'error' tasks[project_scenario_id].task_status.error_info = e.__str__() + @router.post('/evaluate') -def evaluate(background_tasks: BackgroundTasks, project_scenario_id: int, token: str = Depends(auth.verify_token)): +def evaluate(background_tasks: BackgroundTasks, project_scenario_id: int, token: str = Depends(auth.verify_token), is_context: bool = False): check_result = check_or_set_status(project_scenario_id, token) if check_result["action"] == "return": del check_result["action"] return check_result - background_tasks.add_task(_evaluate_effects_task, project_scenario_id, token) - return {'task_id' : project_scenario_id } + background_tasks.add_task(_evaluate_master_plan_task, project_scenario_id, token, is_context=is_context) + return {'task_id': project_scenario_id} @router.delete('/evaluation') def delete_evaluation(project_scenario_id : int): @@ -198,3 +157,7 @@ def delete_evaluation(project_scenario_id : int): return 'oke' except: return 'oops' + + + + diff --git a/app/api/utils/__init__.py b/app/effects_api/models/__init__.py similarity index 100% rename from app/api/utils/__init__.py rename to app/effects_api/models/__init__.py diff --git a/app/api/routers/effects/effects_models.py b/app/effects_api/models/effects_models.py similarity index 100% rename from app/api/routers/effects/effects_models.py rename to app/effects_api/models/effects_models.py diff --git a/app/effects_api/modules/__init__.py b/app/effects_api/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/modules/blocksnet_service.py b/app/effects_api/modules/blocksnet_service.py new file mode 100644 index 0000000..8b21f57 --- /dev/null +++ b/app/effects_api/modules/blocksnet_service.py @@ -0,0 +1,316 @@ +import geopandas as gpd +import pandas as pd +from blocksnet.blocks.aggregation import aggregate_objects +from blocksnet.blocks.assignment import assign_land_use +from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects +from blocksnet.relations import get_accessibility_graph, calculate_accessibility_matrix +from loguru import logger + + +from app.common.exceptions.http_exception_wrapper import http_exception +from app.effects_api.constants import const +from app.effects_api.constants.const import mapping +from app.effects_api.modules.service_type_service import get_zones +from app.effects_api.modules.urban_api_gateway import get_scenario_objects + +SPEED_M_MIN = 60 * 1000 / 60 +GAP_TOLERANCE = 5 + +def _get_geoms_by_function(function_name, physical_object_types, scenario_gdf): + valid_type_ids = { + d['physical_object_type_id'] + for d in physical_object_types + if function_name in d['physical_object_function']['name'] + } + return scenario_gdf[scenario_gdf['physical_objects'].apply( + lambda x: any(d.get('physical_object_type').get('id') in valid_type_ids for d in x))] + +def _get_water(scenario_gdf, physical_object_types): + water = _get_geoms_by_function('Водный объект', physical_object_types, scenario_gdf) + water = water.explode(index_parts=True) + water = water.reset_index() + return water + +import shapely +import numpy as np + +def close_gaps(gdf, tolerance): # taken from momepy + geom = gdf.geometry.array + coords = shapely.get_coordinates(geom) + indices = shapely.get_num_coordinates(geom) + + edges = [0] + i = 0 + for ind in indices: + ix = i + ind + edges.append(ix - 1) + edges.append(ix) + i = ix + edges = edges[:-1] + points = shapely.points(np.unique(coords[edges], axis=0)) + + buffered = shapely.buffer(points, tolerance / 2) + + dissolved = shapely.union_all(buffered) + + exploded = [ + shapely.get_geometry(dissolved, i) + for i in range(shapely.get_num_geometries(dissolved)) + ] + + centroids = shapely.centroid(exploded) + + snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) + + return gpd.GeoSeries(snapped, crs=gdf.crs) + +def _get_roads(scenario_gdf, physical_object_types): + roads = _get_geoms_by_function('Дорога', physical_object_types, scenario_gdf) + merged = roads.unary_union + if merged.geom_type == 'MultiLineString': + roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) + else: + roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) + roads = roads.explode(index_parts=False).reset_index(drop=True) + roads.geometry = close_gaps(roads, GAP_TOLERANCE) + roads = roads[roads.geom_type.isin(['LineString'])] + return roads + +def _get_geoms_by_object_type_id(scenario_gdf, object_type_id): + return scenario_gdf[scenario_gdf['physical_objects'].apply(lambda x: any(d.get('physical_object_type').get('id') == object_type_id for d in x))] + +def _get_buildings(scenario_gdf, physical_object_types): + LIVING_BUILDINGS_ID = 4 + NON_LIVING_BUILDINGS_ID = 5 + living_building = _get_geoms_by_object_type_id(scenario_gdf, LIVING_BUILDINGS_ID) + living_building['is_living'] = True + # print(living_building) + non_living_buildings = _get_geoms_by_object_type_id(scenario_gdf, NON_LIVING_BUILDINGS_ID) + non_living_buildings['is_living'] = False + + buildings = gpd.GeoDataFrame( pd.concat( [living_building, non_living_buildings], ignore_index=True) ) + # print(buildings) + # buildings = _get_geoms_by_function('Здание', physical_object_types, scenario_gdf) + buildings['number_of_floors'] = 1 + # buildings['is_living'] = True + buildings['footprint_area'] = buildings.geometry.area + buildings['build_floor_area'] = buildings['footprint_area'] * buildings['number_of_floors'] + buildings['living_area'] = buildings.geometry.area + buildings['population'] = 0 + buildings['population'][buildings['is_living']] = 100 + buildings = buildings.reset_index() + buildings = buildings[buildings.geometry.type != 'Point'] + return buildings[['geometry', 'number_of_floors', 'footprint_area', 'build_floor_area', 'living_area', 'population']] + + +def _get_services(scenario_gdf) -> gpd.GeoDataFrame | None: + + def extract_services(row): + if isinstance(row['services'], list) and len(row['services']) > 0: + return [ + { + 'service_id': service['service_id'], + 'service_type_id': service['service_type']['id'], + 'name': service['name'], + 'capacity_real': service['capacity'], + 'geometry': row['geometry'] # Сохраняем геометрию + } + for service in row['services'] + if service.get('capacity') is not None and service['capacity'] > 0 + ] + return [] + + extracted_data = [] + for _, row in scenario_gdf.iterrows(): + extracted_data.extend(extract_services(row)) + + if len(extracted_data) == 0: + raise http_exception(404, f'No services found to extract') + + services_gdf = gpd.GeoDataFrame(extracted_data, crs=scenario_gdf.crs) + + services_gdf['capacity'] = services_gdf['capacity_real'] + services_gdf = services_gdf[['geometry', 'service_id', 'service_type_id', 'name', 'capacity']] + + services_gdf['area'] = services_gdf.geometry.area + services_gdf['area'] = services_gdf['area'].apply(lambda a : a if a > 1 else 1) + # services_gdf.loc[services_gdf.area == 0, 'area'] = 100 + # services_gdf['area'] = services_gdf + + return services_gdf + +def _get_boundaries(project_info : dict, is_context: bool = False) -> gpd.GeoDataFrame: + if is_context == False: + boundaries = gpd.GeoDataFrame(geometry=[project_info['geometry']]) + else: + boundaries = gpd.GeoDataFrame(geometry=[project_info['context']]) + boundaries = boundaries.set_crs(const.DEFAULT_CRS) + local_crs = boundaries.estimate_utm_crs() + return boundaries.to_crs(local_crs) + + +def _generate_blocks(boundaries_gdf : gpd.GeoDataFrame, roads_gdf : gpd.GeoDataFrame, scenario_gdf : gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: + water_gdf = _get_water(scenario_gdf, physical_object_types).to_crs(boundaries_gdf.crs) + lines, polygons = preprocess_urban_objects(roads_gdf, None, water_gdf) + blocks = cut_urban_blocks(boundaries_gdf, lines, polygons) + blocks['land_use'] = None # TODO ЗАмнить на норм land_use?? >> здесь должен быть этап определения лендюза по тому что есть в бд + return blocks + + +def _assign_landuse(project_scenario_id: int, blocks: gpd.GeoDataFrame, is_context: bool, token): + functional_zones = get_zones(project_scenario_id, token, is_context) + functional_zones = functional_zones.to_crs(functional_zones.estimate_utm_crs()) + functional_zones = functional_zones[["geometry", "functional_zone"]] + blocks = assign_land_use(blocks, functional_zones, mapping) + return blocks + + +def _calculate_acc_mx(boundaries : gpd.GeoDataFrame, blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: + graph = get_accessibility_graph(boundaries, 'intermodal') + accessibility_matrix = calculate_accessibility_matrix(blocks_gdf, graph) + return accessibility_matrix + + +def _update_buildings(scenario_gdf: gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: + buildings_gdf = _get_buildings(scenario_gdf, physical_object_types) + buildings_gdf = buildings_gdf[buildings_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] + # buildings_gdf = impute_buildings(buildings_gdf, default_living_demand=30) + return buildings_gdf + + + + +# def _update_services(city : City, service_types : list[ServiceType], scenario_gdf : gpd.GeoDataFrame) -> None: +# # reset service types +# city._service_types = {} +# for st in service_types: +# city.add_service_type(st) +# # filter services and add to the model if exist +# services_gdf = _get_services(scenario_gdf) +# if services_gdf is None: +# return +# services_gdf = services_gdf.to_crs(city.crs).copy() +# service_type_dict = {service.code: service for service in service_types} +# for service_type_code, st_gdf in services_gdf.groupby('service_type_id'): +# gdf = st_gdf.copy().to_crs(city.crs) +# gdf.geometry = gdf.representative_point() +# service_type = service_type_dict.get(str(service_type_code), None) +# if service_type is not None: +# city.update_services(service_type, gdf) + +def _update_services(scenario_gdf : gpd.GeoDataFrame) -> gpd.GeoDataFrame: + services_gdf = _get_services(scenario_gdf) + services_gdf = services_gdf[services_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] + return services_gdf + +# ToDo handle no service case + + + + # def _update_non_residential_block(block: Block): + # for building in block.buildings: + # building.population = 0 + # + # def _update_residential_block(block: Block, pop_per_ha: float, service_types: list[ServiceType]): + # pop_per_m2 = pop_per_ha / SQ_M_IN_HA + # area = block.site_area + # population = round(pop_per_m2 * area) + # # удаляем здания и сервисы + # block.buildings = [] + # block.services = [] + # # добавляем dummy здание и даем ему наше население + # dummy_building = Building( + # block=block, + # geometry=block.geometry.buffer(-0.01), + # population=population, + # **const.DUMMY_BUILDING_PARAMS + # ) + # block.buildings.append(dummy_building) + # # добавляем по каждому типу сервиса большой dummy_service + # for service_type in service_types: + # capacity = service_type.calculate_in_need(population) + # dummy_service = BlockService( + # service_type=service_type, + # capacity=capacity, + # is_integrated=False, + # block=block, + # geometry=block.geometry.representative_point().buffer(0.01), + # ) + # block.services.append(dummy_service) + # + # def _update_block(block: Block, zone: str, service_types: list[ServiceType]): + # if zone in const.residential_mapping: # если квартал жилой + # pop_min, pop_max = const.residential_mapping[zone] + # _update_residential_block(block, random.randint(pop_min, pop_max), service_types) + # else: + # _update_non_residential_block(block) + # + # def update_blocks(city: City, blocks_with_lu: gpd.GeoDataFrame, service_types: list[ServiceType]): + # for block_id, row in blocks_with_lu.iterrows(): + # zone = row['zone'] + # block = city[block_id] + # _update_block(block, zone, service_types) + # + # LU_SHARE = 0.5 + # SQ_M_IN_HA = 10_000 + # zones = _process_zones(zones) + # blocks = city.get_blocks_gdf(True) + # zones.to_crs(blocks.crs, inplace=True) + # blocks_with_lu = _get_blocks_to_process(blocks, zones) + # residential_sts = [city[st_name] for st_name in ['school', 'kindergarten', 'polyclinic'] if st_name in city.services] + # update_blocks(city, blocks_with_lu, residential_sts) + # return city + +# ToDo move zones to preprocessing and pass them to the function + +def fetch_city_model( + project_info: dict, + project_scenario_id: int, + token: str, + # scenario_gdf: gpd.GeoDataFrame, + physical_object_types: dict, + # service_types: list, + is_context: bool = False +): + # getting boundaries for our model + logger.info('Fetching city model') + boundaries_gdf = _get_boundaries(project_info, is_context) + logger.info('boundaries have been fetched') + local_crs = boundaries_gdf.crs + scenario_objects_gdf = get_scenario_objects(project_scenario_id, token, is_context) + # clipping scenario objects + scenario_objects_gdf = scenario_objects_gdf.to_crs(local_crs) + scenario_objects_gdf = scenario_objects_gdf.clip(boundaries_gdf) + logger.info('scenario objects have been fetched') + + roads_gdf = _get_roads(scenario_objects_gdf, physical_object_types) + roads_gdf = roads_gdf.to_crs(local_crs) + logger.info('roads have been fetched') + + # generating blocks layer + blocks_gdf = _generate_blocks(boundaries_gdf, roads_gdf, scenario_objects_gdf, physical_object_types) + blocks_gdf = blocks_gdf.to_crs(local_crs) + logger.info('blocks have been fetched') + blocks_gdf = _assign_landuse(project_scenario_id, blocks_gdf, is_context, token) + logger.info('landuse has been assigned') + # acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) + # logger.info('acc_mx has been calculated') + + + + # initializing city model + # city = City( + # blocks=blocks_gdf, + # acc_mx=acc_mx, + # ) + + # updating buildings layer + buildings_gdf = _update_buildings(scenario_objects_gdf, physical_object_types) + buildings_blocks_gdf = aggregate_objects(blocks_gdf, buildings_gdf)[0] + logger.info('buildings blocks have been aggregated') + + # services_gdf = _update_services(scenario_objects_gdf) + # services_blocks_gdf = aggregate_objects(buildings_blocks_gdf, services_gdf)[0] + # logger.info('services blocks have been aggregated') + + return blocks_gdf, buildings_blocks_gdf diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py new file mode 100644 index 0000000..ba7581d --- /dev/null +++ b/app/effects_api/modules/context_service.py @@ -0,0 +1,98 @@ +import geopandas as gpd +import shapely +import numpy as np +from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks + +from app.effects_api.modules.urban_api_gateway import get_zones, get_project_geometry + + +def close_gaps(gdf, tolerance): # taken from momepy + geom = gdf.geometry.array + coords = shapely.get_coordinates(geom) + indices = shapely.get_num_coordinates(geom) + + edges = [0] + i = 0 + for ind in indices: + ix = i + ind + edges.append(ix - 1) + edges.append(ix) + i = ix + edges = edges[:-1] + points = shapely.points(np.unique(coords[edges], axis=0)) + + buffered = shapely.buffer(points, tolerance / 2) + + dissolved = shapely.union_all(buffered) + + exploded = [ + shapely.get_geometry(dissolved, i) + for i in range(shapely.get_num_geometries(dissolved)) + ] + + centroids = shapely.centroid(exploded) + + snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) + + return gpd.GeoSeries(snapped, crs=gdf.crs) + +LIVING_BUILDING_POT_ID = 4 + +def _get_project_boundaries(project_id : int): + return gpd.GeoDataFrame(geometry=[projects.get_project_geometry(project_id)], crs=4326) + +def _get_context_boundaries(project_id : int) -> gpd.GeoDataFrame: + project = projects.get_project(project_id) + context_ids = project['properties']['context'] + geometries = [territories.get_territory_geometry(territory_id) for territory_id in context_ids] + return gpd.GeoDataFrame(geometry=geometries, crs=4326) + +def _get_context_roads(project_id : int): + gdf = projects.get_physical_objects(project_id, physical_object_function_id=ROADS_POF_ID) + return gdf[['geometry']].reset_index(drop=True) + +def _get_context_water(project_id : int): + gdf = projects.get_physical_objects(project_id, physical_object_function_id=WATER_POF_ID) + return gdf[['geometry']].reset_index(drop=True) + +def _get_context_blocks(project_id : int, boundaries : gpd.GeoDataFrame) -> gpd.GeoDataFrame: + crs = boundaries.crs + boundaries.geometry = boundaries.buffer(-1) + + water = _get_context_water(project_id).to_crs(crs) + roads = _get_context_roads(project_id).to_crs(crs) + roads.geometry = close_gaps(roads, 1) + + lines, polygons = preprocess_urban_objects(roads, None, water) + blocks = cut_urban_blocks(boundaries, lines, polygons) + return blocks + + +def get_context_blocks(project_id: int, token: str): + project_boundaries = get_project_geometry(project_id, token) + context_boundaries = _get_context_boundaries(project_id) + + crs = context_boundaries.estimate_utm_crs() + context_boundaries = context_boundaries.to_crs(crs) + project_boundaries = project_boundaries.to_crs(crs) + + context_boundaries = context_boundaries.overlay(project_boundaries, how='difference') + return _get_context_blocks(project_id, context_boundaries) + +def get_context_functional_zones(project_id : int, token: str) -> gpd.GeoDataFrame: + sources_df = get_zones(project_id, token, is_context=True) + year, source = _get_best_functional_zones_source(sources_df) + functional_zones = projects.get_functional_zones(project_id, year, source) + return adapt_functional_zones(functional_zones) + +def get_context_buildings(project_id : int): + gdf = projects.get_physical_objects(project_id, physical_object_type_id=1, centers_only=True) + gdf = adapt_buildings(gdf.reset_index(drop=True)) + crs = gdf.estimate_utm_crs() + return impute_buildings(gdf.to_crs(crs)).to_crs(4326) + +def get_context_services(project_id : int, service_types : pd.DataFrame): + gdf = projects.get_services(project_id, centers_only=True) + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) + return {st:impute_services(gdf,st) for st,gdf in gdfs.items()} + diff --git a/app/effects_api/modules/effects_service.py b/app/effects_api/modules/effects_service.py new file mode 100644 index 0000000..5e3d755 --- /dev/null +++ b/app/effects_api/modules/effects_service.py @@ -0,0 +1,121 @@ +import os +import math +from typing import Literal + +import geopandas as gpd +import warnings +import pandas as pd +from blocksnet.analysis.indicators import calculate_development_indicators +from blocksnet.enums import LandUse +from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor +from blocksnet.relations import generate_adjacency_graph +from urllib3.exceptions import InsecureRequestWarning +from loguru import logger +from app.effects_api.constants import const +from app.effects_api.models import effects_models as em +from app.effects_api.modules import blocksnet_service as bs, urban_api_gateway as ps + +for warning in [pd.errors.PerformanceWarning, RuntimeWarning, pd.errors.SettingWithCopyWarning, InsecureRequestWarning, + FutureWarning]: + warnings.filterwarnings(action='ignore', category=warning) + +PROVISION_COLUMNS = ['provision', 'demand', 'demand_within'] + + +def _get_file_path(project_scenario_id: int, effect_type: em.EffectType, scale_type: em.ScaleType): + file_path = f'{project_scenario_id}_{effect_type.name}_{scale_type.name}' + return os.path.join(const.DATA_PATH, f'{file_path}.parquet') + +def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: + logger.info('Evaluating master plan effects') + blocks = buildings_blocks_gdf.join( + blocks.drop(columns="geometry"), # не тащим геометрию дважды + how="left" + ) + adjacency_graph = generate_adjacency_graph(blocks, 10) + dr = DensityRegressor() + density_df = dr.evaluate(blocks, adjacency_graph) + + + density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 + density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 + density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 + density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 + density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 + density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 + + blocks['site_area'] = blocks.area + density_df['site_area'] = blocks['site_area'] + development_df = calculate_development_indicators(density_df) + development_df['population'] = development_df['living_area'] // 20 + cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] + blocks[cols] = development_df[cols].values + for lu in LandUse: + blocks[lu.value] = blocks[lu.value] * blocks['site_area'] + data = [blocks.drop(columns=['land_use', 'geometry']).sum().to_dict()] + input = pd.DataFrame(data) + + input['latitude'] = blocks.geometry.union_all().centroid.x + input['longitude'] = blocks.geometry.union_all().centroid.y + input['buildings_count'] = input['count'] + sr = SocialRegressor() + y_pred, pi_lower, pi_upper = sr.evaluate(input) + iloc = 0 + result_data = { + 'pred': y_pred.apply(round).astype(int).iloc[iloc].to_dict(), + 'lower': pi_lower.iloc[iloc].to_dict(), + 'upper': pi_upper.iloc[iloc].to_dict(), + } + result_df = pd.DataFrame.from_dict(result_data) + result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) + + return result_df + + +def _evaluation_exists(project_scenario_id: int, token: str): + exists = True + for effect_type in list(em.EffectType): + for scale_type in list(em.ScaleType): + file_path = _get_file_path(project_scenario_id, effect_type, scale_type) + if not os.path.exists(file_path): + exists = False + return exists + + +def delete_evaluation(project_scenario_id: int): + for effect_type in list(em.EffectType): + for scale_type in list(em.ScaleType): + file_path = _get_file_path(project_scenario_id, effect_type, scale_type) + if os.path.exists(file_path): + os.remove(file_path) + + +def evaluate_f22(project_scenario_id: int, token: str, reevaluate: bool = True, is_context: bool = False): + logger.info(f'Fetching {project_scenario_id} project info') + + project_info = ps.get_project_info(project_scenario_id, token) + based_scenario_id = ps.get_based_scenario_id(project_info, token) + if project_scenario_id != based_scenario_id: + evaluate_f22(based_scenario_id, token, reevaluate=False) + + exists = _evaluation_exists(project_scenario_id, token) + if exists and not reevaluate: + logger.info(f'{project_scenario_id} evaluation already exists') + return + + logger.info('Fetching region service types') + logger.info('Fetching physical object types') + physical_object_types = ps.get_physical_object_types() + + logger.info('Fetching project model') + blocks_gdf, buildings_blocks_gdf = bs.fetch_city_model( + project_info=project_info, + project_scenario_id=project_scenario_id, + token=token, + # service_types=service_types, + physical_object_types=physical_object_types, + is_context=is_context + ) + + + _evaluate_master_plan(blocks_gdf, buildings_blocks_gdf) diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py new file mode 100644 index 0000000..947b001 --- /dev/null +++ b/app/effects_api/modules/service_type_service.py @@ -0,0 +1,41 @@ +import pandas as pd +import geopandas as gpd +from fastapi import HTTPException + +from app.dependencies import urban_api_handler +from app.effects_api.constants import const +from app.effects_api.modules.urban_api_gateway import get_project_info + + +async def get_indicators(parent_id: int | None = None, **kwargs): + res = urban_api_handler.get('/api/v1/indicators_by_parent', params={ + 'parent_id': parent_id, + **kwargs + }) + return pd.DataFrame(res).set_index('indicator_id') + + +async def get_service_types(**kwargs): + res = urban_api_handler.get('/api/v1/service_types', params=kwargs) + return pd.DataFrame(res).set_index('service_type_id') + + +async def get_social_values(**kwargs): + res = urban_api_handler.get('/api/v1/social_values', params=kwargs) + return pd.DataFrame(res).set_index('soc_value_id') + + +async def get_social_value_service_types(soc_value_id: int, **kwargs): + res = urban_api_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) + if len(res) == 0: + return None + return pd.DataFrame(res).set_index('service_type_id') + + +async def get_service_type_social_values(service_type_id: int, **kwargs): + res = urban_api_handler.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) + if len(res) == 0: + return None + return pd.DataFrame(res).set_index('soc_value_id') + + diff --git a/app/api/routers/effects/services/task_api_service.py b/app/effects_api/modules/task_api_service.py similarity index 90% rename from app/api/routers/effects/services/task_api_service.py rename to app/effects_api/modules/task_api_service.py index e02e94d..4e46983 100644 --- a/app/api/routers/effects/services/task_api_service.py +++ b/app/effects_api/modules/task_api_service.py @@ -1,12 +1,9 @@ -from datetime import datetime from typing import Optional import requests from fastapi import HTTPException -from app.api.utils.const import URBAN_API -from app.api.routers.effects.task_schema import TaskInfoSchema - +from app.effects_api.constants.const import URBAN_API def get_headers(token: Optional[str] = None) -> dict[str, str] | None: diff --git a/app/effects_api/modules/urban_api_gateway.py b/app/effects_api/modules/urban_api_gateway.py new file mode 100644 index 0000000..f788beb --- /dev/null +++ b/app/effects_api/modules/urban_api_gateway.py @@ -0,0 +1,226 @@ +import json + +import pandas as pd +import requests +import shapely +import geopandas as gpd + +from app.common.exceptions.http_exception_wrapper import http_exception +from app.dependencies import urban_api_handler +from app.effects_api.constants import const +from app.effects_api.models import effects_models as em + + +def get_zones(scenario_id: int, token, is_context: bool = False) -> gpd.GeoDataFrame: + """ + + Args: + scenario_id (int): scenario id + + Returns: + gpd.GeoDataFrame: geodataframe with zones + + """ + + def _form_source_params(sources: list[dict]) -> dict: + source_names = [i["source"] for i in sources] + source_data_df = pd.DataFrame(sources) + if "PZZ" in source_names: + return source_data_df.loc[ + source_data_df[source_data_df["source"] == "PZZ"]["year"].idxmax() + ].to_dict() + elif "OSM" in source_names: + return source_data_df.loc[ + source_data_df[source_data_df["source"] == "OSM"]["year"].idxmax() + ].to_dict() + elif "User" in source_names: + return source_data_df.loc[ + source_data_df[source_data_df["source"] == "User"]["year"].idxmax() + ].to_dict() + else: + raise http_exception(404, "Source type not found for given ID", scenario_id) + + zones_sources = urban_api_handler.get( + url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zone_sources", + ) + zones_params_request = _form_source_params(zones_sources.json()) + + if is_context: + project_id = get_project_info(scenario_id, token)["project_id"] + target_zones = urban_api_handler.get( + url=f"{const.URBAN_API}/api/v1/projects/{project_id}/context/functional_zones", + params=zones_params_request,) + else: + target_zones = urban_api_handler.get( + url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zones", + params=zones_params_request, + ) + target_zones_gdf = gpd.GeoDataFrame.from_features(target_zones.json(), crs=4326) + target_zones_gdf["functional_zone"] = target_zones_gdf["functional_zone_type"].apply(lambda x: x.get("name")) + return target_zones_gdf + +def get_scenarios_by_project_id(project_id: int, token: str) -> dict: + res = urban_api_handler.get(const.URBAN_API + f'/api/v1/projects/{project_id}/scenarios', + headers={'Authorization': f'Bearer {token}'}) + res.raise_for_status() + return res.json() + +def get_project_geometry(project_id: int, token: str): + """ + Fetches the territory information for a project. + + Parameters: + project_id (int): ID of the project. + + Returns: + dict: Territory information. + """ + endpoint = f"/api/v1/projects/{project_id}/territory" + + response = urban_api_handler.get(endpoint, headers = { + "Authorization": f"Bearer {token}""" + }) + + if not response: + raise http_exception(404, f"No territory information found for project ID:", project_id) + + return response + +def get_scenario(scenario_id : int, token: str) -> dict: + res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}', + headers={'Authorization': f'Bearer {token}'}) + return res + + +def get_based_scenario_id(project_info, token): + scenarios = get_scenarios_by_project_id(project_info['project_id'], token) + based_scenario_id = list(filter(lambda x: x['is_based'], scenarios))[0]['scenario_id'] + return based_scenario_id + + +def _get_scenario_objects( + scenario_id: int, + token: str, + is_context: bool, + project_id: int = None, + physical_object_type_id: int | None = None, + service_type_id: int | None = None, + physical_object_function_id: int | None = None, + urban_function_id: int | None = None, +): + headers = {'Authorization': f'Bearer {token}'} + if is_context: + url = const.URBAN_API + f'/api/v1/projects/{project_id}/context/geometries_with_all_objects' + else: + url = const.URBAN_API + f'/api/v1/scenarios/{scenario_id}/geometries_with_all_objects' + res = urban_api_handler.get(url, params={ + 'physical_object_type_id': physical_object_type_id, + 'service_type_id': service_type_id, + 'physical_object_function_id': physical_object_function_id, + 'urban_function_id': urban_function_id + }, headers=headers) + return res.json() + + +def get_physical_objects(scenario_id: int, token: str, is_context: bool, *args, **kwargs) -> gpd.GeoDataFrame: + if is_context: + project_id = get_project_info(scenario_id, token)["project_id"] + raw = _get_scenario_objects( + scenario_id, token, is_context, project_id, *args, **kwargs + ) + else: + raw = _get_scenario_objects( + scenario_id, token, is_context, *args, **kwargs + ) + + if isinstance(raw, dict): + collections = [raw] + else: + collections = raw # type: ignore + + features: list[dict[str, any]] = [ + feature + for collection in collections + for feature in collection.get("features", []) + ] + + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('physical_object_id') + + +def get_services(project_scenario_id: int, token: str, is_context: bool) -> gpd.GeoDataFrame: + if is_context: + project_id = get_project_info(project_scenario_id, token)["project_id"] + res = urban_api_handler.get(f'/api/v1/projects/{project_id}/context/services_with_geometry', + headers={'Authorization': f'Bearer {token}'}) + else: + res = urban_api_handler.get(f'/api/v1/scenarios/{project_scenario_id}/services_with_geometry', + headers={'Authorization': f'Bearer {token}'}) + features = res['features'] + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('service_id') + + +def get_physical_object_types(): + res = urban_api_handler.get(const.URBAN_API + f'/api/v1/physical_object_types', verify=False) + return res.json() + + +def _get_scenario_by_id(scenario_id: int, token: str) -> dict: + res = urban_api_handler.get(const.URBAN_API + f'/api/v1/scenarios/{scenario_id}', + headers={'Authorization': f'Bearer {token}'}) + res.raise_for_status() + return res.json() + + +def _get_project_territory_by_id(project_id: int, token: str) -> dict: + res = urban_api_handler.get(const.URBAN_API + f'/api/v1/projects/{project_id}/territory', + headers={'Authorization': f'Bearer {token}'}) + res.raise_for_status() + return res.json() + + +def _get_project_by_id(project_id: int, token: str) -> dict: + res = urban_api_handler.get(const.URBAN_API + f'/api/v1/projects/{project_id}', + headers={'Authorization': f'Bearer {token}'}) + res.raise_for_status() + return res.json() + + +def _get_territory_by_id(territory_id: int) -> dict: + res = urban_api_handler.get(const.URBAN_API + f'/api/v1/territory/{territory_id}') + res.raise_for_status() + return res.json() + + +def _get_context_geometry(territories_ids: list[int]): + geometries = [] + for territory_id in territories_ids: + territory = _get_territory_by_id(territory_id) + geom_json = json.dumps(territory['geometry']) + geometry = shapely.from_geojson(geom_json) + geometries.append(geometry) + return shapely.unary_union(geometries) + + +def get_project_info(project_scenario_id: int, token: str) -> dict: + """ + Fetch project data + """ + scenario_info = _get_scenario_by_id(project_scenario_id, token) + is_based = scenario_info['is_based'] + project_id = scenario_info['project']['project_id'] + + project_info = _get_project_by_id(project_id, token) + context_ids = project_info['properties']['context'] + + project_territory = _get_project_territory_by_id(project_id, token) + region_id = project_territory['project']['region']['id'] + project_geometry = json.dumps(project_territory['geometry']) + + return { + 'project_id': project_id, + 'region_id': region_id, + 'is_based': is_based, + 'geometry': shapely.from_geojson(project_geometry), + 'context': _get_context_geometry(context_ids) + } + diff --git a/app/effects_api/schemas/__init__.py b/app/effects_api/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/routers/effects/task_schema.py b/app/effects_api/schemas/task_schema.py similarity index 100% rename from app/api/routers/effects/task_schema.py rename to app/effects_api/schemas/task_schema.py diff --git a/app/main.py b/app/main.py index 8ad1a16..54559d1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,8 @@ from contextlib import asynccontextmanager -from app.api.routers.effects import effects_controller -from app.api.routers.effects.task_schema import TaskSchema -from app.api.utils.const import API_DESCRIPTION, API_TITLE +from app.effects_api import effects_controller +from app.effects_api.schemas.task_schema import TaskSchema +from app.effects_api.constants.const import API_DESCRIPTION, API_TITLE from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware diff --git a/requirements.txt b/requirements.txt index 77ca51227177aef4feda6fea5d7d41cb67e70c37..b7cf539b68c197ec86e6a5d525bfbe97f16b4a8f 100644 GIT binary patch delta 51 ycmZ3-^n+3R|Gy-L9EN;`WQJ^pVun11RE81;TOc%K&|@%w;uN508H2&Z#Ipct9Sm9k delta 105 zcmeytxQ;3D|G#vGOokE$ZH5dWS;A1jP|RS(pbvx)@eGDih9m|(hGd3(hFqX%B2aZM lke34%*9Xew0A-RHvVr>Y7*c^I=s|Tl0M!;SWKPsR3ji&R7pDLK diff --git a/testing.ipynb b/testing.ipynb new file mode 100644 index 0000000..54f657b --- /dev/null +++ b/testing.ipynb @@ -0,0 +1,37 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "initial_id", + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 2 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython2", + "version": "2.7.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} From 933b619081218fc4c369b980337e73807d25cf81 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 26 Jun 2025 21:34:04 +0300 Subject: [PATCH 005/161] refactor(WIP_2): added some services for main logic --- app/effects_api/modules/buildings_service.py | 56 ++++ app/effects_api/modules/context_service.py | 10 +- app/effects_api/modules/f22_service.py | 0 .../modules/functional_sources_service.py | 25 ++ app/effects_api/modules/scenario_service.py | 120 +++++++++ .../modules/service_type_service.py | 118 +++++++- app/effects_api/modules/services_service.py | 15 ++ app/effects_api/modules/urban_api_gateway.py | 255 ++++-------------- 8 files changed, 378 insertions(+), 221 deletions(-) create mode 100644 app/effects_api/modules/buildings_service.py create mode 100644 app/effects_api/modules/f22_service.py create mode 100644 app/effects_api/modules/functional_sources_service.py create mode 100644 app/effects_api/modules/scenario_service.py create mode 100644 app/effects_api/modules/services_service.py diff --git a/app/effects_api/modules/buildings_service.py b/app/effects_api/modules/buildings_service.py new file mode 100644 index 0000000..f921b6e --- /dev/null +++ b/app/effects_api/modules/buildings_service.py @@ -0,0 +1,56 @@ +import geopandas as gpd +import pandas as pd + +BUILDINGS_RULES = { + 'number_of_floors': [ + ['floors'], + ['properties', 'storeys_count'], + ['properties', 'osm_data', 'building:levels'] + ], + 'footprint_area': [ + ['building_area_official'], + ['building_area_modeled'], + ['properties', 'area_total'] + ], + 'build_floor_area': [ + ['properties', 'area_total'], + ], + 'living_area': [ + ['properties', 'living_area_official'], + ['properties', 'living_area'], + ['properties', 'living_area_modeled'] + ], + 'non_living_area': [ + ['properties', 'area_non_residential'], + ], + 'population': [ + ['properties', 'population_balanced'] + ] +} + +def _parse(data : dict | None, *args): + key = args[0] + args = args[1:] + if data is not None and key in data and data[key] is not None: + if len(args) == 0: + value = data[key] + if isinstance(value, str): + value = value.replace(',', '.') + return value + return _parse(data[key], *args) + return None + +def _adapt(data : dict, rules : list): + for rule in rules: + value = _parse(data, *rule) + if value is not None: + return value + return None + +def adapt_buildings(buildings_gdf : gpd.GeoDataFrame, living_pot_id : int = 4): + gdf = buildings_gdf[['geometry']].copy() + gdf['is_living'] = buildings_gdf['physical_object_type'].apply(lambda pot : pot['physical_object_type_id'] == 4) + for column, rules in BUILDINGS_RULES.items(): + series = buildings_gdf['building'].apply(lambda b : _adapt(b, rules)) + gdf[column] = pd.to_numeric(series, errors='coerce') + return gdf \ No newline at end of file diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index ba7581d..dba3b61 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -48,11 +48,11 @@ def _get_context_boundaries(project_id : int) -> gpd.GeoDataFrame: return gpd.GeoDataFrame(geometry=geometries, crs=4326) def _get_context_roads(project_id : int): - gdf = projects.get_physical_objects(project_id, physical_object_function_id=ROADS_POF_ID) + gdf = projects.get_physical_objects_scenario(project_id, physical_object_function_id=ROADS_POF_ID) return gdf[['geometry']].reset_index(drop=True) def _get_context_water(project_id : int): - gdf = projects.get_physical_objects(project_id, physical_object_function_id=WATER_POF_ID) + gdf = projects.get_physical_objects_scenario(project_id, physical_object_function_id=WATER_POF_ID) return gdf[['geometry']].reset_index(drop=True) def _get_context_blocks(project_id : int, boundaries : gpd.GeoDataFrame) -> gpd.GeoDataFrame: @@ -82,17 +82,17 @@ def get_context_blocks(project_id: int, token: str): def get_context_functional_zones(project_id : int, token: str) -> gpd.GeoDataFrame: sources_df = get_zones(project_id, token, is_context=True) year, source = _get_best_functional_zones_source(sources_df) - functional_zones = projects.get_functional_zones(project_id, year, source) + functional_zones = projects.get_functional_zones_scenario(project_id, year, source) return adapt_functional_zones(functional_zones) def get_context_buildings(project_id : int): - gdf = projects.get_physical_objects(project_id, physical_object_type_id=1, centers_only=True) + gdf = projects.get_physical_objects_scenario(project_id, physical_object_type_id=1, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) def get_context_services(project_id : int, service_types : pd.DataFrame): - gdf = projects.get_services(project_id, centers_only=True) + gdf = projects.get_services_scenario(project_id, centers_only=True) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st:impute_services(gdf,st) for st,gdf in gdfs.items()} diff --git a/app/effects_api/modules/f22_service.py b/app/effects_api/modules/f22_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/modules/functional_sources_service.py b/app/effects_api/modules/functional_sources_service.py new file mode 100644 index 0000000..701e2bd --- /dev/null +++ b/app/effects_api/modules/functional_sources_service.py @@ -0,0 +1,25 @@ +import geopandas as gpd +from blocksnet.enums import LandUse + +LAND_USE_RULES = { + 'residential' : LandUse.RESIDENTIAL, + 'recreation' : LandUse.RECREATION, + 'special' : LandUse.SPECIAL, + 'industrial' : LandUse.INDUSTRIAL, + 'agriculture' : LandUse.AGRICULTURE, + 'transport' : LandUse.TRANSPORT, + 'business' : LandUse.BUSINESS, + 'residential_individual' : LandUse.RESIDENTIAL, + 'residential_lowrise' : LandUse.RESIDENTIAL, + 'residential_midrise' : LandUse.RESIDENTIAL, + 'residential_multistorey' : LandUse.RESIDENTIAL, +} + +def _adapt_functional_zone(data : dict): + functional_zone_type_id = data['name'] + return functional_zone_type_id + +def adapt_functional_zones(functional_zones_gdf : gpd.GeoDataFrame): + gdf = functional_zones_gdf[['geometry']].copy() + gdf['functional_zone'] = functional_zones_gdf['functional_zone_type'].apply(_adapt_functional_zone) + return gdf \ No newline at end of file diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py new file mode 100644 index 0000000..8f0fe9c --- /dev/null +++ b/app/effects_api/modules/scenario_service.py @@ -0,0 +1,120 @@ +import shapely +import numpy as np +from prostor.adapters import adapt_functional_zones +from prostor.adapters import adapt_services +from blocksnet.preprocessing.imputing import impute_services +from prostor.adapters import adapt_buildings +from blocksnet.preprocessing.imputing import impute_buildings +from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks +import geopandas as gpd +import pandas as pd +import prostor.fetchers.projects as projects +import prostor.fetchers.scenarios as scenarios +import prostor.fetchers.territories as territories +from blocksnet.config import log_config +SOURCES_PRIORITY = ['User', 'PZZ', 'OSM'] + + +def close_gaps(gdf, tolerance): # taken from momepy + geom = gdf.geometry.array + coords = shapely.get_coordinates(geom) + indices = shapely.get_num_coordinates(geom) + + edges = [0] + i = 0 + for ind in indices: + ix = i + ind + edges.append(ix - 1) + edges.append(ix) + i = ix + edges = edges[:-1] + points = shapely.points(np.unique(coords[edges], axis=0)) + + buffered = shapely.buffer(points, tolerance / 2) + + dissolved = shapely.union_all(buffered) + + exploded = [ + shapely.get_geometry(dissolved, i) + for i in range(shapely.get_num_geometries(dissolved)) + ] + + centroids = shapely.centroid(exploded) + + snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) + + return gpd.GeoSeries(snapped, crs=gdf.crs) + +def _get_project_boundaries(project_id : int): + return gpd.GeoDataFrame(geometry=[projects.get_project_geometry(project_id)], crs=4326) + +def _get_scenario_roads(scenario_id : int): + gdf = scenarios.get_physical_objects_scenario(scenario_id, physical_object_function_id=ROADS_POF_ID) + return gdf[['geometry']].reset_index(drop=True) + +def _get_scenario_water(scenario_id : int): + gdf = scenarios.get_physical_objects_scenario(scenario_id, physical_object_function_id=WATER_POF_ID) + return gdf[['geometry']].reset_index(drop=True) + +def _get_scenario_blocks(user_scenario_id : int, base_scenario_id : int, boundaries : gpd.GeoDataFrame) -> gpd.GeoDataFrame: + crs = boundaries.crs + boundaries.geometry = boundaries.buffer(-1) + + water = _get_scenario_water(user_scenario_id).to_crs(crs) + user_roads = _get_scenario_roads(user_scenario_id).to_crs(crs) + base_roads = _get_scenario_roads(base_scenario_id).to_crs(crs) + roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) + roads.geometry = close_gaps(roads, 1) + + lines, polygons = preprocess_urban_objects(roads, None, water) + blocks = cut_urban_blocks(boundaries, lines, polygons) + return blocks + +def _get_scenario_info(scenario_id : int) -> tuple[int, int]: + scenario = scenarios.get_scenario(scenario_id) + project_id = scenario['project']['project_id'] + project = projects.get_project(project_id) + base_scenario_id = project['base_scenario']['id'] + return project_id, base_scenario_id + + +def _get_best_functional_zones_source(sources_df : pd.DataFrame) -> tuple[int | None, str | None]: + sources = sources_df['source'].unique() + for source in SOURCES_PRIORITY: + if source in sources: + sources_df = sources_df[sources_df['source'] == source] + year = sources_df.year.max() + return int(year), source + return None, None #FIXME ??? + +def get_scenario_blocks(user_scenario_id : int): + project_id, base_scenario_id = _get_scenario_info(user_scenario_id) + project_boundaries = _get_project_boundaries(project_id) + + crs = project_boundaries.estimate_utm_crs() + project_boundaries = project_boundaries.to_crs(crs) + + return _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries) + +def get_scenario_functional_zones(scenario_id : int) -> gpd.GeoDataFrame: + sources_df = scenarios.get_functional_zones_sources_scenario(scenario_id) + year, source = _get_best_functional_zones_source(sources_df) + functional_zones = scenarios.get_functional_zones_scenario(scenario_id, year, source) + return adapt_functional_zones(functional_zones) + +def get_scenario_buildings(scenario_id : int): + try: + gdf = scenarios.get_physical_objects_scenario(scenario_id, physical_object_type_id=LIVING_BUILDING_POT_ID, centers_only=True) + except: + return None + gdf = adapt_buildings(gdf.reset_index(drop=True)) + crs = gdf.estimate_utm_crs() + return impute_buildings(gdf.to_crs(crs)).to_crs(4326) + +def get_scenario_services(scenario_id : int, service_types : pd.DataFrame): + try: + gdf = scenarios.get_services_scenario(scenario_id, centers_only=True) + except: + return {} + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) + return {st:impute_services(gdf,st) for st,gdf in gdfs.items()} \ No newline at end of file diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 947b001..ee8333f 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,13 +1,9 @@ -import pandas as pd -import geopandas as gpd -from fastapi import HTTPException - from app.dependencies import urban_api_handler -from app.effects_api.constants import const -from app.effects_api.modules.urban_api_gateway import get_project_info +import pandas as pd +from blocksnet.config import service_types_config -async def get_indicators(parent_id: int | None = None, **kwargs): +def get_indicators(parent_id: int | None = None, **kwargs): res = urban_api_handler.get('/api/v1/indicators_by_parent', params={ 'parent_id': parent_id, **kwargs @@ -15,27 +11,127 @@ async def get_indicators(parent_id: int | None = None, **kwargs): return pd.DataFrame(res).set_index('indicator_id') -async def get_service_types(**kwargs): +def get_service_types(**kwargs): res = urban_api_handler.get('/api/v1/service_types', params=kwargs) return pd.DataFrame(res).set_index('service_type_id') -async def get_social_values(**kwargs): +def get_social_values(**kwargs): res = urban_api_handler.get('/api/v1/social_values', params=kwargs) return pd.DataFrame(res).set_index('soc_value_id') -async def get_social_value_service_types(soc_value_id: int, **kwargs): +def get_social_value_service_types(soc_value_id: int, **kwargs): res = urban_api_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) if len(res) == 0: return None return pd.DataFrame(res).set_index('service_type_id') -async def get_service_type_social_values(service_type_id: int, **kwargs): +def get_service_type_social_values(service_type_id: int, **kwargs): res = urban_api_handler.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) if len(res) == 0: return None return pd.DataFrame(res).set_index('soc_value_id') + + +SERVICE_TYPES_MAPPING = { + # basic + 1: 'park', + 21: 'kindergarten', + 22: 'school', + 28: 'polyclinic', + 34: 'pharmacy', + 61: 'cafe', + 66: 'pitch', + 68: None, # спортивный зал + 74: 'playground', + 78: 'police', + # additional + 30: None, # стоматология + 35: 'hospital', + 50: 'museum', + 56: 'cinema', + 57: 'mall', + 59: 'stadium', + 62: 'restaurant', + 63: 'bar', + 77: None, # скейт парк + 79: None, # пожарная станция + 80: 'train_station', + 89: 'supermarket', + 99: None, # пункт выдачи + 100: 'bank', + 107: 'veterinary', + 143: 'sanatorium', + # comfort + 5: 'beach', + 27: 'university', + 36: None, # роддом + 48: 'library', + 51: 'theatre', + 91: 'market', + 93: None, # одежда и обувь + 94: None, # бытовая техника + 95: None, # книжный магазин + 96: None, # детские товары + 97: None, # спортивный магазин + 108: None, # зоомагазин + 110: 'hotel', + 114: 'religion', # религиозный объект + # others + 26: None, # ССУЗ + 32: None, # женская консультация + 39: None, # скорая помощь + 40: None, # травматология + 45: 'recruitment', + 47: 'multifunctional_center', + 55: 'zoo', + 65: 'bakery', + 67: 'swimming_pool', + 75: None, # парк аттракционов + 81: 'train_building', + 82: 'aeroway_terminal', # аэропорт?? + 86: 'bus_station', + 88: 'subway_entrance', + 102: 'lawyer', + 103: 'notary', + 109: 'dog_park', + 111: 'hostel', + 112: None, # база отдыха + 113: None, # памятник +} + +for st_id, st_name in SERVICE_TYPES_MAPPING.items(): + if st_name is None: + continue + assert st_name in service_types_config, f'{st_id}:{st_name} not in config' + + +def _adapt_name(service_type_id: int): + return SERVICE_TYPES_MAPPING.get(service_type_id) + + +def _adapt_infrastructure_weight(data: dict): + return data.get('weight_value', None) + + +def _adapt_social_values(service_type_id: int): + social_values = get_service_type_social_values(service_type_id) + if social_values is None: + return None + else: + return list(social_values.index) + + +def adapt_service_types(service_types_df: pd.DataFrame): + df = service_types_df[['infrastructure_type']].copy() + df['infrastructure_weight'] = service_types_df['properties'].apply(_adapt_infrastructure_weight) + df['name'] = df.apply(lambda s: _adapt_name(s.name), axis=1) + df = df[~df['name'].isna()].copy() + + df['social_values'] = df.apply(lambda s: _adapt_social_values(s.name), axis=1) + + return df[['name', 'infrastructure_type', 'infrastructure_weight', 'social_values']].copy() diff --git a/app/effects_api/modules/services_service.py b/app/effects_api/modules/services_service.py new file mode 100644 index 0000000..169d4b9 --- /dev/null +++ b/app/effects_api/modules/services_service.py @@ -0,0 +1,15 @@ +import geopandas as gpd +import pandas as pd + +def _adapt_service_type(data : dict, service_types : pd.DataFrame) -> int: + service_type_id = int(data['service_type_id']) + if service_type_id in service_types.index: + service_type_name = service_types.loc[service_type_id, 'name'] + return service_type_name + return None + +def adapt_services(buildings_gdf : gpd.GeoDataFrame, service_types : pd.DataFrame) -> dict[int, gpd.GeoDataFrame]: + gdf = buildings_gdf[['geometry', 'capacity']].copy() + gdf['service_type'] = buildings_gdf['service_type'].apply(lambda st : _adapt_service_type(st, service_types)) + gdf = gdf[~gdf['service_type'].isna()].copy() + return {st:gdf[gdf['service_type']==st].drop(columns=['service_type']) for st in sorted(gdf['service_type'].unique())} \ No newline at end of file diff --git a/app/effects_api/modules/urban_api_gateway.py b/app/effects_api/modules/urban_api_gateway.py index f788beb..d8c3d73 100644 --- a/app/effects_api/modules/urban_api_gateway.py +++ b/app/effects_api/modules/urban_api_gateway.py @@ -10,217 +10,62 @@ from app.effects_api.constants import const from app.effects_api.models import effects_models as em - -def get_zones(scenario_id: int, token, is_context: bool = False) -> gpd.GeoDataFrame: - """ - - Args: - scenario_id (int): scenario id - - Returns: - gpd.GeoDataFrame: geodataframe with zones - - """ - - def _form_source_params(sources: list[dict]) -> dict: - source_names = [i["source"] for i in sources] - source_data_df = pd.DataFrame(sources) - if "PZZ" in source_names: - return source_data_df.loc[ - source_data_df[source_data_df["source"] == "PZZ"]["year"].idxmax() - ].to_dict() - elif "OSM" in source_names: - return source_data_df.loc[ - source_data_df[source_data_df["source"] == "OSM"]["year"].idxmax() - ].to_dict() - elif "User" in source_names: - return source_data_df.loc[ - source_data_df[source_data_df["source"] == "User"]["year"].idxmax() - ].to_dict() - else: - raise http_exception(404, "Source type not found for given ID", scenario_id) - - zones_sources = urban_api_handler.get( - url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zone_sources", - ) - zones_params_request = _form_source_params(zones_sources.json()) - - if is_context: - project_id = get_project_info(scenario_id, token)["project_id"] - target_zones = urban_api_handler.get( - url=f"{const.URBAN_API}/api/v1/projects/{project_id}/context/functional_zones", - params=zones_params_request,) - else: - target_zones = urban_api_handler.get( - url=f"{const.URBAN_API}/api/v1/scenarios/{scenario_id}/functional_zones", - params=zones_params_request, - ) - target_zones_gdf = gpd.GeoDataFrame.from_features(target_zones.json(), crs=4326) - target_zones_gdf["functional_zone"] = target_zones_gdf["functional_zone_type"].apply(lambda x: x.get("name")) - return target_zones_gdf - -def get_scenarios_by_project_id(project_id: int, token: str) -> dict: - res = urban_api_handler.get(const.URBAN_API + f'/api/v1/projects/{project_id}/scenarios', - headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - -def get_project_geometry(project_id: int, token: str): - """ - Fetches the territory information for a project. - - Parameters: - project_id (int): ID of the project. - - Returns: - dict: Territory information. - """ - endpoint = f"/api/v1/projects/{project_id}/territory" - - response = urban_api_handler.get(endpoint, headers = { - "Authorization": f"Bearer {token}""" - }) - - if not response: - raise http_exception(404, f"No territory information found for project ID:", project_id) - - return response - -def get_scenario(scenario_id : int, token: str) -> dict: - res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}', - headers={'Authorization': f'Bearer {token}'}) +#TODO context +# def get_physical_objects(project_id : int, **kwargs) -> dict: +# res = api.get(f'/api/v1/projects/{project_id}/context/physical_objects_with_geometry', params=kwargs) +# features = res['features'] +# return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('physical_object_id') +# +# def get_services(project_id : int, **kwargs) -> dict: +# res = api.get(f'/api/v1/projects/{project_id}/context/services_with_geometry', params=kwargs) +# features = res['features'] +# return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('service_id') +# +# def get_functional_zones_sources(project_id : int): +# res = api.get(f'/api/v1/projects/{project_id}/context/functional_zone_sources') +# return pd.DataFrame(res) +# +# def get_functional_zones(project_id : int, year : int, source : int): +# res = api.get(f'/api/v1/projects/{project_id}/context/functional_zones', params={ +# 'year': year, +# 'source': source +# }) +# features = res['features'] +# return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('functional_zone_id') +# +# def get_project(project_id : int) -> dict: +# res = api.get(f'/api/v1/projects/{project_id}') +# return res +# +# def get_project_geometry(project_id : int): +# res = api.get(f'/api/v1/projects/{project_id}/territory') +# geometry_json = json.dumps(res['geometry']) +# return shapely.from_geojson(geometry_json) + +#TODO scenario +def get_scenario(scenario_id : int) -> dict: + res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}') return res +def get_functional_zones_sources_scenario(scenario_id : int): + res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/functional_zone_sources') + return pd.DataFrame(res) -def get_based_scenario_id(project_info, token): - scenarios = get_scenarios_by_project_id(project_info['project_id'], token) - based_scenario_id = list(filter(lambda x: x['is_based'], scenarios))[0]['scenario_id'] - return based_scenario_id - - -def _get_scenario_objects( - scenario_id: int, - token: str, - is_context: bool, - project_id: int = None, - physical_object_type_id: int | None = None, - service_type_id: int | None = None, - physical_object_function_id: int | None = None, - urban_function_id: int | None = None, -): - headers = {'Authorization': f'Bearer {token}'} - if is_context: - url = const.URBAN_API + f'/api/v1/projects/{project_id}/context/geometries_with_all_objects' - else: - url = const.URBAN_API + f'/api/v1/scenarios/{scenario_id}/geometries_with_all_objects' - res = urban_api_handler.get(url, params={ - 'physical_object_type_id': physical_object_type_id, - 'service_type_id': service_type_id, - 'physical_object_function_id': physical_object_function_id, - 'urban_function_id': urban_function_id - }, headers=headers) - return res.json() - - -def get_physical_objects(scenario_id: int, token: str, is_context: bool, *args, **kwargs) -> gpd.GeoDataFrame: - if is_context: - project_id = get_project_info(scenario_id, token)["project_id"] - raw = _get_scenario_objects( - scenario_id, token, is_context, project_id, *args, **kwargs - ) - else: - raw = _get_scenario_objects( - scenario_id, token, is_context, *args, **kwargs - ) - - if isinstance(raw, dict): - collections = [raw] - else: - collections = raw # type: ignore - - features: list[dict[str, any]] = [ - feature - for collection in collections - for feature in collection.get("features", []) - ] +def get_functional_zones_scenario(scenario_id : int, year : int, source : int): + res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/functional_zones', params={ + 'year': year, + 'source': source + }) + features = res['features'] + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('functional_zone_id') +def get_physical_objects_scenario(scenario_id : int, **kwargs): + res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry', params=kwargs) + features = res['features'] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('physical_object_id') - -def get_services(project_scenario_id: int, token: str, is_context: bool) -> gpd.GeoDataFrame: - if is_context: - project_id = get_project_info(project_scenario_id, token)["project_id"] - res = urban_api_handler.get(f'/api/v1/projects/{project_id}/context/services_with_geometry', - headers={'Authorization': f'Bearer {token}'}) - else: - res = urban_api_handler.get(f'/api/v1/scenarios/{project_scenario_id}/services_with_geometry', - headers={'Authorization': f'Bearer {token}'}) +def get_services_scenario(scenario_id : int, **kwargs) -> dict: + res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/services_with_geometry', params=kwargs) features = res['features'] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('service_id') - -def get_physical_object_types(): - res = urban_api_handler.get(const.URBAN_API + f'/api/v1/physical_object_types', verify=False) - return res.json() - - -def _get_scenario_by_id(scenario_id: int, token: str) -> dict: - res = urban_api_handler.get(const.URBAN_API + f'/api/v1/scenarios/{scenario_id}', - headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - - -def _get_project_territory_by_id(project_id: int, token: str) -> dict: - res = urban_api_handler.get(const.URBAN_API + f'/api/v1/projects/{project_id}/territory', - headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - - -def _get_project_by_id(project_id: int, token: str) -> dict: - res = urban_api_handler.get(const.URBAN_API + f'/api/v1/projects/{project_id}', - headers={'Authorization': f'Bearer {token}'}) - res.raise_for_status() - return res.json() - - -def _get_territory_by_id(territory_id: int) -> dict: - res = urban_api_handler.get(const.URBAN_API + f'/api/v1/territory/{territory_id}') - res.raise_for_status() - return res.json() - - -def _get_context_geometry(territories_ids: list[int]): - geometries = [] - for territory_id in territories_ids: - territory = _get_territory_by_id(territory_id) - geom_json = json.dumps(territory['geometry']) - geometry = shapely.from_geojson(geom_json) - geometries.append(geometry) - return shapely.unary_union(geometries) - - -def get_project_info(project_scenario_id: int, token: str) -> dict: - """ - Fetch project data - """ - scenario_info = _get_scenario_by_id(project_scenario_id, token) - is_based = scenario_info['is_based'] - project_id = scenario_info['project']['project_id'] - - project_info = _get_project_by_id(project_id, token) - context_ids = project_info['properties']['context'] - - project_territory = _get_project_territory_by_id(project_id, token) - region_id = project_territory['project']['region']['id'] - project_geometry = json.dumps(project_territory['geometry']) - - return { - 'project_id': project_id, - 'region_id': region_id, - 'is_based': is_based, - 'geometry': shapely.from_geojson(project_geometry), - 'context': _get_context_geometry(context_ids) - } - From 651ae541dbfbd0fa79ba20973a3477dc5556d7df Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Jun 2025 00:00:15 +0300 Subject: [PATCH 006/161] refactor(structure): - started structure revision --- app/common/api_handlers/__init__.py | 0 .../json_api_handler.py} | 27 ++++++++++++++---- app/common/auth/__init__.py | 0 app/common/{ => auth}/auth.py | 2 +- app/common/decorators.py | 2 +- .../exceptions/http_exception_wrapper.py | 2 +- app/dependencies.py | 4 +-- app/effects_api/constants/const.py | 22 -------------- app/effects_api/dto/__init__.py | 0 app/effects_api/effects_controller.py | 5 ---- app/effects_api/effects_service.py | 0 app/main.py | 18 ++---------- requirements.txt | Bin 248 -> 378 bytes 13 files changed, 28 insertions(+), 54 deletions(-) create mode 100644 app/common/api_handlers/__init__.py rename app/common/{api_handler.py => api_handlers/json_api_handler.py} (91%) create mode 100644 app/common/auth/__init__.py rename app/common/{ => auth}/auth.py (93%) create mode 100644 app/effects_api/dto/__init__.py create mode 100644 app/effects_api/effects_service.py diff --git a/app/common/api_handlers/__init__.py b/app/common/api_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/api_handler.py b/app/common/api_handlers/json_api_handler.py similarity index 91% rename from app/common/api_handler.py rename to app/common/api_handlers/json_api_handler.py index 2173412..5ea23cf 100644 --- a/app/common/api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -3,7 +3,7 @@ from app.common.exceptions.http_exception_wrapper import http_exception -class APIHandler: +class JSONAPIHandler: def __init__( self, @@ -56,6 +56,25 @@ async def _check_response_status( _detail=await response.json(), ) + @staticmethod + async def check_request_params( + params: dict[str, str | int | float | bool] | None, + ) -> dict | None: + """ + Function checks request parameters + Args: + params (dict[str, str | int | float | bool] | None): Request parameters + Returns: + dict | None: Returns modified parameters if they are not empty, otherwise returns None + """ + + if params: + for key, param in params.items(): + if isinstance(param, bool): + params[key] = {True: "true", False: "false"}[param] + return params + return params + async def get( self, endpoint_url: str, @@ -64,7 +83,6 @@ async def get( session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to get data from api - Args: endpoint_url (str): Endpoint url headers (dict | None): Headers @@ -115,7 +133,6 @@ async def post( session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api - Args: endpoint_url (str): Endpoint url headers (dict | None): Headers @@ -144,7 +161,7 @@ async def post( ) as response: result = await self._check_response_status(response) if not result: - return await self.post( + return await self.post( endpoint_url=endpoint_url, headers=headers, params=params, @@ -161,7 +178,6 @@ async def put( session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api - Args: endpoint_url (str): Endpoint url headers (dict | None): Headers @@ -207,7 +223,6 @@ async def delete( session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api - Args: endpoint_url (str): Endpoint url headers (dict | None): Headers diff --git a/app/common/auth/__init__.py b/app/common/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/auth.py b/app/common/auth/auth.py similarity index 93% rename from app/common/auth.py rename to app/common/auth/auth.py index 79e8c6b..38853ac 100644 --- a/app/common/auth.py +++ b/app/common/auth/auth.py @@ -21,4 +21,4 @@ def _get_token_from_header(credentials: HTTPAuthorizationCredentials) -> str: return token async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(http_bearer)): - return _get_token_from_header(credentials) \ No newline at end of file + return _get_token_from_header(credentials) diff --git a/app/common/decorators.py b/app/common/decorators.py index c2d6254..75e2752 100644 --- a/app/common/decorators.py +++ b/app/common/decorators.py @@ -44,4 +44,4 @@ def process(*args, **kwargs): gdf = func(*args, **kwargs).to_crs(4326) # gdf.geometry = set_precision(gdf.geometry, grid_size=PRECISION_GRID_SIZE) return json.loads(gdf.to_json()) - return process \ No newline at end of file + return process diff --git a/app/common/exceptions/http_exception_wrapper.py b/app/common/exceptions/http_exception_wrapper.py index 22ac213..ce9718e 100644 --- a/app/common/exceptions/http_exception_wrapper.py +++ b/app/common/exceptions/http_exception_wrapper.py @@ -9,4 +9,4 @@ def http_exception(status_code: int, msg: str, _input=None, _detail=None) -> HTT "input": _input, "detail": _detail } - ) \ No newline at end of file + ) diff --git a/app/dependencies.py b/app/dependencies.py index 22dfef4..efcaa8c 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -3,7 +3,7 @@ from loguru import logger from iduconfig import Config -from app.common.api_handler import APIHandler +from app.common.api_handlers.json_api_handler import JSONAPIHandler logger.remove() logger.add(sys.stderr, level="INFO") @@ -24,4 +24,4 @@ level="INFO", ) -urban_api_handler = APIHandler(config.get("URBAN_API")) +urban_api_handler = JSONAPIHandler(config.get("URBAN_API")) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index fb91531..e8f8e67 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -1,28 +1,6 @@ -import os -from iduconfig import Config from blocksnet.enums import LandUse -config = Config() - -API_TITLE = 'Effects API' -API_DESCRIPTION = 'API for assessing territory transformation effects' -EVALUATION_RESPONSE_MESSAGE = 'Evaluation started' -DEFAULT_CRS = 4326 -NORMATIVES_YEAR = 2024 - -if config.get("DATA_PATH"): - DATA_PATH = os.path.abspath('data') -else: - # DATA_PATH = 'app/data' - raise Exception('No DATA_PATH in env file') -if config.get("URBAN_API"): - URBAN_API = config.get("URBAN_API") -else: - # URBAN_API = 'http://10.32.1.107:5300' - raise Exception('No URBAN_API in env file') - - LU_SHARE = 0.5 SQ_M_IN_HA = 10_000 diff --git a/app/effects_api/dto/__init__.py b/app/effects_api/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index b36084d..4332c8f 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -14,11 +14,6 @@ router = APIRouter(prefix='/effects', tags=['Effects']) -def on_startup(): # TODO оценка базовых сценариев - if not os.path.exists(const.DATA_PATH): - logger.info(f'Creating data folder at {const.DATA_PATH}') - os.mkdir(const.DATA_PATH) - tasks: dict[int, TaskSchema] = {} def check_task_evaluation(scenario_id: int) -> None: diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py new file mode 100644 index 0000000..e69de29 diff --git a/app/main.py b/app/main.py index 54559d1..9e4d64a 100644 --- a/app/main.py +++ b/app/main.py @@ -2,30 +2,19 @@ from app.effects_api import effects_controller from app.effects_api.schemas.task_schema import TaskSchema -from app.effects_api.constants.const import API_DESCRIPTION, API_TITLE from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse -controllers = [effects_controller] - -async def on_startup(): - for c in controllers: - c.on_startup() - -async def on_shutdown(): - ... @asynccontextmanager async def lifespan(router : FastAPI): - await on_startup() yield - await on_shutdown() app = FastAPI( - title=API_TITLE, - description=API_DESCRIPTION, + title="Effects API", + description="API for calculating effects of territory transformation with BlocksNet library", lifespan=lifespan ) @@ -50,6 +39,3 @@ def get_tasks() -> dict[int, TaskSchema]: @app.get('/task_status', tags=['Tasks']) def get_task_status(task_id : int) -> TaskSchema: return effects_controller.tasks[task_id] - -for controller in controllers: - app.include_router(controller.router) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b7cf539b68c197ec86e6a5d525bfbe97f16b4a8f..13038d19e837a558ee81ddda0e4c38d27a2b932f 100644 GIT binary patch literal 378 zcmZ9IOA5k35JcZv@Fz5 zCNwb=k&G%JE8Gc-I-$Bng1sk!mmr}d4#C0rn@u!q1_j|8TcQrcB literal 248 zcmZ9HQ4Yc&5JczO#G};c0X$5#Xs``bT5P<$^)09$hAauQZ)Vx`b<&`yvwBSma+-DI z9JJH^QyuDq*H#-{)WXf&qkK`!eN|SXj2QJ)M?XM$avQo1=7uk#8k11&@UCP{KVM5H nK`lkeOtplFR(+o2OrmLlzj(4O&*q0ew(hbNcTexTSKaIl4Xi7h From 299a6a9ecf8707a4440c391bde46744998b65ec6 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Fri, 27 Jun 2025 00:44:10 +0300 Subject: [PATCH 007/161] refactor(WIP_3): schematic for development parameters evaluation --- app/effects_api/effects_controller.py | 36 + app/effects_api/modules/blocksnet_service.py | 630 +++++++++--------- app/effects_api/modules/buildings_service.py | 2 +- app/effects_api/modules/context_service.py | 99 ++- app/effects_api/modules/f22_service.py | 92 +++ .../modules/functional_sources_service.py | 2 +- app/effects_api/modules/scenario_service.py | 98 +-- .../modules/service_type_service.py | 200 +++--- app/effects_api/modules/urban_api_gateway.py | 194 ++++-- 9 files changed, 779 insertions(+), 574 deletions(-) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index b36084d..27022c1 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -1,3 +1,4 @@ +import json import os from datetime import datetime from typing import Annotated @@ -5,10 +6,15 @@ from loguru import logger # from blocksnet.models import ServiceType from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from starlette.responses import JSONResponse +import geopandas as gpd + from app.effects_api.constants import const from app.common import auth, decorators from app.effects_api.models import effects_models as em from app.effects_api.modules import effects_service as es, service_type_service as sts +from app.effects_api.modules.f22_service import run_development_parameters +from app.effects_api.modules.scenario_service import get_scenario_functional_zones from app.effects_api.schemas.task_schema import TaskSchema, TaskStatusSchema, TaskInfoSchema from app.effects_api.modules.task_api_service import get_scenario_info, get_all_project_info, get_project_id @@ -150,6 +156,36 @@ def evaluate(background_tasks: BackgroundTasks, project_scenario_id: int, token: background_tasks.add_task(_evaluate_master_plan_task, project_scenario_id, token, is_context=is_context) return {'task_id': project_scenario_id} +#ручка для теста, можно убрать +@router.get('/get_scenario_zones/{scenario_id}') +async def get_scenario_zones(scenario_id: int): + zones: gpd.GeoDataFrame = await get_scenario_functional_zones(scenario_id) + geojson_str = zones.to_json() + geojson_dict = json.loads(geojson_str) + return JSONResponse(content=geojson_dict, media_type="application/geo+json") + + +# @router.post('/socio_economic_effects/{scenario_id}') +# def evaluate_socio_economic_effects( +# scenario_id: int, +# token: str = Depends(auth.verify_token), +# functional_zone_source: str, +# functional_zone_year: int, +# context_functional_zone_source: str, +# context_functional_zone_year: int +# +# ): +# + +@router.get('/get_development_parameters/{scenario_id}') +async def get_development_parameters(scenario_id: int): + development_parameters: gpd.GeoDataFrame = await run_development_parameters(scenario_id) + geojson_str = development_parameters.to_json() + geojson_dict = json.loads(geojson_str) + return JSONResponse(content=geojson_dict, media_type="application/geo+json") + + + @router.delete('/evaluation') def delete_evaluation(project_scenario_id : int): try: diff --git a/app/effects_api/modules/blocksnet_service.py b/app/effects_api/modules/blocksnet_service.py index 8b21f57..ca97f54 100644 --- a/app/effects_api/modules/blocksnet_service.py +++ b/app/effects_api/modules/blocksnet_service.py @@ -1,316 +1,316 @@ -import geopandas as gpd -import pandas as pd -from blocksnet.blocks.aggregation import aggregate_objects -from blocksnet.blocks.assignment import assign_land_use -from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects -from blocksnet.relations import get_accessibility_graph, calculate_accessibility_matrix -from loguru import logger - - -from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.constants import const -from app.effects_api.constants.const import mapping -from app.effects_api.modules.service_type_service import get_zones -from app.effects_api.modules.urban_api_gateway import get_scenario_objects - -SPEED_M_MIN = 60 * 1000 / 60 -GAP_TOLERANCE = 5 - -def _get_geoms_by_function(function_name, physical_object_types, scenario_gdf): - valid_type_ids = { - d['physical_object_type_id'] - for d in physical_object_types - if function_name in d['physical_object_function']['name'] - } - return scenario_gdf[scenario_gdf['physical_objects'].apply( - lambda x: any(d.get('physical_object_type').get('id') in valid_type_ids for d in x))] - -def _get_water(scenario_gdf, physical_object_types): - water = _get_geoms_by_function('Водный объект', physical_object_types, scenario_gdf) - water = water.explode(index_parts=True) - water = water.reset_index() - return water - -import shapely -import numpy as np - -def close_gaps(gdf, tolerance): # taken from momepy - geom = gdf.geometry.array - coords = shapely.get_coordinates(geom) - indices = shapely.get_num_coordinates(geom) - - edges = [0] - i = 0 - for ind in indices: - ix = i + ind - edges.append(ix - 1) - edges.append(ix) - i = ix - edges = edges[:-1] - points = shapely.points(np.unique(coords[edges], axis=0)) - - buffered = shapely.buffer(points, tolerance / 2) - - dissolved = shapely.union_all(buffered) - - exploded = [ - shapely.get_geometry(dissolved, i) - for i in range(shapely.get_num_geometries(dissolved)) - ] - - centroids = shapely.centroid(exploded) - - snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) - - return gpd.GeoSeries(snapped, crs=gdf.crs) - -def _get_roads(scenario_gdf, physical_object_types): - roads = _get_geoms_by_function('Дорога', physical_object_types, scenario_gdf) - merged = roads.unary_union - if merged.geom_type == 'MultiLineString': - roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) - else: - roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) - roads = roads.explode(index_parts=False).reset_index(drop=True) - roads.geometry = close_gaps(roads, GAP_TOLERANCE) - roads = roads[roads.geom_type.isin(['LineString'])] - return roads - -def _get_geoms_by_object_type_id(scenario_gdf, object_type_id): - return scenario_gdf[scenario_gdf['physical_objects'].apply(lambda x: any(d.get('physical_object_type').get('id') == object_type_id for d in x))] - -def _get_buildings(scenario_gdf, physical_object_types): - LIVING_BUILDINGS_ID = 4 - NON_LIVING_BUILDINGS_ID = 5 - living_building = _get_geoms_by_object_type_id(scenario_gdf, LIVING_BUILDINGS_ID) - living_building['is_living'] = True - # print(living_building) - non_living_buildings = _get_geoms_by_object_type_id(scenario_gdf, NON_LIVING_BUILDINGS_ID) - non_living_buildings['is_living'] = False - - buildings = gpd.GeoDataFrame( pd.concat( [living_building, non_living_buildings], ignore_index=True) ) - # print(buildings) - # buildings = _get_geoms_by_function('Здание', physical_object_types, scenario_gdf) - buildings['number_of_floors'] = 1 - # buildings['is_living'] = True - buildings['footprint_area'] = buildings.geometry.area - buildings['build_floor_area'] = buildings['footprint_area'] * buildings['number_of_floors'] - buildings['living_area'] = buildings.geometry.area - buildings['population'] = 0 - buildings['population'][buildings['is_living']] = 100 - buildings = buildings.reset_index() - buildings = buildings[buildings.geometry.type != 'Point'] - return buildings[['geometry', 'number_of_floors', 'footprint_area', 'build_floor_area', 'living_area', 'population']] - - -def _get_services(scenario_gdf) -> gpd.GeoDataFrame | None: - - def extract_services(row): - if isinstance(row['services'], list) and len(row['services']) > 0: - return [ - { - 'service_id': service['service_id'], - 'service_type_id': service['service_type']['id'], - 'name': service['name'], - 'capacity_real': service['capacity'], - 'geometry': row['geometry'] # Сохраняем геометрию - } - for service in row['services'] - if service.get('capacity') is not None and service['capacity'] > 0 - ] - return [] - - extracted_data = [] - for _, row in scenario_gdf.iterrows(): - extracted_data.extend(extract_services(row)) - - if len(extracted_data) == 0: - raise http_exception(404, f'No services found to extract') - - services_gdf = gpd.GeoDataFrame(extracted_data, crs=scenario_gdf.crs) - - services_gdf['capacity'] = services_gdf['capacity_real'] - services_gdf = services_gdf[['geometry', 'service_id', 'service_type_id', 'name', 'capacity']] - - services_gdf['area'] = services_gdf.geometry.area - services_gdf['area'] = services_gdf['area'].apply(lambda a : a if a > 1 else 1) - # services_gdf.loc[services_gdf.area == 0, 'area'] = 100 - # services_gdf['area'] = services_gdf - - return services_gdf - -def _get_boundaries(project_info : dict, is_context: bool = False) -> gpd.GeoDataFrame: - if is_context == False: - boundaries = gpd.GeoDataFrame(geometry=[project_info['geometry']]) - else: - boundaries = gpd.GeoDataFrame(geometry=[project_info['context']]) - boundaries = boundaries.set_crs(const.DEFAULT_CRS) - local_crs = boundaries.estimate_utm_crs() - return boundaries.to_crs(local_crs) - - -def _generate_blocks(boundaries_gdf : gpd.GeoDataFrame, roads_gdf : gpd.GeoDataFrame, scenario_gdf : gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: - water_gdf = _get_water(scenario_gdf, physical_object_types).to_crs(boundaries_gdf.crs) - lines, polygons = preprocess_urban_objects(roads_gdf, None, water_gdf) - blocks = cut_urban_blocks(boundaries_gdf, lines, polygons) - blocks['land_use'] = None # TODO ЗАмнить на норм land_use?? >> здесь должен быть этап определения лендюза по тому что есть в бд - return blocks - - -def _assign_landuse(project_scenario_id: int, blocks: gpd.GeoDataFrame, is_context: bool, token): - functional_zones = get_zones(project_scenario_id, token, is_context) - functional_zones = functional_zones.to_crs(functional_zones.estimate_utm_crs()) - functional_zones = functional_zones[["geometry", "functional_zone"]] - blocks = assign_land_use(blocks, functional_zones, mapping) - return blocks - - -def _calculate_acc_mx(boundaries : gpd.GeoDataFrame, blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: - graph = get_accessibility_graph(boundaries, 'intermodal') - accessibility_matrix = calculate_accessibility_matrix(blocks_gdf, graph) - return accessibility_matrix - - -def _update_buildings(scenario_gdf: gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: - buildings_gdf = _get_buildings(scenario_gdf, physical_object_types) - buildings_gdf = buildings_gdf[buildings_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] - # buildings_gdf = impute_buildings(buildings_gdf, default_living_demand=30) - return buildings_gdf - - - - -# def _update_services(city : City, service_types : list[ServiceType], scenario_gdf : gpd.GeoDataFrame) -> None: -# # reset service types -# city._service_types = {} -# for st in service_types: -# city.add_service_type(st) -# # filter services and add to the model if exist +# import geopandas as gpd +# import pandas as pd +# from blocksnet.blocks.aggregation import aggregate_objects +# from blocksnet.blocks.assignment import assign_land_use +# from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects +# from blocksnet.relations import get_accessibility_graph, calculate_accessibility_matrix +# from loguru import logger +# +# +# from app.common.exceptions.http_exception_wrapper import http_exception +# from app.effects_api.constants import const +# from app.effects_api.constants.const import mapping +# # from app.effects_api.modules.service_type_service import get_zones +# # from app.effects_api.modules.urban_api_gateway import get_scenario_objects +# +# SPEED_M_MIN = 60 * 1000 / 60 +# GAP_TOLERANCE = 5 +# +# def _get_geoms_by_function(function_name, physical_object_types, scenario_gdf): +# valid_type_ids = { +# d['physical_object_type_id'] +# for d in physical_object_types +# if function_name in d['physical_object_function']['name'] +# } +# return scenario_gdf[scenario_gdf['physical_objects'].apply( +# lambda x: any(d.get('physical_object_type').get('id') in valid_type_ids for d in x))] +# +# def _get_water(scenario_gdf, physical_object_types): +# water = _get_geoms_by_function('Водный объект', physical_object_types, scenario_gdf) +# water = water.explode(index_parts=True) +# water = water.reset_index() +# return water +# +# import shapely +# import numpy as np +# +# def close_gaps(gdf, tolerance): # taken from momepy +# geom = gdf.geometry.array +# coords = shapely.get_coordinates(geom) +# indices = shapely.get_num_coordinates(geom) +# +# edges = [0] +# i = 0 +# for ind in indices: +# ix = i + ind +# edges.append(ix - 1) +# edges.append(ix) +# i = ix +# edges = edges[:-1] +# points = shapely.points(np.unique(coords[edges], axis=0)) +# +# buffered = shapely.buffer(points, tolerance / 2) +# +# dissolved = shapely.union_all(buffered) +# +# exploded = [ +# shapely.get_geometry(dissolved, i) +# for i in range(shapely.get_num_geometries(dissolved)) +# ] +# +# centroids = shapely.centroid(exploded) +# +# snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) +# +# return gpd.GeoSeries(snapped, crs=gdf.crs) +# +# def _get_roads(scenario_gdf, physical_object_types): +# roads = _get_geoms_by_function('Дорога', physical_object_types, scenario_gdf) +# merged = roads.unary_union +# if merged.geom_type == 'MultiLineString': +# roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) +# else: +# roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) +# roads = roads.explode(index_parts=False).reset_index(drop=True) +# roads.geometry = close_gaps(roads, GAP_TOLERANCE) +# roads = roads[roads.geom_type.isin(['LineString'])] +# return roads +# +# def _get_geoms_by_object_type_id(scenario_gdf, object_type_id): +# return scenario_gdf[scenario_gdf['physical_objects'].apply(lambda x: any(d.get('physical_object_type').get('id') == object_type_id for d in x))] +# +# def _get_buildings(scenario_gdf, physical_object_types): +# LIVING_BUILDINGS_ID = 4 +# NON_LIVING_BUILDINGS_ID = 5 +# living_building = _get_geoms_by_object_type_id(scenario_gdf, LIVING_BUILDINGS_ID) +# living_building['is_living'] = True +# # print(living_building) +# non_living_buildings = _get_geoms_by_object_type_id(scenario_gdf, NON_LIVING_BUILDINGS_ID) +# non_living_buildings['is_living'] = False +# +# buildings = gpd.GeoDataFrame( pd.concat( [living_building, non_living_buildings], ignore_index=True) ) +# # print(buildings) +# # buildings = _get_geoms_by_function('Здание', physical_object_types, scenario_gdf) +# buildings['number_of_floors'] = 1 +# # buildings['is_living'] = True +# buildings['footprint_area'] = buildings.geometry.area +# buildings['build_floor_area'] = buildings['footprint_area'] * buildings['number_of_floors'] +# buildings['living_area'] = buildings.geometry.area +# buildings['population'] = 0 +# buildings['population'][buildings['is_living']] = 100 +# buildings = buildings.reset_index() +# buildings = buildings[buildings.geometry.type != 'Point'] +# return buildings[['geometry', 'number_of_floors', 'footprint_area', 'build_floor_area', 'living_area', 'population']] +# +# +# def _get_services(scenario_gdf) -> gpd.GeoDataFrame | None: +# +# def extract_services(row): +# if isinstance(row['services'], list) and len(row['services']) > 0: +# return [ +# { +# 'service_id': service['service_id'], +# 'service_type_id': service['service_type']['id'], +# 'name': service['name'], +# 'capacity_real': service['capacity'], +# 'geometry': row['geometry'] # Сохраняем геометрию +# } +# for service in row['services'] +# if service.get('capacity') is not None and service['capacity'] > 0 +# ] +# return [] +# +# extracted_data = [] +# for _, row in scenario_gdf.iterrows(): +# extracted_data.extend(extract_services(row)) +# +# if len(extracted_data) == 0: +# raise http_exception(404, f'No services found to extract') +# +# services_gdf = gpd.GeoDataFrame(extracted_data, crs=scenario_gdf.crs) +# +# services_gdf['capacity'] = services_gdf['capacity_real'] +# services_gdf = services_gdf[['geometry', 'service_id', 'service_type_id', 'name', 'capacity']] +# +# services_gdf['area'] = services_gdf.geometry.area +# services_gdf['area'] = services_gdf['area'].apply(lambda a : a if a > 1 else 1) +# # services_gdf.loc[services_gdf.area == 0, 'area'] = 100 +# # services_gdf['area'] = services_gdf +# +# return services_gdf +# +# def _get_boundaries(project_info : dict, is_context: bool = False) -> gpd.GeoDataFrame: +# if is_context == False: +# boundaries = gpd.GeoDataFrame(geometry=[project_info['geometry']]) +# else: +# boundaries = gpd.GeoDataFrame(geometry=[project_info['context']]) +# boundaries = boundaries.set_crs(const.DEFAULT_CRS) +# local_crs = boundaries.estimate_utm_crs() +# return boundaries.to_crs(local_crs) +# +# +# def _generate_blocks(boundaries_gdf : gpd.GeoDataFrame, roads_gdf : gpd.GeoDataFrame, scenario_gdf : gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: +# water_gdf = _get_water(scenario_gdf, physical_object_types).to_crs(boundaries_gdf.crs) +# lines, polygons = preprocess_urban_objects(roads_gdf, None, water_gdf) +# blocks = cut_urban_blocks(boundaries_gdf, lines, polygons) +# blocks['land_use'] = None # TODO ЗАмнить на норм land_use?? >> здесь должен быть этап определения лендюза по тому что есть в бд +# return blocks +# +# +# def _assign_landuse(project_scenario_id: int, blocks: gpd.GeoDataFrame, is_context: bool, token): +# functional_zones = get_zones(project_scenario_id, token, is_context) +# functional_zones = functional_zones.to_crs(functional_zones.estimate_utm_crs()) +# functional_zones = functional_zones[["geometry", "functional_zone"]] +# blocks = assign_land_use(blocks, functional_zones, mapping) +# return blocks +# +# +# def _calculate_acc_mx(boundaries : gpd.GeoDataFrame, blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: +# graph = get_accessibility_graph(boundaries, 'intermodal') +# accessibility_matrix = calculate_accessibility_matrix(blocks_gdf, graph) +# return accessibility_matrix +# +# +# def _update_buildings(scenario_gdf: gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: +# buildings_gdf = _get_buildings(scenario_gdf, physical_object_types) +# buildings_gdf = buildings_gdf[buildings_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] +# # buildings_gdf = impute_buildings(buildings_gdf, default_living_demand=30) +# return buildings_gdf +# +# +# +# +# # def _update_services(city : City, service_types : list[ServiceType], scenario_gdf : gpd.GeoDataFrame) -> None: +# # # reset service types +# # city._service_types = {} +# # for st in service_types: +# # city.add_service_type(st) +# # # filter services and add to the model if exist +# # services_gdf = _get_services(scenario_gdf) +# # if services_gdf is None: +# # return +# # services_gdf = services_gdf.to_crs(city.crs).copy() +# # service_type_dict = {service.code: service for service in service_types} +# # for service_type_code, st_gdf in services_gdf.groupby('service_type_id'): +# # gdf = st_gdf.copy().to_crs(city.crs) +# # gdf.geometry = gdf.representative_point() +# # service_type = service_type_dict.get(str(service_type_code), None) +# # if service_type is not None: +# # city.update_services(service_type, gdf) +# +# def _update_services(scenario_gdf : gpd.GeoDataFrame) -> gpd.GeoDataFrame: # services_gdf = _get_services(scenario_gdf) -# if services_gdf is None: -# return -# services_gdf = services_gdf.to_crs(city.crs).copy() -# service_type_dict = {service.code: service for service in service_types} -# for service_type_code, st_gdf in services_gdf.groupby('service_type_id'): -# gdf = st_gdf.copy().to_crs(city.crs) -# gdf.geometry = gdf.representative_point() -# service_type = service_type_dict.get(str(service_type_code), None) -# if service_type is not None: -# city.update_services(service_type, gdf) - -def _update_services(scenario_gdf : gpd.GeoDataFrame) -> gpd.GeoDataFrame: - services_gdf = _get_services(scenario_gdf) - services_gdf = services_gdf[services_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] - return services_gdf - -# ToDo handle no service case - - - - # def _update_non_residential_block(block: Block): - # for building in block.buildings: - # building.population = 0 - # - # def _update_residential_block(block: Block, pop_per_ha: float, service_types: list[ServiceType]): - # pop_per_m2 = pop_per_ha / SQ_M_IN_HA - # area = block.site_area - # population = round(pop_per_m2 * area) - # # удаляем здания и сервисы - # block.buildings = [] - # block.services = [] - # # добавляем dummy здание и даем ему наше население - # dummy_building = Building( - # block=block, - # geometry=block.geometry.buffer(-0.01), - # population=population, - # **const.DUMMY_BUILDING_PARAMS - # ) - # block.buildings.append(dummy_building) - # # добавляем по каждому типу сервиса большой dummy_service - # for service_type in service_types: - # capacity = service_type.calculate_in_need(population) - # dummy_service = BlockService( - # service_type=service_type, - # capacity=capacity, - # is_integrated=False, - # block=block, - # geometry=block.geometry.representative_point().buffer(0.01), - # ) - # block.services.append(dummy_service) - # - # def _update_block(block: Block, zone: str, service_types: list[ServiceType]): - # if zone in const.residential_mapping: # если квартал жилой - # pop_min, pop_max = const.residential_mapping[zone] - # _update_residential_block(block, random.randint(pop_min, pop_max), service_types) - # else: - # _update_non_residential_block(block) - # - # def update_blocks(city: City, blocks_with_lu: gpd.GeoDataFrame, service_types: list[ServiceType]): - # for block_id, row in blocks_with_lu.iterrows(): - # zone = row['zone'] - # block = city[block_id] - # _update_block(block, zone, service_types) - # - # LU_SHARE = 0.5 - # SQ_M_IN_HA = 10_000 - # zones = _process_zones(zones) - # blocks = city.get_blocks_gdf(True) - # zones.to_crs(blocks.crs, inplace=True) - # blocks_with_lu = _get_blocks_to_process(blocks, zones) - # residential_sts = [city[st_name] for st_name in ['school', 'kindergarten', 'polyclinic'] if st_name in city.services] - # update_blocks(city, blocks_with_lu, residential_sts) - # return city - -# ToDo move zones to preprocessing and pass them to the function - -def fetch_city_model( - project_info: dict, - project_scenario_id: int, - token: str, - # scenario_gdf: gpd.GeoDataFrame, - physical_object_types: dict, - # service_types: list, - is_context: bool = False -): - # getting boundaries for our model - logger.info('Fetching city model') - boundaries_gdf = _get_boundaries(project_info, is_context) - logger.info('boundaries have been fetched') - local_crs = boundaries_gdf.crs - scenario_objects_gdf = get_scenario_objects(project_scenario_id, token, is_context) - # clipping scenario objects - scenario_objects_gdf = scenario_objects_gdf.to_crs(local_crs) - scenario_objects_gdf = scenario_objects_gdf.clip(boundaries_gdf) - logger.info('scenario objects have been fetched') - - roads_gdf = _get_roads(scenario_objects_gdf, physical_object_types) - roads_gdf = roads_gdf.to_crs(local_crs) - logger.info('roads have been fetched') - - # generating blocks layer - blocks_gdf = _generate_blocks(boundaries_gdf, roads_gdf, scenario_objects_gdf, physical_object_types) - blocks_gdf = blocks_gdf.to_crs(local_crs) - logger.info('blocks have been fetched') - blocks_gdf = _assign_landuse(project_scenario_id, blocks_gdf, is_context, token) - logger.info('landuse has been assigned') - # acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) - # logger.info('acc_mx has been calculated') - - - - # initializing city model - # city = City( - # blocks=blocks_gdf, - # acc_mx=acc_mx, - # ) - - # updating buildings layer - buildings_gdf = _update_buildings(scenario_objects_gdf, physical_object_types) - buildings_blocks_gdf = aggregate_objects(blocks_gdf, buildings_gdf)[0] - logger.info('buildings blocks have been aggregated') - - # services_gdf = _update_services(scenario_objects_gdf) - # services_blocks_gdf = aggregate_objects(buildings_blocks_gdf, services_gdf)[0] - # logger.info('services blocks have been aggregated') - - return blocks_gdf, buildings_blocks_gdf +# services_gdf = services_gdf[services_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] +# return services_gdf +# +# # ToDo handle no service case +# +# +# +# # def _update_non_residential_block(block: Block): +# # for building in block.buildings: +# # building.population = 0 +# # +# # def _update_residential_block(block: Block, pop_per_ha: float, service_types: list[ServiceType]): +# # pop_per_m2 = pop_per_ha / SQ_M_IN_HA +# # area = block.site_area +# # population = round(pop_per_m2 * area) +# # # удаляем здания и сервисы +# # block.buildings = [] +# # block.services = [] +# # # добавляем dummy здание и даем ему наше население +# # dummy_building = Building( +# # block=block, +# # geometry=block.geometry.buffer(-0.01), +# # population=population, +# # **const.DUMMY_BUILDING_PARAMS +# # ) +# # block.buildings.append(dummy_building) +# # # добавляем по каждому типу сервиса большой dummy_service +# # for service_type in service_types: +# # capacity = service_type.calculate_in_need(population) +# # dummy_service = BlockService( +# # service_type=service_type, +# # capacity=capacity, +# # is_integrated=False, +# # block=block, +# # geometry=block.geometry.representative_point().buffer(0.01), +# # ) +# # block.services.append(dummy_service) +# # +# # def _update_block(block: Block, zone: str, service_types: list[ServiceType]): +# # if zone in const.residential_mapping: # если квартал жилой +# # pop_min, pop_max = const.residential_mapping[zone] +# # _update_residential_block(block, random.randint(pop_min, pop_max), service_types) +# # else: +# # _update_non_residential_block(block) +# # +# # def update_blocks(city: City, blocks_with_lu: gpd.GeoDataFrame, service_types: list[ServiceType]): +# # for block_id, row in blocks_with_lu.iterrows(): +# # zone = row['zone'] +# # block = city[block_id] +# # _update_block(block, zone, service_types) +# # +# # LU_SHARE = 0.5 +# # SQ_M_IN_HA = 10_000 +# # zones = _process_zones(zones) +# # blocks = city.get_blocks_gdf(True) +# # zones.to_crs(blocks.crs, inplace=True) +# # blocks_with_lu = _get_blocks_to_process(blocks, zones) +# # residential_sts = [city[st_name] for st_name in ['school', 'kindergarten', 'polyclinic'] if st_name in city.services] +# # update_blocks(city, blocks_with_lu, residential_sts) +# # return city +# +# # ToDo move zones to preprocessing and pass them to the function +# +# def fetch_city_model( +# project_info: dict, +# project_scenario_id: int, +# token: str, +# # scenario_gdf: gpd.GeoDataFrame, +# physical_object_types: dict, +# # service_types: list, +# is_context: bool = False +# ): +# # getting boundaries for our model +# logger.info('Fetching city model') +# boundaries_gdf = _get_boundaries(project_info, is_context) +# logger.info('boundaries have been fetched') +# local_crs = boundaries_gdf.crs +# scenario_objects_gdf = get_scenario_objects(project_scenario_id, token, is_context) +# # clipping scenario objects +# scenario_objects_gdf = scenario_objects_gdf.to_crs(local_crs) +# scenario_objects_gdf = scenario_objects_gdf.clip(boundaries_gdf) +# logger.info('scenario objects have been fetched') +# +# roads_gdf = _get_roads(scenario_objects_gdf, physical_object_types) +# roads_gdf = roads_gdf.to_crs(local_crs) +# logger.info('roads have been fetched') +# +# # generating blocks layer +# blocks_gdf = _generate_blocks(boundaries_gdf, roads_gdf, scenario_objects_gdf, physical_object_types) +# blocks_gdf = blocks_gdf.to_crs(local_crs) +# logger.info('blocks have been fetched') +# blocks_gdf = _assign_landuse(project_scenario_id, blocks_gdf, is_context, token) +# logger.info('landuse has been assigned') +# # acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) +# # logger.info('acc_mx has been calculated') +# +# +# +# # initializing city model +# # city = City( +# # blocks=blocks_gdf, +# # acc_mx=acc_mx, +# # ) +# +# # updating buildings layer +# buildings_gdf = _update_buildings(scenario_objects_gdf, physical_object_types) +# buildings_blocks_gdf = aggregate_objects(blocks_gdf, buildings_gdf)[0] +# logger.info('buildings blocks have been aggregated') +# +# # services_gdf = _update_services(scenario_objects_gdf) +# # services_blocks_gdf = aggregate_objects(buildings_blocks_gdf, services_gdf)[0] +# # logger.info('services blocks have been aggregated') +# +# return blocks_gdf, buildings_blocks_gdf diff --git a/app/effects_api/modules/buildings_service.py b/app/effects_api/modules/buildings_service.py index f921b6e..3f43e24 100644 --- a/app/effects_api/modules/buildings_service.py +++ b/app/effects_api/modules/buildings_service.py @@ -53,4 +53,4 @@ def adapt_buildings(buildings_gdf : gpd.GeoDataFrame, living_pot_id : int = 4): for column, rules in BUILDINGS_RULES.items(): series = buildings_gdf['building'].apply(lambda b : _adapt(b, rules)) gdf[column] = pd.to_numeric(series, errors='coerce') - return gdf \ No newline at end of file + return gdf diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index dba3b61..5f3a075 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,66 +1,49 @@ +"""Все методы, которые вызывают ручки по контексту +А также методы, которые собиракют геослои по контексту""" + import geopandas as gpd -import shapely -import numpy as np +import pandas as pd from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks +from blocksnet.preprocessing.imputing import impute_buildings, impute_services -from app.effects_api.modules.urban_api_gateway import get_zones, get_project_geometry - - -def close_gaps(gdf, tolerance): # taken from momepy - geom = gdf.geometry.array - coords = shapely.get_coordinates(geom) - indices = shapely.get_num_coordinates(geom) - - edges = [0] - i = 0 - for ind in indices: - ix = i + ind - edges.append(ix - 1) - edges.append(ix) - i = ix - edges = edges[:-1] - points = shapely.points(np.unique(coords[edges], axis=0)) - - buffered = shapely.buffer(points, tolerance / 2) +from app.effects_api.modules.buildings_service import adapt_buildings +from app.effects_api.modules.functional_sources_service import adapt_functional_zones +from app.effects_api.modules.scenario_service import _get_best_functional_zones_source, close_gaps +from app.effects_api.modules.services_service import adapt_services +from app.effects_api.modules.urban_api_gateway import UrbanAPIGateway - dissolved = shapely.union_all(buffered) - - exploded = [ - shapely.get_geometry(dissolved, i) - for i in range(shapely.get_num_geometries(dissolved)) - ] - - centroids = shapely.centroid(exploded) +LIVING_BUILDING_POT_ID = 4 - snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) - return gpd.GeoSeries(snapped, crs=gdf.crs) +async def _get_project_boundaries(project_id: int): + return gpd.GeoDataFrame(geometry=[await UrbanAPIGateway.get_project_geometry(project_id)], crs=4326) -LIVING_BUILDING_POT_ID = 4 -def _get_project_boundaries(project_id : int): - return gpd.GeoDataFrame(geometry=[projects.get_project_geometry(project_id)], crs=4326) - -def _get_context_boundaries(project_id : int) -> gpd.GeoDataFrame: - project = projects.get_project(project_id) +async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: + project = await UrbanAPIGateway.get_project(project_id) context_ids = project['properties']['context'] - geometries = [territories.get_territory_geometry(territory_id) for territory_id in context_ids] + geometries = [await UrbanAPIGateway.get_territory_geometry(territory_id) for territory_id in context_ids] return gpd.GeoDataFrame(geometry=geometries, crs=4326) -def _get_context_roads(project_id : int): - gdf = projects.get_physical_objects_scenario(project_id, physical_object_function_id=ROADS_POF_ID) + +async def _get_context_roads(project_id: int): + gdf = await UrbanAPIGateway.get_physical_objects_scenario(project_id, physical_object_function_id=26) return gdf[['geometry']].reset_index(drop=True) -def _get_context_water(project_id : int): - gdf = projects.get_physical_objects_scenario(project_id, physical_object_function_id=WATER_POF_ID) + +async def _get_context_water(project_id: int): + gdf = await UrbanAPIGateway.get_physical_objects_scenario(project_id, physical_object_function_id=4) return gdf[['geometry']].reset_index(drop=True) -def _get_context_blocks(project_id : int, boundaries : gpd.GeoDataFrame) -> gpd.GeoDataFrame: + +async def _get_context_blocks(project_id: int, boundaries: gpd.GeoDataFrame) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = _get_context_water(project_id).to_crs(crs) - roads = _get_context_roads(project_id).to_crs(crs) + water = await _get_context_water(project_id) + water = water.to_crs(crs) + roads = await _get_context_roads(project_id) + roads = roads.to_crs(crs) roads.geometry = close_gaps(roads, 1) lines, polygons = preprocess_urban_objects(roads, None, water) @@ -68,9 +51,9 @@ def _get_context_blocks(project_id : int, boundaries : gpd.GeoDataFrame) -> gpd. return blocks -def get_context_blocks(project_id: int, token: str): - project_boundaries = get_project_geometry(project_id, token) - context_boundaries = _get_context_boundaries(project_id) +async def get_context_blocks(project_id: int): + project_boundaries = await UrbanAPIGateway.get_project_geometry(project_id) + context_boundaries = await _get_context_boundaries(project_id) crs = context_boundaries.estimate_utm_crs() context_boundaries = context_boundaries.to_crs(crs) @@ -79,20 +62,22 @@ def get_context_blocks(project_id: int, token: str): context_boundaries = context_boundaries.overlay(project_boundaries, how='difference') return _get_context_blocks(project_id, context_boundaries) -def get_context_functional_zones(project_id : int, token: str) -> gpd.GeoDataFrame: - sources_df = get_zones(project_id, token, is_context=True) + +async def get_context_functional_zones(project_id: int, token: str) -> gpd.GeoDataFrame: + sources_df = await UrbanAPIGateway.get_functional_zones_sources(project_id) year, source = _get_best_functional_zones_source(sources_df) - functional_zones = projects.get_functional_zones_scenario(project_id, year, source) + functional_zones = await UrbanAPIGateway.get_functional_zones_scenario(project_id, year, source) return adapt_functional_zones(functional_zones) -def get_context_buildings(project_id : int): - gdf = projects.get_physical_objects_scenario(project_id, physical_object_type_id=1, centers_only=True) + +async def get_context_buildings(project_id: int): + gdf = await UrbanAPIGateway.get_physical_objects_scenario(project_id, physical_object_type_id=1, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) -def get_context_services(project_id : int, service_types : pd.DataFrame): - gdf = projects.get_services_scenario(project_id, centers_only=True) - gdfs = adapt_services(gdf.reset_index(drop=True), service_types) - return {st:impute_services(gdf,st) for st,gdf in gdfs.items()} +async def get_context_services(project_id: int, service_types: pd.DataFrame): + gdf = await UrbanAPIGateway.get_services_scenario(project_id, centers_only=True) + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) + return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/f22_service.py b/app/effects_api/modules/f22_service.py index e69de29..46fe1d2 100644 --- a/app/effects_api/modules/f22_service.py +++ b/app/effects_api/modules/f22_service.py @@ -0,0 +1,92 @@ +"""Здесь метод который собирает единый слой кварталов для сценраия и метод который считает параметры застройки + https://github.com/vasilstar97/prostor-examples/blob/main/examples/Ф22.ipynb""" + +import pandas as pd +import geopandas as gpd +from loguru import logger +from blocksnet.analysis.indicators import calculate_development_indicators +from blocksnet.blocks.aggregation import aggregate_objects +from blocksnet.blocks.assignment import assign_land_use +from blocksnet.enums import LandUse +from blocksnet.machine_learning.regression import DensityRegressor +from blocksnet.relations import generate_adjacency_graph + +from app.effects_api.modules.functional_sources_service import LAND_USE_RULES +from app.effects_api.modules.scenario_service import get_scenario_blocks, get_scenario_functional_zones, \ + get_scenario_buildings, get_scenario_services +from app.effects_api.modules.service_type_service import get_service_types + + +#собираем единый слой кварталов для сецнария +async def aggregate_blocks_layer_scenario( + scenario_id: int, + token: str = None, + functional_zone_source: str = None, + functional_zone_year: int = None) -> gpd.GeoDataFrame: + logger.info(f"Aggregating blocks layer scenario {scenario_id}") + blocks = await get_scenario_blocks(scenario_id) + blocks_crs = blocks.crs + logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") + logger.info(f"Aggregating functional_zones layer scenario {scenario_id}") + functional_zones = await get_scenario_functional_zones(scenario_id) + functional_zones = functional_zones.to_crs(blocks_crs) + logger.info(f"assign_land_use layer scenario {scenario_id}") + blocks_lu = assign_land_use(blocks, functional_zones, LAND_USE_RULES) + blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) + logger.info(f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") + logger.info(f"buildings layer scenario {scenario_id}") + #TODO жилых зданий может нге быть в сценарии, пока ломается здесь + buildings = await get_scenario_buildings(scenario_id) + logger.info( + f"{len(blocks)} BLOCKS WITH BUILDINGS layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") + buildings = buildings.to_crs(blocks_crs) + if buildings is not None: + buildings = buildings.to_crs(blocks.crs) + blocks_buildings, _ = aggregate_objects(blocks, buildings) + blocks = blocks.join( + blocks_buildings.drop(columns=['geometry']).rename(columns={'objects_count': 'count_buildings'})) + blocks['count_buildings'] = blocks['count_buildings'].fillna(0).astype(int) + logger.info(f"service_types layer scenario {scenario_id}") + service_types = await get_service_types() + services_dict = await get_scenario_services(scenario_id, service_types) + for service_type, services in services_dict.items(): + services = services.to_crs(blocks.crs) + blocks_services, _ = aggregate_objects(blocks, services) + blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) + blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) + blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'objects_count': f'count_{service_type}', + })) + logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + return blocks + +async def run_development_parameters(scenario_id: int) -> gpd.GeoDataFrame | pd.DataFrame: + blocks = await aggregate_blocks_layer_scenario(scenario_id) + for lu in LandUse: + blocks[lu.value] = blocks[lu.value].apply(lambda v: min(v, 1)) + logger.info(f"adjacency_graph scenario {scenario_id}") + adjacency_graph = generate_adjacency_graph(blocks, 10) + dr = DensityRegressor() + + logger.info(f"DensityRegressor scenario {scenario_id}") + density_df = dr.evaluate(blocks, adjacency_graph) + density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 + + density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 + density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 + + density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 + density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 + + density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 + density_df['site_area'] = blocks['site_area'] + + logger.info(f"Calculating density indicators for {scenario_id}") + development_df = calculate_development_indicators(density_df) + development_df['population'] = development_df['living_area'] // 20 + + mask = blocks['is_project'] + columns = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] + blocks.loc[mask, columns] = development_df.loc[mask, columns] + return blocks diff --git a/app/effects_api/modules/functional_sources_service.py b/app/effects_api/modules/functional_sources_service.py index 701e2bd..b3ecb5f 100644 --- a/app/effects_api/modules/functional_sources_service.py +++ b/app/effects_api/modules/functional_sources_service.py @@ -22,4 +22,4 @@ def _adapt_functional_zone(data : dict): def adapt_functional_zones(functional_zones_gdf : gpd.GeoDataFrame): gdf = functional_zones_gdf[['geometry']].copy() gdf['functional_zone'] = functional_zones_gdf['functional_zone_type'].apply(_adapt_functional_zone) - return gdf \ No newline at end of file + return gdf diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 8f0fe9c..10b6a63 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,21 +1,23 @@ +"""Все методы, которые вызывают ручки по сценарию +А также методы, которые собиракют геослои по сценарию""" import shapely import numpy as np -from prostor.adapters import adapt_functional_zones -from prostor.adapters import adapt_services from blocksnet.preprocessing.imputing import impute_services -from prostor.adapters import adapt_buildings from blocksnet.preprocessing.imputing import impute_buildings from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks import geopandas as gpd import pandas as pd -import prostor.fetchers.projects as projects -import prostor.fetchers.scenarios as scenarios -import prostor.fetchers.territories as territories -from blocksnet.config import log_config + +from app.common.exceptions.http_exception_wrapper import http_exception +from app.effects_api.modules.buildings_service import adapt_buildings +from app.effects_api.modules.functional_sources_service import adapt_functional_zones +from app.effects_api.modules.services_service import adapt_services +from app.effects_api.modules.urban_api_gateway import UrbanAPIGateway + SOURCES_PRIORITY = ['User', 'PZZ', 'OSM'] -def close_gaps(gdf, tolerance): # taken from momepy +def close_gaps(gdf, tolerance): # taken from momepy geom = gdf.geometry.array coords = shapely.get_coordinates(geom) indices = shapely.get_num_coordinates(geom) @@ -45,24 +47,32 @@ def close_gaps(gdf, tolerance): # taken from momepy return gpd.GeoSeries(snapped, crs=gdf.crs) -def _get_project_boundaries(project_id : int): - return gpd.GeoDataFrame(geometry=[projects.get_project_geometry(project_id)], crs=4326) -def _get_scenario_roads(scenario_id : int): - gdf = scenarios.get_physical_objects_scenario(scenario_id, physical_object_function_id=ROADS_POF_ID) +async def _get_project_boundaries(project_id: int): + return gpd.GeoDataFrame(geometry=[await UrbanAPIGateway.get_project_geometry(project_id)], crs=4326) + + +async def _get_scenario_roads(scenario_id: int): + gdf = await UrbanAPIGateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=26) return gdf[['geometry']].reset_index(drop=True) -def _get_scenario_water(scenario_id : int): - gdf = scenarios.get_physical_objects_scenario(scenario_id, physical_object_function_id=WATER_POF_ID) + +async def _get_scenario_water(scenario_id: int): + gdf = await UrbanAPIGateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=4) return gdf[['geometry']].reset_index(drop=True) -def _get_scenario_blocks(user_scenario_id : int, base_scenario_id : int, boundaries : gpd.GeoDataFrame) -> gpd.GeoDataFrame: + +async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, + boundaries: gpd.GeoDataFrame) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = _get_scenario_water(user_scenario_id).to_crs(crs) - user_roads = _get_scenario_roads(user_scenario_id).to_crs(crs) - base_roads = _get_scenario_roads(base_scenario_id).to_crs(crs) + water = await _get_scenario_water(user_scenario_id) + water = water.to_crs(crs) + user_roads = await _get_scenario_roads(user_scenario_id) + user_roads = user_roads.to_crs(crs) + base_roads =await _get_scenario_roads(base_scenario_id) + base_roads = base_roads.to_crs(crs) roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) roads.geometry = close_gaps(roads, 1) @@ -70,51 +80,57 @@ def _get_scenario_blocks(user_scenario_id : int, base_scenario_id : int, boundar blocks = cut_urban_blocks(boundaries, lines, polygons) return blocks -def _get_scenario_info(scenario_id : int) -> tuple[int, int]: - scenario = scenarios.get_scenario(scenario_id) + +async def _get_scenario_info(scenario_id: int) -> tuple[int, int]: + scenario = await UrbanAPIGateway.get_scenario(scenario_id) project_id = scenario['project']['project_id'] - project = projects.get_project(project_id) + project = await UrbanAPIGateway.get_project(project_id) base_scenario_id = project['base_scenario']['id'] return project_id, base_scenario_id -def _get_best_functional_zones_source(sources_df : pd.DataFrame) -> tuple[int | None, str | None]: +async def _get_best_functional_zones_source(sources_df: pd.DataFrame) -> tuple[int | None, str | None]: sources = sources_df['source'].unique() for source in SOURCES_PRIORITY: if source in sources: sources_df = sources_df[sources_df['source'] == source] year = sources_df.year.max() return int(year), source - return None, None #FIXME ??? + return None, None # FIXME ??? -def get_scenario_blocks(user_scenario_id : int): - project_id, base_scenario_id = _get_scenario_info(user_scenario_id) - project_boundaries = _get_project_boundaries(project_id) + +async def get_scenario_blocks(user_scenario_id: int): + project_id, base_scenario_id = await _get_scenario_info(user_scenario_id) + project_boundaries = await _get_project_boundaries(project_id) crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) - return _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries) + return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries) + -def get_scenario_functional_zones(scenario_id : int) -> gpd.GeoDataFrame: - sources_df = scenarios.get_functional_zones_sources_scenario(scenario_id) - year, source = _get_best_functional_zones_source(sources_df) - functional_zones = scenarios.get_functional_zones_scenario(scenario_id, year, source) +async def get_scenario_functional_zones(scenario_id: int, source: str = None, year: int = None) -> gpd.GeoDataFrame: + sources_df = await UrbanAPIGateway.get_functional_zones_sources_scenario(scenario_id) + year, source = await _get_best_functional_zones_source(sources_df) + functional_zones = await UrbanAPIGateway.get_functional_zones_scenario(scenario_id, year, source) return adapt_functional_zones(functional_zones) -def get_scenario_buildings(scenario_id : int): + +async def get_scenario_buildings(scenario_id: int): try: - gdf = scenarios.get_physical_objects_scenario(scenario_id, physical_object_type_id=LIVING_BUILDING_POT_ID, centers_only=True) - except: - return None - gdf = adapt_buildings(gdf.reset_index(drop=True)) - crs = gdf.estimate_utm_crs() - return impute_buildings(gdf.to_crs(crs)).to_crs(4326) + gdf = await UrbanAPIGateway.get_physical_objects_scenario(scenario_id, physical_object_type_id=4, centers_only=True) + gdf = adapt_buildings(gdf.reset_index(drop=True)) + crs = gdf.estimate_utm_crs() + return impute_buildings(gdf.to_crs(crs)).to_crs(4326) + except Exception as e: + http_exception(404, f'No buildings found for scenario {scenario_id}', str(e)) + -def get_scenario_services(scenario_id : int, service_types : pd.DataFrame): +async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame): try: - gdf = scenarios.get_services_scenario(scenario_id, centers_only=True) + gdf = await UrbanAPIGateway.get_services_scenario(scenario_id, centers_only=True) except: return {} gdfs = adapt_services(gdf.reset_index(drop=True), service_types) - return {st:impute_services(gdf,st) for st,gdf in gdfs.items()} \ No newline at end of file + return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index ee8333f..d85b512 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,137 +1,137 @@ +"""Здесь предобработка типов сервисов для блокснета""" + from app.dependencies import urban_api_handler import pandas as pd from blocksnet.config import service_types_config def get_indicators(parent_id: int | None = None, **kwargs): - res = urban_api_handler.get('/api/v1/indicators_by_parent', params={ - 'parent_id': parent_id, - **kwargs - }) - return pd.DataFrame(res).set_index('indicator_id') + res = urban_api_handler.get('/api/v1/indicators_by_parent', params={ + 'parent_id': parent_id, + **kwargs + }) + return pd.DataFrame(res).set_index('indicator_id') -def get_service_types(**kwargs): - res = urban_api_handler.get('/api/v1/service_types', params=kwargs) - return pd.DataFrame(res).set_index('service_type_id') +async def get_service_types(**kwargs): + res = await urban_api_handler.get('/api/v1/service_types', params=kwargs) + return pd.DataFrame(res).set_index('service_type_id') def get_social_values(**kwargs): - res = urban_api_handler.get('/api/v1/social_values', params=kwargs) - return pd.DataFrame(res).set_index('soc_value_id') + res = urban_api_handler.get('/api/v1/social_values', params=kwargs) + return pd.DataFrame(res).set_index('soc_value_id') def get_social_value_service_types(soc_value_id: int, **kwargs): - res = urban_api_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) - if len(res) == 0: - return None - return pd.DataFrame(res).set_index('service_type_id') + res = urban_api_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) + if len(res) == 0: + return None + return pd.DataFrame(res).set_index('service_type_id') def get_service_type_social_values(service_type_id: int, **kwargs): - res = urban_api_handler.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) - if len(res) == 0: - return None - return pd.DataFrame(res).set_index('soc_value_id') - - + res = urban_api_handler.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) + if len(res) == 0: + return None + return pd.DataFrame(res).set_index('soc_value_id') SERVICE_TYPES_MAPPING = { - # basic - 1: 'park', - 21: 'kindergarten', - 22: 'school', - 28: 'polyclinic', - 34: 'pharmacy', - 61: 'cafe', - 66: 'pitch', - 68: None, # спортивный зал - 74: 'playground', - 78: 'police', - # additional - 30: None, # стоматология - 35: 'hospital', - 50: 'museum', - 56: 'cinema', - 57: 'mall', - 59: 'stadium', - 62: 'restaurant', - 63: 'bar', - 77: None, # скейт парк - 79: None, # пожарная станция - 80: 'train_station', - 89: 'supermarket', - 99: None, # пункт выдачи - 100: 'bank', - 107: 'veterinary', - 143: 'sanatorium', - # comfort - 5: 'beach', - 27: 'university', - 36: None, # роддом - 48: 'library', - 51: 'theatre', - 91: 'market', - 93: None, # одежда и обувь - 94: None, # бытовая техника - 95: None, # книжный магазин - 96: None, # детские товары - 97: None, # спортивный магазин - 108: None, # зоомагазин - 110: 'hotel', - 114: 'religion', # религиозный объект - # others - 26: None, # ССУЗ - 32: None, # женская консультация - 39: None, # скорая помощь - 40: None, # травматология - 45: 'recruitment', - 47: 'multifunctional_center', - 55: 'zoo', - 65: 'bakery', - 67: 'swimming_pool', - 75: None, # парк аттракционов - 81: 'train_building', - 82: 'aeroway_terminal', # аэропорт?? - 86: 'bus_station', - 88: 'subway_entrance', - 102: 'lawyer', - 103: 'notary', - 109: 'dog_park', - 111: 'hostel', - 112: None, # база отдыха - 113: None, # памятник + # basic + 1: 'park', + 21: 'kindergarten', + 22: 'school', + 28: 'polyclinic', + 34: 'pharmacy', + 61: 'cafe', + 66: 'pitch', + 68: None, # спортивный зал + 74: 'playground', + 78: 'police', + # additional + 30: None, # стоматология + 35: 'hospital', + 50: 'museum', + 56: 'cinema', + 57: 'mall', + 59: 'stadium', + 62: 'restaurant', + 63: 'bar', + 77: None, # скейт парк + 79: None, # пожарная станция + 80: 'train_station', + 89: 'supermarket', + 99: None, # пункт выдачи + 100: 'bank', + 107: 'veterinary', + 143: 'sanatorium', + # comfort + 5: 'beach', + 27: 'university', + 36: None, # роддом + 48: 'library', + 51: 'theatre', + 91: 'market', + 93: None, # одежда и обувь + 94: None, # бытовая техника + 95: None, # книжный магазин + 96: None, # детские товары + 97: None, # спортивный магазин + 108: None, # зоомагазин + 110: 'hotel', + 114: 'religion', # религиозный объект + # others + 26: None, # ССУЗ + 32: None, # женская консультация + 39: None, # скорая помощь + 40: None, # травматология + 45: 'recruitment', + 47: 'multifunctional_center', + 55: 'zoo', + 65: 'bakery', + 67: 'swimming_pool', + 75: None, # парк аттракционов + 81: 'train_building', + 82: 'aeroway_terminal', # аэропорт?? + 86: 'bus_station', + 88: 'subway_entrance', + 102: 'lawyer', + 103: 'notary', + 109: 'dog_park', + 111: 'hostel', + 112: None, # база отдыха + 113: None, # памятник } for st_id, st_name in SERVICE_TYPES_MAPPING.items(): - if st_name is None: - continue - assert st_name in service_types_config, f'{st_id}:{st_name} not in config' + if st_name is None: + continue + assert st_name in service_types_config, f'{st_id}:{st_name} not in config' def _adapt_name(service_type_id: int): - return SERVICE_TYPES_MAPPING.get(service_type_id) + return SERVICE_TYPES_MAPPING.get(service_type_id) def _adapt_infrastructure_weight(data: dict): - return data.get('weight_value', None) + return data.get('weight_value', None) def _adapt_social_values(service_type_id: int): - social_values = get_service_type_social_values(service_type_id) - if social_values is None: - return None - else: - return list(social_values.index) + social_values = get_service_type_social_values(service_type_id) + if social_values is None: + return None + else: + return list(social_values.index) def adapt_service_types(service_types_df: pd.DataFrame): - df = service_types_df[['infrastructure_type']].copy() - df['infrastructure_weight'] = service_types_df['properties'].apply(_adapt_infrastructure_weight) - df['name'] = df.apply(lambda s: _adapt_name(s.name), axis=1) - df = df[~df['name'].isna()].copy() + df = service_types_df[['infrastructure_type']].copy() + df['infrastructure_weight'] = service_types_df['properties'].apply(_adapt_infrastructure_weight) + df['name'] = df.apply(lambda s: _adapt_name(s.name), axis=1) + df = df[~df['name'].isna()].copy() - df['social_values'] = df.apply(lambda s: _adapt_social_values(s.name), axis=1) + df['social_values'] = df.apply(lambda s: _adapt_social_values(s.name), axis=1) - return df[['name', 'infrastructure_type', 'infrastructure_weight', 'social_values']].copy() + return df[['name', 'infrastructure_type', 'infrastructure_weight', 'social_values']].copy() diff --git a/app/effects_api/modules/urban_api_gateway.py b/app/effects_api/modules/urban_api_gateway.py index d8c3d73..6801bcd 100644 --- a/app/effects_api/modules/urban_api_gateway.py +++ b/app/effects_api/modules/urban_api_gateway.py @@ -1,71 +1,147 @@ import json - +import geopandas as gpd import pandas as pd -import requests import shapely -import geopandas as gpd - +from typing import Any, Dict from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_handler -from app.effects_api.constants import const -from app.effects_api.models import effects_models as em -#TODO context -# def get_physical_objects(project_id : int, **kwargs) -> dict: -# res = api.get(f'/api/v1/projects/{project_id}/context/physical_objects_with_geometry', params=kwargs) -# features = res['features'] -# return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('physical_object_id') -# -# def get_services(project_id : int, **kwargs) -> dict: -# res = api.get(f'/api/v1/projects/{project_id}/context/services_with_geometry', params=kwargs) -# features = res['features'] -# return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('service_id') -# -# def get_functional_zones_sources(project_id : int): -# res = api.get(f'/api/v1/projects/{project_id}/context/functional_zone_sources') -# return pd.DataFrame(res) -# -# def get_functional_zones(project_id : int, year : int, source : int): -# res = api.get(f'/api/v1/projects/{project_id}/context/functional_zones', params={ -# 'year': year, -# 'source': source -# }) -# features = res['features'] -# return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('functional_zone_id') -# -# def get_project(project_id : int) -> dict: -# res = api.get(f'/api/v1/projects/{project_id}') -# return res -# -# def get_project_geometry(project_id : int): -# res = api.get(f'/api/v1/projects/{project_id}/territory') -# geometry_json = json.dumps(res['geometry']) -# return shapely.from_geojson(geometry_json) -#TODO scenario -def get_scenario(scenario_id : int) -> dict: - res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}') - return res -def get_functional_zones_sources_scenario(scenario_id : int): - res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/functional_zone_sources') - return pd.DataFrame(res) +class UrbanAPIGateway: + # TODO context + @staticmethod + async def get_physical_objects( + project_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + res = await urban_api_handler.get( + f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", + params=kwargs + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("physical_object_id") + ) + + @staticmethod + async def get_services( + project_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + res = await urban_api_handler.get( + f"/api/v1/projects/{project_id}/context/services_with_geometry", + params=kwargs + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("service_id") + ) + + @staticmethod + async def get_functional_zones_sources( + project_id: int + ) -> pd.DataFrame: + res = await urban_api_handler.get( + f"/api/v1/projects/{project_id}/context/functional_zone_sources" + ) + return pd.DataFrame(res) + + @staticmethod + async def get_functional_zones( + project_id: int, + year: int, + source: int + ) -> gpd.GeoDataFrame: + res = await urban_api_handler.get( + f"/api/v1/projects/{project_id}/context/functional_zones", + params={"year": year, "source": source} + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("functional_zone_id") + ) + + @staticmethod + async def get_project(project_id: int) -> Dict[str, Any]: + res = await urban_api_handler.get(f"/api/v1/projects/{project_id}") + return res + + @staticmethod + async def get_project_geometry(project_id: int): + res = await urban_api_handler.get( + f"/api/v1/projects/{project_id}/territory" + ) + geometry_json = json.dumps(res["geometry"]) + return shapely.from_geojson(geometry_json) + + # TODO scenario + @staticmethod + async def get_scenario(scenario_id: int) -> Dict[str, Any]: + res = await urban_api_handler.get(f"/api/v1/scenarios/{scenario_id}") + return res + + @staticmethod + async def get_functional_zones_sources_scenario( + scenario_id: int + ) -> pd.DataFrame: + res = await urban_api_handler.get( + f"/api/v1/scenarios/{scenario_id}/functional_zone_sources" + ) + return pd.DataFrame(res) -def get_functional_zones_scenario(scenario_id : int, year : int, source : int): - res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/functional_zones', params={ - 'year': year, - 'source': source - }) - features = res['features'] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('functional_zone_id') + @staticmethod + async def get_functional_zones_scenario( + scenario_id: int, + year: int, + source: str + ) -> gpd.GeoDataFrame: + res = await urban_api_handler.get( + f"/api/v1/scenarios/{scenario_id}/functional_zones", + params={"year": year, "source": source} + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("functional_zone_id") + ) -def get_physical_objects_scenario(scenario_id : int, **kwargs): - res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry', params=kwargs) - features = res['features'] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('physical_object_id') + @staticmethod + async def get_physical_objects_scenario( + scenario_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + params = { + k: (str(v).lower() if isinstance(v, bool) else v) + for k, v in kwargs.items() + } + res = await urban_api_handler.get( + f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", + params=params + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("physical_object_id") + ) -def get_services_scenario(scenario_id : int, **kwargs) -> dict: - res = urban_api_handler.get(f'/api/v1/scenarios/{scenario_id}/services_with_geometry', params=kwargs) - features = res['features'] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index('service_id') + @staticmethod + async def get_services_scenario( + scenario_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + res = await urban_api_handler.get( + f"/api/v1/scenarios/{scenario_id}/services_with_geometry", + params=kwargs + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("service_id") + ) +# Экземпляр для удобства +UrbanAPIGateway = UrbanAPIGateway() \ No newline at end of file From 851009515ef179539b08558ea09298eccbbeece2 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Jun 2025 16:25:06 +0300 Subject: [PATCH 008/161] feat(blocksnet_service): - added empty service class --- app/effects_api/effects_service.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index e69de29..059f917 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -0,0 +1,4 @@ +class EffectsService: + + def __init__(self): + pass From 2311f8ae1bb97224ed47c4f5909031ae93cec2fb Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Jun 2025 16:38:46 +0300 Subject: [PATCH 009/161] feat(development_dto): - added dtos for development requests --- .gitignore | 2 +- app/effects_api/dto/development_dto.py | 32 +++++++++++++++++++++++++ requirements.txt | Bin 378 -> 674 bytes 3 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 app/effects_api/dto/development_dto.py diff --git a/.gitignore b/.gitignore index 61f3563..3cd41a7 100644 --- a/.gitignore +++ b/.gitignore @@ -126,7 +126,7 @@ celerybeat.pid # Environments app/.env.development -.env.development +.env.* .venv env/ venv/ diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py new file mode 100644 index 0000000..ca06997 --- /dev/null +++ b/app/effects_api/dto/development_dto.py @@ -0,0 +1,32 @@ +from typing import Literal + +from pydantic import BaseModel, Field + + +class DevelopmentDTO(BaseModel): + + scenario_id: Field(..., examples=[822], description="Project scenario id to retrieve data from.") + proj_func_zone_source: Literal["PZZ", "OSM", "User"] = Field( + default=None, + examples=["User", "PZZ", "OSM",], + description="Project functional zones source to retrieve data from. Default retrieves in priority User -> PZZ -> OSM" + ) + proj_func_source_year: int = Field( + default=None, + examples=[2023, 2024], + description="Project functional zones source year to retrieve data from. As default retrieves latest year." + ) + + +class ContextDevelopmentDTO(DevelopmentDTO): + + context_func_zone_source: Literal["PZZ", "OSM", "User"] = Field( + default=None, + examples=["PZZ", "OSM"], + description="Project functional zones source to retrieve data from. As default retrieves in priority PZZ -> OSM" + ) + context_func_source_year: int = Field( + default=None, + examples=[2023, 2024], + description="Context functional zones source year to retrieve data from. Default retrieves latest year." + ) diff --git a/requirements.txt b/requirements.txt index 13038d19e837a558ee81ddda0e4c38d27a2b932f..77755c9b4dbcc17e67cad0fda79d5bb114a913e8 100644 GIT binary patch literal 674 zcmZ8fK~BR!5c3&{rxdYC0CC^}oO!{vO+%o~mL#R^g{K4dOp{bmw2>NnZI7paze{Y9 zu)-%gG$_&I2QLV?!~Nx~`SqK>ZgGQGtZ~2_SqUv7Dq`3CV62Fm_&ZrW_J-%w3dro8 z8)w7Y9q~v;Z5;UpJ`YzcWvl4e^2kS=Cmz?>2KI^2-2=hux$ zwU+qN@PKDS<*f-1oK;$yZ&JunjIOnHurN-84cXO2$vewTN2!~cQpVgvukuoRiSug$ z&C!iI>fiIsXVgsIqd0|m%@u^}cuQ(;%csVYUxxdd$2;k`DQ%%7=RD$5f`*gqh)fp%TsALdMENB{r; delta 7 OcmZ3)`ip5p6(ax)f&$L~ From 8d402f211a8cc3c03db12a9031cc69b5a29b046a Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Jun 2025 17:05:43 +0300 Subject: [PATCH 010/161] feat(effects_controller): - added effects_controller structure --- app/effects_api/effects_controller.py | 206 ++---------------- .../schemas/development_response_schema.py | 0 app/effects_api/schemas/task_schema.py | 22 -- 3 files changed, 18 insertions(+), 210 deletions(-) create mode 100644 app/effects_api/schemas/development_response_schema.py delete mode 100644 app/effects_api/schemas/task_schema.py diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index d8db273..b17c353 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -1,194 +1,24 @@ -import json -import os -from datetime import datetime from typing import Annotated -from loguru import logger -# from blocksnet.models import ServiceType -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException -from starlette.responses import JSONResponse -import geopandas as gpd - -from app.effects_api.constants import const -from app.common import auth, decorators -from app.effects_api.models import effects_models as em -from app.effects_api.modules import effects_service as es, service_type_service as sts -from app.effects_api.modules.f22_service import run_development_parameters -from app.effects_api.modules.scenario_service import get_scenario_functional_zones -from app.effects_api.schemas.task_schema import TaskSchema, TaskStatusSchema, TaskInfoSchema -from app.effects_api.modules.task_api_service import get_scenario_info, get_all_project_info, get_project_id - -router = APIRouter(prefix='/effects', tags=['Effects']) - -tasks: dict[int, TaskSchema] = {} - -def check_task_evaluation(scenario_id: int) -> None: - - if not tasks.get(scenario_id): - raise HTTPException( - 404, - detail={ - "msg": f"Calculations for scenario {scenario_id} was never started", - "detail": { - "available scenarios": list(tasks.keys()) - } - } - - ) - elif tasks[scenario_id].task_status.task_status == "pending": - raise HTTPException( - 400, - detail={ - "msg": f"Calculations for scenario {scenario_id} is still running", - "detail": { - "available results": [ i for i in tasks.values() if i.task_status.task_status == "success" ], - } - } - ) - - elif tasks[scenario_id].task_status == "error": - raise HTTPException( - 500, - detail={ - "msg": f"Calculations for scenario {scenario_id} failed", - "detail": { - "error": tasks[scenario_id].task_status.task_status, - } - } - ) - elif tasks[scenario_id].task_status.task_status == "success": - return - else: - raise HTTPException( - 500, - detail={ - "msg": f"Unexpected error during task check", - "detail": { - "unknown status": tasks[scenario_id].task_status.task_status, - } - } - ) - - -#ToDo rewrite to check token firstly -def check_or_set_status(project_scenario_id: int, token) -> dict: - - scenario_info = get_scenario_info(project_scenario_id, token) - - if task_info := tasks.get(project_scenario_id): - task_date = task_info.task_info.lust_update - if scenario_info.get("updated_at"): - actual_date = datetime.strptime(scenario_info["updated_at"], "%Y-%m-%dT%H:%M:%S.%fZ") - else: - actual_date = datetime.strptime(scenario_info["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ") - if actual_date > task_date: - task_info.task_info.lust_update = actual_date - task_info.task_status.task_status = "pending" - return {"action": "continue"} - match task_info.task_status.task_status: - case "success": - return { - "action": "return", - "msg": "task is already done and up to date", - "task_info": task_info, - } - case "pending": - return { - "action": "return", - "msg": "task is already running", - "task_info": task_info, - } - case"done": - return { - "action": "return", - "msg": "task is done", - "task_info": task_info, - } - case "error": - return { - "action": "return", - "msg": "task failed due to error", - "task_info": task_info, - } - case _: - raise HTTPException(status_code=500, detail="Unknown task status") - else: - project_id = get_project_id(project_scenario_id, token) - project_info = get_all_project_info(project_id, token) - if scenario_info.get("updated_at"): - lust_update = datetime.strptime(scenario_info["updated_at"], "%Y-%m-%dT%H:%M:%S.%fZ") - else: - lust_update = datetime.strptime(scenario_info["created_at"], "%Y-%m-%dT%H:%M:%S.%fZ") - task_info_to_add = TaskInfoSchema( - project_id=project_info["project_id"], - base_scenario_id=project_info["base_scenario"]["id"], - lust_update=lust_update - ) - tasks[project_scenario_id] = TaskSchema( - task_status=TaskStatusSchema(task_status="pending"), - target_scenario_id=project_scenario_id, - task_info=task_info_to_add - ) - return {"action": "continue"} - -def _evaluate_master_plan_task(project_scenario_id: int, token: str = Depends(auth.verify_token), is_context: bool = False): - try: - es.evaluate_f22(project_scenario_id, token, is_context=is_context) - tasks[project_scenario_id].task_status.task_status = "success" - except Exception as e: - logger.error(e) - logger.exception(e) - tasks[project_scenario_id].task_status.task_status = 'error' - tasks[project_scenario_id].task_status.error_info = e.__str__() - - -@router.post('/evaluate') -def evaluate(background_tasks: BackgroundTasks, project_scenario_id: int, token: str = Depends(auth.verify_token), is_context: bool = False): - check_result = check_or_set_status(project_scenario_id, token) - if check_result["action"] == "return": - del check_result["action"] - return check_result - background_tasks.add_task(_evaluate_master_plan_task, project_scenario_id, token, is_context=is_context) - return {'task_id': project_scenario_id} - -#ручка для теста, можно убрать -@router.get('/get_scenario_zones/{scenario_id}') -async def get_scenario_zones(scenario_id: int): - zones: gpd.GeoDataFrame = await get_scenario_functional_zones(scenario_id) - geojson_str = zones.to_json() - geojson_dict = json.loads(geojson_str) - return JSONResponse(content=geojson_dict, media_type="application/geo+json") - - -# @router.post('/socio_economic_effects/{scenario_id}') -# def evaluate_socio_economic_effects( -# scenario_id: int, -# token: str = Depends(auth.verify_token), -# functional_zone_source: str, -# functional_zone_year: int, -# context_functional_zone_source: str, -# context_functional_zone_year: int -# -# ): -# - -@router.get('/get_development_parameters/{scenario_id}') -async def get_development_parameters(scenario_id: int): - development_parameters: gpd.GeoDataFrame = await run_development_parameters(scenario_id) - geojson_str = development_parameters.to_json() - geojson_dict = json.loads(geojson_str) - return JSONResponse(content=geojson_dict, media_type="application/geo+json") - - - -@router.delete('/evaluation') -def delete_evaluation(project_scenario_id : int): - try: - es.delete_evaluation(project_scenario_id) - return 'oke' - except: - return 'oops' +from fastapi import APIRouter +from fastapi.params import Depends +from app.common.auth.auth import verify_token +from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +development_router = APIRouter(prefix='/redevelopment', tags=['Effects']) +#TODO describe output schemas +@development_router.get("/project_redevelopment") +async def get_project_redevelopment( + params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], + token: str = Depends(verify_token), +): + pass +@development_router.et("/context_redevelopment") +async def get_context_redevelopment( + params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], + token: str = Depends(verify_token), +): + pass diff --git a/app/effects_api/schemas/development_response_schema.py b/app/effects_api/schemas/development_response_schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/schemas/task_schema.py b/app/effects_api/schemas/task_schema.py deleted file mode 100644 index 073fcbf..0000000 --- a/app/effects_api/schemas/task_schema.py +++ /dev/null @@ -1,22 +0,0 @@ -from datetime import datetime -from typing import Literal, Optional - -from pydantic import BaseModel - - -class TaskStatusSchema(BaseModel): - - task_status: Literal["pending", "success", "error", ] - error_info: Optional[str] = None - -class TaskInfoSchema(BaseModel): - - project_id: int - base_scenario_id: int - lust_update: datetime - - -class TaskSchema(BaseModel): - task_status: TaskStatusSchema - target_scenario_id: int - task_info: Optional[TaskInfoSchema] = None From 2e29ac7f23ccf72f557477993c54a015bd499007 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Jun 2025 17:09:05 +0300 Subject: [PATCH 011/161] feat(effects_service): - added effects_service methods structure --- app/effects_api/effects_service.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 059f917..94dbdf1 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,4 +1,29 @@ +from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO + + class EffectsService: def __init__(self): pass + + async def calc_project_development(self, params: DevelopmentDTO): + """ + Function calculates development only for project with blocksnet + Args: + params (DevelopmentDTO): + Returns: + -- + """ + + pass + + async def calc_context_development(self, params: ContextDevelopmentDTO): + """ + Function calculates development for context with project with blocksnet + Args: + params (DevelopmentDTO): + Returns: + -- + """ + + pass From 40109ce4103a5cd250e004eaaa383dd67d66a314 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 27 Jun 2025 17:23:34 +0300 Subject: [PATCH 012/161] refactor(WIP_4_buildings_parameters): working logic for buidiings parameters --- app/effects_api/effects_service.py | 161 ++++++++++++++++++++ app/effects_api/modules/scenario_service.py | 8 +- requirements.txt | Bin 674 -> 682 bytes 3 files changed, 165 insertions(+), 4 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 94dbdf1..3c63246 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,11 +1,172 @@ from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +import pandas as pd +import geopandas as gpd +from blocksnet.machine_learning.regression import SocialRegressor +from loguru import logger +from blocksnet.analysis.indicators import calculate_development_indicators +from blocksnet.blocks.aggregation import aggregate_objects +from blocksnet.blocks.assignment import assign_land_use +from blocksnet.enums import LandUse +from blocksnet.machine_learning.regression import DensityRegressor +from blocksnet.relations import generate_adjacency_graph + +from app.effects_api.modules.functional_sources_service import LAND_USE_RULES +from app.effects_api.modules.scenario_service import get_scenario_blocks, get_scenario_functional_zones, \ + get_scenario_services, get_scenario_buildings +from app.effects_api.modules.service_type_service import get_service_types + + class EffectsService: def __init__(self): pass +def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: + logger.info('Evaluating master plan effects') + blocks = buildings_blocks_gdf.join( + blocks.drop(columns="geometry"), # не тащим геометрию дважды + how="left" + ) + adjacency_graph = generate_adjacency_graph(blocks, 10) + dr = DensityRegressor() + density_df = dr.evaluate(blocks, adjacency_graph) + + + density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 + density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 + density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 + density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 + density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 + density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 + + blocks['site_area'] = blocks.area + density_df['site_area'] = blocks['site_area'] + development_df = calculate_development_indicators(density_df) + development_df['population'] = development_df['living_area'] // 20 + cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] + blocks[cols] = development_df[cols].values + for lu in LandUse: + blocks[lu.value] = blocks[lu.value] * blocks['site_area'] + data = [blocks.drop(columns=['land_use', 'geometry']).sum().to_dict()] + input = pd.DataFrame(data) + + input['latitude'] = blocks.geometry.union_all().centroid.x + input['longitude'] = blocks.geometry.union_all().centroid.y + input['buildings_count'] = input['count'] + sr = SocialRegressor() + y_pred, pi_lower, pi_upper = sr.evaluate(input) + iloc = 0 + result_data = { + 'pred': y_pred.apply(round).astype(int).iloc[iloc].to_dict(), + 'lower': pi_lower.iloc[iloc].to_dict(), + 'upper': pi_upper.iloc[iloc].to_dict(), + } + result_df = pd.DataFrame.from_dict(result_data) + result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) + + return result_df + +async def aggregate_blocks_layer_scenario( + scenario_id: int, + token: str = None, + functional_zone_source: str = None, + functional_zone_year: int = None) -> gpd.GeoDataFrame: + logger.info(f"Aggregating blocks layer scenario {scenario_id}") + blocks = await get_scenario_blocks(scenario_id) + blocks_crs = blocks.crs + logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") + blocks['site_area'] = blocks.area + logger.info(f"Aggregating functional_zones layer scenario {scenario_id}") + functional_zones = await get_scenario_functional_zones(scenario_id) + functional_zones = functional_zones.to_crs(blocks_crs) + logger.info(f"assign_land_use layer scenario {scenario_id}") + blocks_lu = assign_land_use(blocks, functional_zones, LAND_USE_RULES) + blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) + logger.info(f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") + logger.info(f"buildings layer scenario {scenario_id}") + # TODO жилых зданий может нге быть в сценарии, пока ломается здесь + buildings = await get_scenario_buildings(scenario_id) + logger.info( + f"{len(blocks)} BLOCKS WITH BUILDINGS layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") + buildings = buildings.to_crs(blocks_crs) + if buildings is not None: + buildings = buildings.to_crs(blocks.crs) + blocks_buildings, _ = aggregate_objects(blocks, buildings) + blocks = blocks.join( + blocks_buildings.drop(columns=['geometry']).rename(columns={'objects_count': 'count_buildings'})) + blocks['count_buildings'] = blocks['count_buildings'].fillna(0).astype(int) + logger.info(f"service_types layer scenario {scenario_id}") + service_types = await get_service_types() + logger.info( + f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") + services_dict = await get_scenario_services(scenario_id, service_types) + logger.info( + f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, service_dict {services_dict}") + for service_type, services in services_dict.items(): + services = services.to_crs(blocks.crs) + blocks_services, _ = aggregate_objects(blocks, services) + blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) + blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) + blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'objects_count': f'count_{service_type}', + })) + logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + return blocks + +async def get_services_layer(scenario_id: int): + blocks = await get_scenario_blocks(scenario_id) + blocks_crs = blocks.crs + logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") + service_types = await get_service_types() + logger.info(f"{service_types}") + services_dict = await get_scenario_services(scenario_id, service_types) + + for service_type, services in services_dict.items(): + services = services.to_crs(blocks_crs) + blocks_services, _ = aggregate_objects(blocks, services) + blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) + blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) + blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'objects_count': f'count_{service_type}', + })) + logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + return blocks + +async def run_development_parameters(scenario_id: int) -> gpd.GeoDataFrame | pd.DataFrame: + blocks = await aggregate_blocks_layer_scenario(scenario_id) + for lu in LandUse: + blocks[lu.value] = blocks[lu.value].apply(lambda v: min(v, 1)) + logger.info(f"adjacency_graph scenario {scenario_id}") + adjacency_graph = generate_adjacency_graph(blocks, 10) + dr = DensityRegressor() + + logger.info(f"DensityRegressor scenario {scenario_id}") + density_df = dr.evaluate(blocks, adjacency_graph) + density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 + + density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 + density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 + + density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 + density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 + + density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 + density_df['site_area'] = blocks['site_area'] + + logger.info(f"Calculating density indicators for {scenario_id}") + development_df = calculate_development_indicators(density_df) + development_df['population'] = development_df['living_area'] // 20 + + + # mask = blocks['is_project'] + # columns = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] + # blocks.loc[mask, columns] = development_df.loc[mask, columns] + return development_df + async def calc_project_development(self, params: DevelopmentDTO): """ Function calculates development only for project with blocksnet diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 10b6a63..35da610 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -129,8 +129,8 @@ async def get_scenario_buildings(scenario_id: int): async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame): try: gdf = await UrbanAPIGateway.get_services_scenario(scenario_id, centers_only=True) - except: - return {} - gdfs = adapt_services(gdf.reset_index(drop=True), service_types) - return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) + return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + except Exception as e: + http_exception(404, f'No buildings found for scenario {scenario_id}', str(e)) diff --git a/requirements.txt b/requirements.txt index 77755c9b4dbcc17e67cad0fda79d5bb114a913e8..31d70de3a7d904822ff0bed0d2617916e22fcd29 100644 GIT binary patch delta 18 ZcmZ3)x{7sz2zxX`E<+AO>_*{!CIB#w1n2+& delta 10 RcmZ3*x`=gx$VQnyCIA!&16Tk6 From 52794a86399f65c83be19341f359849f26d7600b Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 27 Jun 2025 18:47:39 +0300 Subject: [PATCH 013/161] feat(temp_commit): adding logic for generating context and scenario unified blocks layer --- app/effects_api/effects_controller.py | 13 ++- app/effects_api/effects_service.py | 115 +++++++++++++------ app/effects_api/modules/context_service.py | 2 +- app/effects_api/modules/f22_service.py | 32 +++++- app/effects_api/modules/task_api_service.py | 29 +++-- app/effects_api/modules/urban_api_gateway.py | 16 +++ app/main.py | 1 + 7 files changed, 158 insertions(+), 50 deletions(-) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index b17c353..3cffe3f 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -5,6 +5,7 @@ from app.common.auth.auth import verify_token from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +from ..common.auth import auth development_router = APIRouter(prefix='/redevelopment', tags=['Effects']) @@ -16,9 +17,19 @@ async def get_project_redevelopment( ): pass -@development_router.et("/context_redevelopment") +@development_router.get("/context_redevelopment") async def get_context_redevelopment( params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), ): pass + +@development_router.get("/socio_economic_prediction") +async def get_socio_economic_prediction( + scenario_id: int, + functional_zone_source: str, + functional_zone_year: int, + context_functional_zone_source: str, + context_functional_zone_year: int, + token: str = Depends(auth.verify_token), +): diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 3c63246..314c324 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -16,6 +16,9 @@ from app.effects_api.modules.scenario_service import get_scenario_blocks, get_scenario_functional_zones, \ get_scenario_services, get_scenario_buildings from app.effects_api.modules.service_type_service import get_service_types +from .modules.context_service import get_context_blocks, get_context_functional_zones, get_context_buildings, \ + get_context_services +from .modules.task_api_service import get_project_id class EffectsService: @@ -26,7 +29,7 @@ def __init__(self): def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: logger.info('Evaluating master plan effects') blocks = buildings_blocks_gdf.join( - blocks.drop(columns="geometry"), # не тащим геометрию дважды + blocks.drop(columns="geometry"), how="left" ) adjacency_graph = generate_adjacency_graph(blocks, 10) @@ -68,6 +71,83 @@ def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.Ge return result_df +async def aggregate_blocks_layer_scenario_context( + scenario_id: int, + functional_zone_source: str, + functional_zone_year: int, + context_functional_zone_source: str, + context_functional_zone_year: int, + token: str = None, + ) -> gpd.GeoDataFrame: + logger.info(f"Aggregating blocks layer scenario {scenario_id}") + project_id = await get_project_id(scenario_id) + + scenario_blocks = await get_scenario_blocks(scenario_id) + scenario_blocks_crs = scenario_blocks.crs + scenario_blocks['site_area'] = scenario_blocks.area + + scenario_functional_zones = await get_scenario_functional_zones(scenario_id) + scenario_functional_zones = scenario_functional_zones.to_crs(scenario_blocks_crs) + scenario_blocks_lu = assign_land_use(scenario_blocks, scenario_functional_zones, LAND_USE_RULES) + scenario_blocks = scenario_blocks.join(scenario_blocks_lu.drop(columns=['geometry'])) + + scenario_buildings = await get_scenario_buildings(scenario_id) + scenario_buildings = scenario_buildings.to_crs(scenario_blocks_crs) + if scenario_buildings is not None: + scenario_buildings = scenario_buildings.to_crs(scenario_blocks.crs) + blocks_buildings, _ = aggregate_objects(scenario_blocks, scenario_buildings) + scenario_blocks = scenario_blocks.join( + blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) + scenario_blocks['count_buildings'] = scenario_blocks['count_buildings'].fillna(0).astype(int) + + service_types = await get_service_types() + scenario_services_dict = await get_scenario_services(scenario_id, service_types) + + for service_type, services in scenario_services_dict.items(): + services = services.to_crs(scenario_blocks.crs) + scenario_blocks_services, _ = aggregate_objects(scenario_blocks, services) + scenario_blocks_services['capacity'] = scenario_blocks_services['capacity'].fillna(0).astype(int) + scenario_blocks_services['count'] = scenario_blocks_services['count'].fillna(0).astype(int) + scenario_blocks = scenario_blocks.join(scenario_blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'count': f'count_{service_type}', + })) + + #---------------------------------------------------------# + + context_blocks = await get_context_blocks(project_id) + context_blocks = context_blocks.to_crs(scenario_blocks_crs) + context_blocks['site_area'] = context_blocks.area + + context_functional_zones = await get_context_functional_zones(project_id) + context_functional_zones = context_functional_zones.to_crs(scenario_blocks_crs) + context_blocks_lu = assign_land_use(context_blocks, context_functional_zones, LAND_USE_RULES) + context_blocks = context_blocks.join(context_blocks_lu.drop(columns=['geometry'])) + + context_buildings = await get_context_buildings(project_id) + context_buildings = context_buildings.to_crs(scenario_blocks_crs) + if context_buildings is not None: + context_buildings = context_buildings.to_crs(context_blocks.crs) + context_blocks_buildings, _ = aggregate_objects(context_blocks, context_buildings) + context_blocks = context_blocks.join( + context_blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) + context_blocks['count_buildings'] = context_blocks['count_buildings'].fillna(0).astype(int) + + context_services_dict = await get_context_services(project_id, service_types) + for service_type, services in context_services_dict.items(): + services = services.to_crs(context_blocks.crs) + context_blocks_services, _ = aggregate_objects(scenario_blocks, services) + context_blocks_services['capacity'] = context_blocks_services['capacity'].fillna(0).astype(int) + context_blocks_services['count'] = context_blocks_services['count'].fillna(0).astype(int) + context_blocks = context_blocks.join(context_blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'count': f'count_{service_type}', + })) + buildings_gdf = scenario_buildings.join(context_buildings.drop(columns=['geometry']).rename(columns={})) + blocks = context_blocks.join(scenario_blocks) + + return blocks + async def aggregate_blocks_layer_scenario( scenario_id: int, token: str = None, @@ -76,44 +156,11 @@ async def aggregate_blocks_layer_scenario( logger.info(f"Aggregating blocks layer scenario {scenario_id}") blocks = await get_scenario_blocks(scenario_id) blocks_crs = blocks.crs - logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") blocks['site_area'] = blocks.area - logger.info(f"Aggregating functional_zones layer scenario {scenario_id}") functional_zones = await get_scenario_functional_zones(scenario_id) functional_zones = functional_zones.to_crs(blocks_crs) - logger.info(f"assign_land_use layer scenario {scenario_id}") blocks_lu = assign_land_use(blocks, functional_zones, LAND_USE_RULES) blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) - logger.info(f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") - logger.info(f"buildings layer scenario {scenario_id}") - # TODO жилых зданий может нге быть в сценарии, пока ломается здесь - buildings = await get_scenario_buildings(scenario_id) - logger.info( - f"{len(blocks)} BLOCKS WITH BUILDINGS layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") - buildings = buildings.to_crs(blocks_crs) - if buildings is not None: - buildings = buildings.to_crs(blocks.crs) - blocks_buildings, _ = aggregate_objects(blocks, buildings) - blocks = blocks.join( - blocks_buildings.drop(columns=['geometry']).rename(columns={'objects_count': 'count_buildings'})) - blocks['count_buildings'] = blocks['count_buildings'].fillna(0).astype(int) - logger.info(f"service_types layer scenario {scenario_id}") - service_types = await get_service_types() - logger.info( - f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") - services_dict = await get_scenario_services(scenario_id, service_types) - logger.info( - f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, service_dict {services_dict}") - for service_type, services in services_dict.items(): - services = services.to_crs(blocks.crs) - blocks_services, _ = aggregate_objects(blocks, services) - blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) - blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) - blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'objects_count': f'count_{service_type}', - })) - logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") return blocks async def get_services_layer(scenario_id: int): diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 5f3a075..e583036 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -63,7 +63,7 @@ async def get_context_blocks(project_id: int): return _get_context_blocks(project_id, context_boundaries) -async def get_context_functional_zones(project_id: int, token: str) -> gpd.GeoDataFrame: +async def get_context_functional_zones(project_id: int, token: str = None) -> gpd.GeoDataFrame: sources_df = await UrbanAPIGateway.get_functional_zones_sources(project_id) year, source = _get_best_functional_zones_source(sources_df) functional_zones = await UrbanAPIGateway.get_functional_zones_scenario(project_id, year, source) diff --git a/app/effects_api/modules/f22_service.py b/app/effects_api/modules/f22_service.py index 46fe1d2..af742fa 100644 --- a/app/effects_api/modules/f22_service.py +++ b/app/effects_api/modules/f22_service.py @@ -35,8 +35,8 @@ async def aggregate_blocks_layer_scenario( blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) logger.info(f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") logger.info(f"buildings layer scenario {scenario_id}") - #TODO жилых зданий может нге быть в сценарии, пока ломается здесь - buildings = await get_scenario_buildings(scenario_id) + # #TODO жилых зданий может нге быть в сценарии, пока ломается здесь + # # buildings = await get_scenario_buildings(scenario_id) logger.info( f"{len(blocks)} BLOCKS WITH BUILDINGS layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") buildings = buildings.to_crs(blocks_crs) @@ -48,7 +48,11 @@ async def aggregate_blocks_layer_scenario( blocks['count_buildings'] = blocks['count_buildings'].fillna(0).astype(int) logger.info(f"service_types layer scenario {scenario_id}") service_types = await get_service_types() + logger.info( + f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") services_dict = await get_scenario_services(scenario_id, service_types) + logger.info( + f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, service_dict {services_dict}") for service_type, services in services_dict.items(): services = services.to_crs(blocks.crs) blocks_services, _ = aggregate_objects(blocks, services) @@ -58,8 +62,28 @@ async def aggregate_blocks_layer_scenario( 'capacity': f'capacity_{service_type}', 'objects_count': f'count_{service_type}', })) - logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") - return blocks + logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + return blocks + +async def get_services_layer(scenario_id: int): + blocks = await get_scenario_blocks(scenario_id) + blocks_crs = blocks.crs + logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") + service_types = await get_service_types() + logger.info(f"{service_types}") + services_dict = await get_scenario_services(scenario_id, service_types) + + for service_type, services in services_dict.items(): + services = services.to_crs(blocks_crs) + blocks_services, _ = aggregate_objects(blocks, services) + blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) + blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) + blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'objects_count': f'count_{service_type}', + })) + logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + return blocks async def run_development_parameters(scenario_id: int) -> gpd.GeoDataFrame | pd.DataFrame: blocks = await aggregate_blocks_layer_scenario(scenario_id) diff --git a/app/effects_api/modules/task_api_service.py b/app/effects_api/modules/task_api_service.py index 4e46983..4a2494e 100644 --- a/app/effects_api/modules/task_api_service.py +++ b/app/effects_api/modules/task_api_service.py @@ -2,8 +2,14 @@ import requests from fastapi import HTTPException +from iduconfig import Config -from app.effects_api.constants.const import URBAN_API +from app.common.exceptions.http_exception_wrapper import http_exception +from app.dependencies import urban_api_handler + +config = Config() + +# from app.effects_api.constants.const import URBAN_API def get_headers(token: Optional[str] = None) -> dict[str, str] | None: @@ -14,17 +20,20 @@ def get_headers(token: Optional[str] = None) -> dict[str, str] | None: return headers return None -def get_project_id(scenario_id: int, token: Optional[str] = None) -> int: - url = f"{URBAN_API}/api/v1/scenarios/{scenario_id}" - headers = get_headers(token) +async def get_project_id(scenario_id: int, token: Optional[str] = None) -> int: + endpoint = f"/api/v1/scenarios/{scenario_id}" + response = await urban_api_handler.get(endpoint, headers={ + "Authorization": f"Bearer {token}""" + }) + try: + project_id = response.get("project", {}).get("project_id") + except Exception: + raise http_exception(404, "Project ID is missing in scenario data.", scenario_id) - response = requests.get(url, headers=headers) - if response.status_code != 200: - raise HTTPException(response.status_code, response.text) - return response.json()["project"]["project_id"] + return project_id def get_all_project_info(project_id: int, token: Optional[str] = None) -> dict: - url = f"{URBAN_API}/api/v1/projects/{project_id}" + url = config.get("URBAN_API") + f"/api/v1/projects/{project_id}" headers = get_headers(token) response = requests.get(url, headers=headers) @@ -35,7 +44,7 @@ def get_all_project_info(project_id: int, token: Optional[str] = None) -> dict: def get_scenario_info(target_scenario_id: int, token) -> dict: - url = f"{URBAN_API}/api/v1/scenarios/{target_scenario_id}" + url = config.get("URBAN_API") + f"/api/v1/scenarios/{target_scenario_id}" headers = get_headers(token) response = requests.get(url, headers=headers) if response.status_code != 200: diff --git a/app/effects_api/modules/urban_api_gateway.py b/app/effects_api/modules/urban_api_gateway.py index 6801bcd..f16458b 100644 --- a/app/effects_api/modules/urban_api_gateway.py +++ b/app/effects_api/modules/urban_api_gateway.py @@ -109,6 +109,19 @@ async def get_functional_zones_scenario( .set_index("functional_zone_id") ) + @staticmethod + async def get_project_id_by_scenario_id(scenario_id: int, token: str = None) -> int: + endpoint = f"/api/v1/scenarios/{scenario_id}" + response = await urban_api_handler.get(endpoint, headers={ + "Authorization": f"Bearer {token}""" + }) + try: + project_id = response.get("project", {}).get("project_id") + except Exception: + raise http_exception(404, "Project ID is missing in scenario data.", scenario_id) + + return project_id + @staticmethod async def get_physical_objects_scenario( scenario_id: int, @@ -143,5 +156,8 @@ async def get_services_scenario( .set_index("service_id") ) + + + # Экземпляр для удобства UrbanAPIGateway = UrbanAPIGateway() \ No newline at end of file diff --git a/app/main.py b/app/main.py index 9e4d64a..51b6e7a 100644 --- a/app/main.py +++ b/app/main.py @@ -27,6 +27,7 @@ async def lifespan(router : FastAPI): allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) +app.include_router(effects_controller.router) @app.get("/", include_in_schema=False) async def read_root(): From db99061c7569034d92a931d890e44c1cbb592cba Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 27 Jun 2025 19:01:22 +0300 Subject: [PATCH 014/161] feat(effects_service): - service in progress --- app/common/decorators.py | 47 ---- app/dependencies.py | 4 +- app/effects_api/effects_service.py | 142 ++++++---- app/effects_api/modules/context_service.py | 22 +- app/effects_api/modules/effects_service.py | 5 +- app/effects_api/modules/scenario_service.py | 22 +- .../modules/service_type_service.py | 12 +- app/effects_api/modules/urban_api_gateway.py | 163 ------------ app/gateways/__init__.py | 0 app/gateways/urban_api_gateway.py | 247 ++++++++++++++++++ 10 files changed, 366 insertions(+), 298 deletions(-) delete mode 100644 app/common/decorators.py delete mode 100644 app/effects_api/modules/urban_api_gateway.py create mode 100644 app/gateways/__init__.py create mode 100644 app/gateways/urban_api_gateway.py diff --git a/app/common/decorators.py b/app/common/decorators.py deleted file mode 100644 index 75e2752..0000000 --- a/app/common/decorators.py +++ /dev/null @@ -1,47 +0,0 @@ -import json -from functools import wraps - -import geopandas as gpd - -# from shapely import set_precision - -PRECISION_GRID_SIZE = 0.0001 - -def gdf_to_geojson(func): - """ - A decorator that processes a GeoDataFrame returned by an asynchronous function and converts it to GeoJSON format with specified CRS and geometry precision. - - This decorator takes an asynchronous function that returns a GeoDataFrame, transforms its coordinate system to EPSG:4326, - and optionally adjusts the geometry precision based on a defined grid size. The final result is a GeoJSON-compatible dictionary. - - Parameters - ---------- - func : Callable - An asynchronous function that returns a GeoDataFrame. - - Returns - ------- - Callable - A wrapped asynchronous function that returns the GeoDataFrame as a GeoJSON-compatible dictionary. - - Notes - ----- - - The decorator converts the GeoDataFrame to EPSG:4326 (WGS 84). - - Geometry precision is adjusted using the `set_precision` function and a grid size defined by `PRECISION_GRID_SIZE`. - - Commented-out code allows optional rounding for columns containing 'provision' in their name, if enabled. - - Examples - -------- - ``` - @gdf_to_geojson - async def get_geodata(): - # returns a GeoDataFrame - return gdf - ``` - """ - @wraps(func) - def process(*args, **kwargs): - gdf = func(*args, **kwargs).to_crs(4326) - # gdf.geometry = set_precision(gdf.geometry, grid_size=PRECISION_GRID_SIZE) - return json.loads(gdf.to_json()) - return process diff --git a/app/dependencies.py b/app/dependencies.py index efcaa8c..8bb7d88 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -3,7 +3,7 @@ from loguru import logger from iduconfig import Config -from app.common.api_handlers.json_api_handler import JSONAPIHandler +from app.gateways.urban_api_gateway import UrbanAPIGateway logger.remove() logger.add(sys.stderr, level="INFO") @@ -24,4 +24,4 @@ level="INFO", ) -urban_api_handler = JSONAPIHandler(config.get("URBAN_API")) +urban_api_gateway = UrbanAPIGateway(config.get("URBAN_API")) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 314c324..4444f7c 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,6 +1,7 @@ from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +from fastapi import HTTPException import pandas as pd import geopandas as gpd from blocksnet.machine_learning.regression import SocialRegressor @@ -19,6 +20,8 @@ from .modules.context_service import get_context_blocks, get_context_functional_zones, get_context_buildings, \ get_context_services from .modules.task_api_service import get_project_id +from ..common.exceptions.http_exception_wrapper import http_exception +from ..dependencies import urban_api_gateway class EffectsService: @@ -36,7 +39,6 @@ def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.Ge dr = DensityRegressor() density_df = dr.evaluate(blocks, adjacency_graph) - density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 @@ -163,67 +165,97 @@ async def aggregate_blocks_layer_scenario( blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) return blocks -async def get_services_layer(scenario_id: int): - blocks = await get_scenario_blocks(scenario_id) - blocks_crs = blocks.crs - logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") - service_types = await get_service_types() - logger.info(f"{service_types}") - services_dict = await get_scenario_services(scenario_id, service_types) - - for service_type, services in services_dict.items(): - services = services.to_crs(blocks_crs) - blocks_services, _ = aggregate_objects(blocks, services) - blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) - blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) - blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'objects_count': f'count_{service_type}', - })) - logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") - return blocks - -async def run_development_parameters(scenario_id: int) -> gpd.GeoDataFrame | pd.DataFrame: - blocks = await aggregate_blocks_layer_scenario(scenario_id) - for lu in LandUse: - blocks[lu.value] = blocks[lu.value].apply(lambda v: min(v, 1)) - logger.info(f"adjacency_graph scenario {scenario_id}") - adjacency_graph = generate_adjacency_graph(blocks, 10) - dr = DensityRegressor() - - logger.info(f"DensityRegressor scenario {scenario_id}") - density_df = dr.evaluate(blocks, adjacency_graph) - density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 - - density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 - density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 - - density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 - density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 - - density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 - density_df['site_area'] = blocks['site_area'] - - logger.info(f"Calculating density indicators for {scenario_id}") - development_df = calculate_development_indicators(density_df) - development_df['population'] = development_df['living_area'] // 20 - - - # mask = blocks['is_project'] - # columns = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] - # blocks.loc[mask, columns] = development_df.loc[mask, columns] - return development_df - - async def calc_project_development(self, params: DevelopmentDTO): + @staticmethod + async def get_services_layer(scenario_id: int): + blocks = await get_scenario_blocks(scenario_id) + blocks_crs = blocks.crs + logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") + service_types = await get_service_types() + logger.info(f"{service_types}") + services_dict = await get_scenario_services(scenario_id, service_types) + + for service_type, services in services_dict.items(): + services = services.to_crs(blocks_crs) + blocks_services, _ = aggregate_objects(blocks, services) + blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) + blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) + blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'objects_count': f'count_{service_type}', + })) + logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + return blocks + + async def run_development_parameters( + self, + scenario_id: int, + token: str, + zone_source: str, + zone_year: int, + ) -> gpd.GeoDataFrame | pd.DataFrame: + + blocks = await self.aggregate_blocks_layer_scenario(scenario_id) + for lu in LandUse: + blocks[lu.value] = blocks[lu.value].apply(lambda v: min(v, 1)) + logger.info(f"adjacency_graph scenario {scenario_id}") + adjacency_graph = generate_adjacency_graph(blocks, 10) + dr = DensityRegressor() + + logger.info(f"DensityRegressor scenario {scenario_id}") + density_df = dr.evaluate(blocks, adjacency_graph) + density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 + + density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 + density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 + + density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 + density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 + + density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 + density_df['site_area'] = blocks['site_area'] + + logger.info(f"Calculating density indicators for {scenario_id}") + development_df = calculate_development_indicators(density_df) + development_df['population'] = development_df['living_area'] // 20 + + return development_df + + async def calc_project_development(self, token: str, params: DevelopmentDTO): """ Function calculates development only for project with blocksnet Args: - params (DevelopmentDTO): + token (str): User token to access data from Urban API + params (DevelopmentDTO): development request params Returns: -- """ - pass + try: + if not params.proj_func_zone_source or not params.proj_func_zone_year: + ( + params.proj_func_zone_source, + params.proj_func_source_year + ) = await urban_api_gateway.get_optimap_func_zone_request_data() + res = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + token, + params.proj_func_zone_source, + params.proj_func_source_year + ) + return res + except HTTPException as http_e: + logger.exception(http_e) + raise http_e + except Exception as e: + logger.exception(e) + raise http_exception( + 500, + "Error during development calculation", + _input=params.__dict__, + _detail={ + "error": repr(e), + } + ) from e async def calc_context_development(self, params: ContextDevelopmentDTO): """ diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index e583036..3d49825 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -10,29 +10,29 @@ from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.scenario_service import _get_best_functional_zones_source, close_gaps from app.effects_api.modules.services_service import adapt_services -from app.effects_api.modules.urban_api_gateway import UrbanAPIGateway +from app.gateways.urban_api_gateway import urban_api_gateway LIVING_BUILDING_POT_ID = 4 async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame(geometry=[await UrbanAPIGateway.get_project_geometry(project_id)], crs=4326) + return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: - project = await UrbanAPIGateway.get_project(project_id) + project = await urban_api_gateway.get_project(project_id) context_ids = project['properties']['context'] - geometries = [await UrbanAPIGateway.get_territory_geometry(territory_id) for territory_id in context_ids] + geometries = [await urban_api_gateway.get_territory_geometry(territory_id) for territory_id in context_ids] return gpd.GeoDataFrame(geometry=geometries, crs=4326) async def _get_context_roads(project_id: int): - gdf = await UrbanAPIGateway.get_physical_objects_scenario(project_id, physical_object_function_id=26) + gdf = await urban_api_gateway.get_physical_objects_scenario(project_id, physical_object_function_id=26) return gdf[['geometry']].reset_index(drop=True) async def _get_context_water(project_id: int): - gdf = await UrbanAPIGateway.get_physical_objects_scenario(project_id, physical_object_function_id=4) + gdf = await urban_api_gateway.get_physical_objects_scenario(project_id, physical_object_function_id=4) return gdf[['geometry']].reset_index(drop=True) @@ -52,7 +52,7 @@ async def _get_context_blocks(project_id: int, boundaries: gpd.GeoDataFrame) -> async def get_context_blocks(project_id: int): - project_boundaries = await UrbanAPIGateway.get_project_geometry(project_id) + project_boundaries = await urban_api_gateway.get_project_geometry(project_id) context_boundaries = await _get_context_boundaries(project_id) crs = context_boundaries.estimate_utm_crs() @@ -64,20 +64,20 @@ async def get_context_blocks(project_id: int): async def get_context_functional_zones(project_id: int, token: str = None) -> gpd.GeoDataFrame: - sources_df = await UrbanAPIGateway.get_functional_zones_sources(project_id) + sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) year, source = _get_best_functional_zones_source(sources_df) - functional_zones = await UrbanAPIGateway.get_functional_zones_scenario(project_id, year, source) + functional_zones = await urban_api_gateway.get_functional_zones_scenario(project_id, year, source) return adapt_functional_zones(functional_zones) async def get_context_buildings(project_id: int): - gdf = await UrbanAPIGateway.get_physical_objects_scenario(project_id, physical_object_type_id=1, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects_scenario(project_id, physical_object_type_id=1, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) async def get_context_services(project_id: int, service_types: pd.DataFrame): - gdf = await UrbanAPIGateway.get_services_scenario(project_id, centers_only=True) + gdf = await urban_api_gateway.get_services_scenario(project_id, centers_only=True) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/effects_service.py b/app/effects_api/modules/effects_service.py index 5e3d755..c7a1cf9 100644 --- a/app/effects_api/modules/effects_service.py +++ b/app/effects_api/modules/effects_service.py @@ -1,6 +1,4 @@ import os -import math -from typing import Literal import geopandas as gpd import warnings @@ -13,7 +11,8 @@ from loguru import logger from app.effects_api.constants import const from app.effects_api.models import effects_models as em -from app.effects_api.modules import blocksnet_service as bs, urban_api_gateway as ps +from app.effects_api.modules import blocksnet_service as bs +from app.gateways import urban_api_gateway as ps for warning in [pd.errors.PerformanceWarning, RuntimeWarning, pd.errors.SettingWithCopyWarning, InsecureRequestWarning, FutureWarning]: diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 35da610..78994a9 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -12,7 +12,7 @@ from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.services_service import adapt_services -from app.effects_api.modules.urban_api_gateway import UrbanAPIGateway +from app.gateways.urban_api_gateway import urban_api_gateway SOURCES_PRIORITY = ['User', 'PZZ', 'OSM'] @@ -49,16 +49,16 @@ def close_gaps(gdf, tolerance): # taken from momepy async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame(geometry=[await UrbanAPIGateway.get_project_geometry(project_id)], crs=4326) + return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) async def _get_scenario_roads(scenario_id: int): - gdf = await UrbanAPIGateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=26) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=26) return gdf[['geometry']].reset_index(drop=True) async def _get_scenario_water(scenario_id: int): - gdf = await UrbanAPIGateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=4) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=4) return gdf[['geometry']].reset_index(drop=True) @@ -82,9 +82,9 @@ async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, async def _get_scenario_info(scenario_id: int) -> tuple[int, int]: - scenario = await UrbanAPIGateway.get_scenario(scenario_id) + scenario = await urban_api_gateway.get_scenario(scenario_id) project_id = scenario['project']['project_id'] - project = await UrbanAPIGateway.get_project(project_id) + project = await urban_api_gateway.get_project(project_id) base_scenario_id = project['base_scenario']['id'] return project_id, base_scenario_id @@ -109,16 +109,16 @@ async def get_scenario_blocks(user_scenario_id: int): return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries) -async def get_scenario_functional_zones(scenario_id: int, source: str = None, year: int = None) -> gpd.GeoDataFrame: - sources_df = await UrbanAPIGateway.get_functional_zones_sources_scenario(scenario_id) +async def get_scenario_functional_zones(scenario_id: int, token: str, source: str = None, year: int = None) -> gpd.GeoDataFrame: + sources_df = await urban_api_gateway.get_functional_zones_sources_scenario(scenario_id) year, source = await _get_best_functional_zones_source(sources_df) - functional_zones = await UrbanAPIGateway.get_functional_zones_scenario(scenario_id, year, source) + functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, year, source) return adapt_functional_zones(functional_zones) async def get_scenario_buildings(scenario_id: int): try: - gdf = await UrbanAPIGateway.get_physical_objects_scenario(scenario_id, physical_object_type_id=4, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, physical_object_type_id=4, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) @@ -128,7 +128,7 @@ async def get_scenario_buildings(scenario_id: int): async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame): try: - gdf = await UrbanAPIGateway.get_services_scenario(scenario_id, centers_only=True) + gdf = await urban_api_gateway.get_services_scenario(scenario_id, centers_only=True) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} except Exception as e: diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index d85b512..8a57f76 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,12 +1,12 @@ """Здесь предобработка типов сервисов для блокснета""" -from app.dependencies import urban_api_handler +from app.dependencies import urban_api_gateway import pandas as pd from blocksnet.config import service_types_config def get_indicators(parent_id: int | None = None, **kwargs): - res = urban_api_handler.get('/api/v1/indicators_by_parent', params={ + res = urban_api_gateway.get('/api/v1/indicators_by_parent', params={ 'parent_id': parent_id, **kwargs }) @@ -14,24 +14,24 @@ def get_indicators(parent_id: int | None = None, **kwargs): async def get_service_types(**kwargs): - res = await urban_api_handler.get('/api/v1/service_types', params=kwargs) + res = await urban_api_gateway.get('/api/v1/service_types', params=kwargs) return pd.DataFrame(res).set_index('service_type_id') def get_social_values(**kwargs): - res = urban_api_handler.get('/api/v1/social_values', params=kwargs) + res = urban_api_gateway.get('/api/v1/social_values', params=kwargs) return pd.DataFrame(res).set_index('soc_value_id') def get_social_value_service_types(soc_value_id: int, **kwargs): - res = urban_api_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) + res = urban_api_gateway.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) if len(res) == 0: return None return pd.DataFrame(res).set_index('service_type_id') def get_service_type_social_values(service_type_id: int, **kwargs): - res = urban_api_handler.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) + res = urban_api_gateway.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) if len(res) == 0: return None return pd.DataFrame(res).set_index('soc_value_id') diff --git a/app/effects_api/modules/urban_api_gateway.py b/app/effects_api/modules/urban_api_gateway.py deleted file mode 100644 index f16458b..0000000 --- a/app/effects_api/modules/urban_api_gateway.py +++ /dev/null @@ -1,163 +0,0 @@ -import json -import geopandas as gpd -import pandas as pd -import shapely -from typing import Any, Dict -from app.common.exceptions.http_exception_wrapper import http_exception -from app.dependencies import urban_api_handler - - - -class UrbanAPIGateway: - # TODO context - @staticmethod - async def get_physical_objects( - project_id: int, - **kwargs: Any - ) -> gpd.GeoDataFrame: - res = await urban_api_handler.get( - f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", - params=kwargs - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("physical_object_id") - ) - - @staticmethod - async def get_services( - project_id: int, - **kwargs: Any - ) -> gpd.GeoDataFrame: - res = await urban_api_handler.get( - f"/api/v1/projects/{project_id}/context/services_with_geometry", - params=kwargs - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("service_id") - ) - - @staticmethod - async def get_functional_zones_sources( - project_id: int - ) -> pd.DataFrame: - res = await urban_api_handler.get( - f"/api/v1/projects/{project_id}/context/functional_zone_sources" - ) - return pd.DataFrame(res) - - @staticmethod - async def get_functional_zones( - project_id: int, - year: int, - source: int - ) -> gpd.GeoDataFrame: - res = await urban_api_handler.get( - f"/api/v1/projects/{project_id}/context/functional_zones", - params={"year": year, "source": source} - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("functional_zone_id") - ) - - @staticmethod - async def get_project(project_id: int) -> Dict[str, Any]: - res = await urban_api_handler.get(f"/api/v1/projects/{project_id}") - return res - - @staticmethod - async def get_project_geometry(project_id: int): - res = await urban_api_handler.get( - f"/api/v1/projects/{project_id}/territory" - ) - geometry_json = json.dumps(res["geometry"]) - return shapely.from_geojson(geometry_json) - - # TODO scenario - @staticmethod - async def get_scenario(scenario_id: int) -> Dict[str, Any]: - res = await urban_api_handler.get(f"/api/v1/scenarios/{scenario_id}") - return res - - @staticmethod - async def get_functional_zones_sources_scenario( - scenario_id: int - ) -> pd.DataFrame: - res = await urban_api_handler.get( - f"/api/v1/scenarios/{scenario_id}/functional_zone_sources" - ) - return pd.DataFrame(res) - - @staticmethod - async def get_functional_zones_scenario( - scenario_id: int, - year: int, - source: str - ) -> gpd.GeoDataFrame: - res = await urban_api_handler.get( - f"/api/v1/scenarios/{scenario_id}/functional_zones", - params={"year": year, "source": source} - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("functional_zone_id") - ) - - @staticmethod - async def get_project_id_by_scenario_id(scenario_id: int, token: str = None) -> int: - endpoint = f"/api/v1/scenarios/{scenario_id}" - response = await urban_api_handler.get(endpoint, headers={ - "Authorization": f"Bearer {token}""" - }) - try: - project_id = response.get("project", {}).get("project_id") - except Exception: - raise http_exception(404, "Project ID is missing in scenario data.", scenario_id) - - return project_id - - @staticmethod - async def get_physical_objects_scenario( - scenario_id: int, - **kwargs: Any - ) -> gpd.GeoDataFrame: - params = { - k: (str(v).lower() if isinstance(v, bool) else v) - for k, v in kwargs.items() - } - res = await urban_api_handler.get( - f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", - params=params - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("physical_object_id") - ) - - @staticmethod - async def get_services_scenario( - scenario_id: int, - **kwargs: Any - ) -> gpd.GeoDataFrame: - res = await urban_api_handler.get( - f"/api/v1/scenarios/{scenario_id}/services_with_geometry", - params=kwargs - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("service_id") - ) - - - - -# Экземпляр для удобства -UrbanAPIGateway = UrbanAPIGateway() \ No newline at end of file diff --git a/app/gateways/__init__.py b/app/gateways/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py new file mode 100644 index 0000000..5e64ea5 --- /dev/null +++ b/app/gateways/urban_api_gateway.py @@ -0,0 +1,247 @@ +import json +import geopandas as gpd +import pandas as pd +import shapely +from typing import Any, Dict, Literal + +from sqlalchemy.util import await_only + +from app.common.api_handlers.json_api_handler import JSONAPIHandler +from app.common.exceptions.http_exception_wrapper import http_exception + + + +class UrbanAPIGateway: + + def __init__(self, base_url: str) -> None: + self.json_handler = JSONAPIHandler(base_url) + + # TODO context + async def get_physical_objects( + self, + project_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", + params=kwargs + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("physical_object_id") + ) + + async def get_services( + self, + project_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/context/services_with_geometry", + params=kwargs + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("service_id") + ) + + async def get_functional_zones_sources( + self, + project_id: int + ) -> pd.DataFrame: + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/context/functional_zone_sources" + ) + return pd.DataFrame(res) + + async def get_functional_zones( + self, + project_id: int, + year: int, + source: int + ) -> gpd.GeoDataFrame: + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/context/functional_zones", + params={"year": year, "source": source} + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("functional_zone_id") + ) + + + async def get_project(self, project_id: int) -> Dict[str, Any]: + res = await self.json_handler.get(f"/api/v1/projects/{project_id}") + return res + + async def get_project_geometry(self, project_id: int): + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/territory" + ) + geometry_json = json.dumps(res["geometry"]) + return shapely.from_geojson(geometry_json) + + # TODO scenario + async def get_scenario(self, scenario_id: int) -> Dict[str, Any]: + res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}") + return res + + async def get_functional_zones_sources_scenario( + self, + scenario_id: int, + token: str, + source: str, + year: int + ) -> pd.DataFrame: + headers = {"Authorization": f"Bearer {token}"} + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}/functional_zone_sources", + headers=headers, + params={ + "year": year, + "source": source, + } + ) + return pd.DataFrame(res) + + async def get_functional_zones_scenario( + self, + scenario_id: int, + year: int, + source: str + ) -> gpd.GeoDataFrame: + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}/functional_zones", + params={"year": year, "source": source} + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("functional_zone_id") + ) + + async def get_physical_objects_scenario( + self, + scenario_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + params = { + k: (str(v).lower() if isinstance(v, bool) else v) + for k, v in kwargs.items() + } + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", + params=params + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("physical_object_id") + ) + + async def get_services_scenario( + self, + scenario_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}/services_with_geometry", + params=kwargs + ) + features = res["features"] + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("service_id") + ) + + async def get_optimap_func_zone_request_data( + self, + token: str, + scenario_id: int, + source: Literal["PZZ", "OSM", "User"] | None, + year: int | None, + project: bool = True + ) -> tuple[str, int]: + """ + Function retrieves best matching zone sources based on given source and year. + Args: + token (str): user token to access data in Urban API. + scenario_id (int): id of scenario to retrieve data by. + source (Literal["PZZ", "OSM", "User"] | None): Functional zone source from urban api. If None in order + User -> PZZ -> OSM priority is retrieved. + year (int | None): year to retrieve zones for. If None retrieves latest available year. + project (bool): If True retrieves with User source. + Returns: + tuple[str, int]: Tuple with source and year. + """ + + async def get_optimal_source( + sources_data: pd.DataFrame, + target_year: int | None, + is_project: bool + ) -> tuple[str, int]: + """ + Function estimates the best source and year + Args: + sources_data (pd.DataFrame): DataFrame containing functional zone sources + target_year (int): year to retrieve zones for. If None retrieves latest available year. + is_project (bool): If True retrieves with User source + Returns: + tuple[str, int]: Tuple with source and year. + Raises: + Any from Urban API + 404, if function couldn't match optimal source + """ + + if project: + sources_priority = ["User", "PZZ", "OSM"] + else: + sources_priority = ["PZZ", "OSM"] + for i in sources_priority: + if i in sources_data["source"].unique(): + sources_data = sources_data[sources_data["source"] == i] + source_name = sources_data["source"].iloc[0] + if year: + source_year = sources_data[sources_data["year"] == target_year].iloc[0] + else: + source_year = sources_data["year"].max() + return source_name, source_year + raise http_exception( + 404, + "No source found", + _input={ + "source": source, + "year": year, + "is_project": is_project, + } + ) + + if not project and source == "User": + raise http_exception( + 500, + "Unreachable functional zones source for non-project data", + _input={ + "source": source, + "year": year, + "project": project, + }, + _detail={ + "available_sources": ["PZZ", "OSM"], + } + ) + headers = {"Authorization": f"Bearer {token}"} + available_sources = await self.json_handler.get( + f"/api/v1/{scenario_id}/available_sources", + headers=headers + ) + sources_df = pd.DataFrame.from_records(available_sources) + if not source: + return await get_optimal_source(sources_df, year, project) + else: + source_df = sources_df[sources_df["source"] == source] + return await get_optimal_source(source_df, year, project) + + From a220cddd82ebad6764d461e415002f0b1247ac23 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Sun, 29 Jun 2025 21:05:56 +0300 Subject: [PATCH 015/161] refactor(WIP_4): - Featured logic for calculating socio-economic effects in effects_service - Deleted unnecessary files - Added socio-economic calculation controller with DTO --- Dockerfile | 2 +- app/effects_api/constants/const.py | 137 +++-- app/effects_api/dto/development_dto.py | 45 +- app/effects_api/dto/socio_economic_dto.py | 43 ++ app/effects_api/effects_controller.py | 23 +- app/effects_api/effects_service.py | 516 +++++++++++------- app/effects_api/modules/blocksnet_service.py | 316 ----------- app/effects_api/modules/buildings_service.py | 40 +- app/effects_api/modules/context_service.py | 27 +- app/effects_api/modules/effects_service.py | 120 ---- app/effects_api/modules/f22_service.py | 116 ---- .../modules/functional_sources_service.py | 15 - app/effects_api/modules/scenario_service.py | 103 ++-- .../modules/service_type_service.py | 137 +---- app/effects_api/modules/task_api_service.py | 52 -- app/gateways/urban_api_gateway.py | 190 ++++++- app/main.py | 14 +- testing.ipynb | 37 -- 18 files changed, 787 insertions(+), 1146 deletions(-) create mode 100644 app/effects_api/dto/socio_economic_dto.py delete mode 100644 app/effects_api/modules/blocksnet_service.py delete mode 100644 app/effects_api/modules/effects_service.py delete mode 100644 app/effects_api/modules/f22_service.py delete mode 100644 app/effects_api/modules/task_api_service.py delete mode 100644 testing.ipynb diff --git a/Dockerfile b/Dockerfile index 3d36c8c..fcc007c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,4 +26,4 @@ WORKDIR /app COPY . /app # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug -CMD ["gunicorn", "--bind", "0.0.0.0:80", "-k", "uvicorn.workers.UvicornWorker", "--workers", "2", "app.main:app"] +CMD ["gunicorn", "--bind", "0.0.0.0:80", "-k", "uvicorn.workers.UvicornWorker", "--workers", "1", "app.main:app"] diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index e8f8e67..a2ea7b3 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -1,38 +1,113 @@ from blocksnet.enums import LandUse - -LU_SHARE = 0.5 -SQ_M_IN_HA = 10_000 - - -mapping = { - 'residential': LandUse.RESIDENTIAL, - 'recreation': LandUse.RECREATION, - 'special': LandUse.SPECIAL, - 'industrial': LandUse.INDUSTRIAL, - 'agriculture': LandUse.AGRICULTURE, - 'transport': LandUse.TRANSPORT, - 'business': LandUse.BUSINESS, - 'residential_individual': LandUse.RESIDENTIAL, - 'residential_lowrise': LandUse.RESIDENTIAL, - 'residential_midrise': LandUse.RESIDENTIAL, - 'residential_multistorey': LandUse.RESIDENTIAL, - } +# UrbanDB to Blocksnet land use types mapping +LAND_USE_RULES = { + 'residential': LandUse.RESIDENTIAL, + 'recreation': LandUse.RECREATION, + 'special': LandUse.SPECIAL, + 'industrial': LandUse.INDUSTRIAL, + 'agriculture': LandUse.AGRICULTURE, + 'transport': LandUse.TRANSPORT, + 'business': LandUse.BUSINESS, + 'residential_individual': LandUse.RESIDENTIAL, + 'residential_lowrise': LandUse.RESIDENTIAL, + 'residential_midrise': LandUse.RESIDENTIAL, + 'residential_multistorey': LandUse.RESIDENTIAL, +} -residential_mapping = { - 'residential': (250,350), - 'residential_individual': (30,35), - 'residential_lowrise': (50,150), - 'residential_midrise': (250,350), - 'residential_multistorey': (350,450), +# UrbanDB to Blocksnet service mapping +SERVICE_TYPES_MAPPING = { + # basic + 1: 'park', + 21: 'kindergarten', + 22: 'school', + 28: 'polyclinic', + 34: 'pharmacy', + 61: 'cafe', + 66: 'pitch', + 68: None, # спортивный зал + 74: 'playground', + 78: 'police', + # additional + 30: None, # стоматология + 35: 'hospital', + 50: 'museum', + 56: 'cinema', + 57: 'mall', + 59: 'stadium', + 62: 'restaurant', + 63: 'bar', + 77: None, # скейт парк + 79: None, # пожарная станция + 80: 'train_station', + 89: 'supermarket', + 99: None, # пункт выдачи + 100: 'bank', + 107: 'veterinary', + 143: 'sanatorium', + # comfort + 5: 'beach', + 27: 'university', + 36: None, # роддом + 48: 'library', + 51: 'theatre', + 91: 'market', + 93: None, # одежда и обувь + 94: None, # бытовая техника + 95: None, # книжный магазин + 96: None, # детские товары + 97: None, # спортивный магазин + 108: None, # зоомагазин + 110: 'hotel', + 114: 'religion', # религиозный объект + # others + 26: None, # ССУЗ + 32: None, # женская консультация + 39: None, # скорая помощь + 40: None, # травматология + 45: 'recruitment', + 47: 'multifunctional_center', + 55: 'zoo', + 65: 'bakery', + 67: 'swimming_pool', + 75: None, # парк аттракционов + 81: 'train_building', + 82: 'aeroway_terminal', # аэропорт?? + 86: 'bus_station', + 88: 'subway_entrance', + 102: 'lawyer', + 103: 'notary', + 109: 'dog_park', + 111: 'hostel', + 112: None, # база отдыха + 113: None, # памятник } -DUMMY_BUILDING_PARAMS = { - 'id' : -1, - 'build_floor_area' : 0, - 'living_area' : 0, - 'non_living_area' : 0, - 'footprint_area' : 0, - 'number_of_floors' : 1, +# Rules for agregating building properties from UrbanDB API +BUILDINGS_RULES = { + 'number_of_floors': [ + ['floors'], + ['properties', 'storeys_count'], + ['properties', 'osm_data', 'building:levels'] + ], + 'footprint_area': [ + ['building_area_official'], + ['building_area_modeled'], + ['properties', 'area_total'] + ], + 'build_floor_area': [ + ['properties', 'area_total'], + ], + 'living_area': [ + ['properties', 'living_area_official'], + ['properties', 'living_area'], + ['properties', 'living_area_modeled'] + ], + 'non_living_area': [ + ['properties', 'area_non_residential'], + ], + 'population': [ + ['properties', 'population_balanced'] + ] } diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index ca06997..224bf7d 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -1,32 +1,39 @@ -from typing import Literal - +from typing import Literal, Optional from pydantic import BaseModel, Field class DevelopmentDTO(BaseModel): - - scenario_id: Field(..., examples=[822], description="Project scenario id to retrieve data from.") - proj_func_zone_source: Literal["PZZ", "OSM", "User"] = Field( - default=None, - examples=["User", "PZZ", "OSM",], - description="Project functional zones source to retrieve data from. Default retrieves in priority User -> PZZ -> OSM" + scenario_id: int = Field( + ..., + examples=[822], + description="Project-scenario ID to retrieve data from.", + ) + proj_func_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( + None, + examples=["User", "PZZ", "OSM"], + description=( + "Preferred source for project functional zones. " + "Default priority: User → PZZ → OSM." + ), ) - proj_func_source_year: int = Field( - default=None, + proj_func_source_year: Optional[int] = Field( + None, examples=[2023, 2024], - description="Project functional zones source year to retrieve data from. As default retrieves latest year." + description="Year of the chosen project functional-zone source.", ) class ContextDevelopmentDTO(DevelopmentDTO): - - context_func_zone_source: Literal["PZZ", "OSM", "User"] = Field( - default=None, + context_func_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( + None, examples=["PZZ", "OSM"], - description="Project functional zones source to retrieve data from. As default retrieves in priority PZZ -> OSM" + description=( + "Preferred source for context functional zones. " + "Default priority: PZZ → OSM." + ), ) - context_func_source_year: int = Field( - default=None, + context_func_source_year: Optional[int] = Field( + None, examples=[2023, 2024], - description="Context functional zones source year to retrieve data from. Default retrieves latest year." - ) + description="Year of the chosen context functional-zone source.", + ) \ No newline at end of file diff --git a/app/effects_api/dto/socio_economic_dto.py b/app/effects_api/dto/socio_economic_dto.py new file mode 100644 index 0000000..424718a --- /dev/null +++ b/app/effects_api/dto/socio_economic_dto.py @@ -0,0 +1,43 @@ +from typing import Literal, Optional +from pydantic import BaseModel, Field + + +class SocioEconomicDTO(BaseModel): + """Input payload for socio-economic evaluation endpoint.""" + scenario_id: int = Field( + ..., + examples=[788], + description="Project-scenario ID to retrieve data from.", + ) + project_functional_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( + None, + examples=["User", "PZZ", "OSM"], + description=( + "Preferred source for PROJECT functional zones. " + "If omitted: priority User, PZZ, OSM." + ), + ) + project_functional_zone_year: Optional[int] = Field( + None, + examples=[2023, 2024], + description=( + "Year of the chosen PROJECT functional-zone source. " + "If omitted, the newest year is used." + ), + ) + context_functional_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( + None, + examples=["PZZ", "OSM"], + description=( + "Preferred source for CONTEXT functional zones. " + "If omitted: priority PZZ → OSM." + ), + ) + context_functional_zone_year: Optional[int] = Field( + None, + examples=[2023, 2024], + description=( + "Year of the chosen CONTEXT functional-zone source. " + "If omitted, the newest year is used." + ), + ) \ No newline at end of file diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 3cffe3f..e913090 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -1,11 +1,14 @@ from typing import Annotated from fastapi import APIRouter +from fastapi.encoders import jsonable_encoder from fastapi.params import Depends +from starlette.responses import JSONResponse from app.common.auth.auth import verify_token from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO -from ..common.auth import auth +from .dto.socio_economic_dto import SocioEconomicDTO +from .effects_service import EffectsService development_router = APIRouter(prefix='/redevelopment', tags=['Effects']) @@ -26,10 +29,16 @@ async def get_context_redevelopment( @development_router.get("/socio_economic_prediction") async def get_socio_economic_prediction( - scenario_id: int, - functional_zone_source: str, - functional_zone_year: int, - context_functional_zone_source: str, - context_functional_zone_year: int, - token: str = Depends(auth.verify_token), + params: Annotated[SocioEconomicDTO, Depends(SocioEconomicDTO)], + token: str = Depends(verify_token), ): + socio_economic_prediction = await EffectsService.evaluate_master_plan( + params.scenario_id, + params.project_functional_zone_source, + params.project_functional_zone_year, + params.context_functional_zone_source, + params.context_functional_zone_year, + token + ) + payload = jsonable_encoder(socio_economic_prediction.to_dict(orient="index")) + return JSONResponse(content=payload) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 4444f7c..e7813d8 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,7 +1,5 @@ -from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +# from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO - -from fastapi import HTTPException import pandas as pd import geopandas as gpd from blocksnet.machine_learning.regression import SocialRegressor @@ -13,14 +11,14 @@ from blocksnet.machine_learning.regression import DensityRegressor from blocksnet.relations import generate_adjacency_graph -from app.effects_api.modules.functional_sources_service import LAND_USE_RULES +from .constants.const import LAND_USE_RULES + from app.effects_api.modules.scenario_service import get_scenario_blocks, get_scenario_functional_zones, \ get_scenario_services, get_scenario_buildings -from app.effects_api.modules.service_type_service import get_service_types +from app.effects_api.modules.service_type_service import adapt_service_types from .modules.context_service import get_context_blocks, get_context_functional_zones, get_context_buildings, \ get_context_services -from .modules.task_api_service import get_project_id -from ..common.exceptions.http_exception_wrapper import http_exception + from ..dependencies import urban_api_gateway @@ -29,150 +27,168 @@ class EffectsService: def __init__(self): pass -def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: - logger.info('Evaluating master plan effects') - blocks = buildings_blocks_gdf.join( - blocks.drop(columns="geometry"), - how="left" - ) - adjacency_graph = generate_adjacency_graph(blocks, 10) - dr = DensityRegressor() - density_df = dr.evaluate(blocks, adjacency_graph) - - density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 - density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 - density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 - density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 - density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 - density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 - - blocks['site_area'] = blocks.area - density_df['site_area'] = blocks['site_area'] - development_df = calculate_development_indicators(density_df) - development_df['population'] = development_df['living_area'] // 20 - cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] - blocks[cols] = development_df[cols].values - for lu in LandUse: - blocks[lu.value] = blocks[lu.value] * blocks['site_area'] - data = [blocks.drop(columns=['land_use', 'geometry']).sum().to_dict()] - input = pd.DataFrame(data) - - input['latitude'] = blocks.geometry.union_all().centroid.x - input['longitude'] = blocks.geometry.union_all().centroid.y - input['buildings_count'] = input['count'] - sr = SocialRegressor() - y_pred, pi_lower, pi_upper = sr.evaluate(input) - iloc = 0 - result_data = { - 'pred': y_pred.apply(round).astype(int).iloc[iloc].to_dict(), - 'lower': pi_lower.iloc[iloc].to_dict(), - 'upper': pi_upper.iloc[iloc].to_dict(), - } - result_df = pd.DataFrame.from_dict(result_data) - result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) - - return result_df - -async def aggregate_blocks_layer_scenario_context( - scenario_id: int, - functional_zone_source: str, - functional_zone_year: int, - context_functional_zone_source: str, - context_functional_zone_year: int, - token: str = None, - ) -> gpd.GeoDataFrame: - logger.info(f"Aggregating blocks layer scenario {scenario_id}") - project_id = await get_project_id(scenario_id) - - scenario_blocks = await get_scenario_blocks(scenario_id) - scenario_blocks_crs = scenario_blocks.crs - scenario_blocks['site_area'] = scenario_blocks.area - - scenario_functional_zones = await get_scenario_functional_zones(scenario_id) - scenario_functional_zones = scenario_functional_zones.to_crs(scenario_blocks_crs) - scenario_blocks_lu = assign_land_use(scenario_blocks, scenario_functional_zones, LAND_USE_RULES) - scenario_blocks = scenario_blocks.join(scenario_blocks_lu.drop(columns=['geometry'])) - - scenario_buildings = await get_scenario_buildings(scenario_id) - scenario_buildings = scenario_buildings.to_crs(scenario_blocks_crs) - if scenario_buildings is not None: - scenario_buildings = scenario_buildings.to_crs(scenario_blocks.crs) - blocks_buildings, _ = aggregate_objects(scenario_blocks, scenario_buildings) - scenario_blocks = scenario_blocks.join( - blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) - scenario_blocks['count_buildings'] = scenario_blocks['count_buildings'].fillna(0).astype(int) - - service_types = await get_service_types() - scenario_services_dict = await get_scenario_services(scenario_id, service_types) - - for service_type, services in scenario_services_dict.items(): - services = services.to_crs(scenario_blocks.crs) - scenario_blocks_services, _ = aggregate_objects(scenario_blocks, services) - scenario_blocks_services['capacity'] = scenario_blocks_services['capacity'].fillna(0).astype(int) - scenario_blocks_services['count'] = scenario_blocks_services['count'].fillna(0).astype(int) - scenario_blocks = scenario_blocks.join(scenario_blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'count': f'count_{service_type}', - })) - - #---------------------------------------------------------# - - context_blocks = await get_context_blocks(project_id) - context_blocks = context_blocks.to_crs(scenario_blocks_crs) - context_blocks['site_area'] = context_blocks.area - - context_functional_zones = await get_context_functional_zones(project_id) - context_functional_zones = context_functional_zones.to_crs(scenario_blocks_crs) - context_blocks_lu = assign_land_use(context_blocks, context_functional_zones, LAND_USE_RULES) - context_blocks = context_blocks.join(context_blocks_lu.drop(columns=['geometry'])) - - context_buildings = await get_context_buildings(project_id) - context_buildings = context_buildings.to_crs(scenario_blocks_crs) - if context_buildings is not None: - context_buildings = context_buildings.to_crs(context_blocks.crs) - context_blocks_buildings, _ = aggregate_objects(context_blocks, context_buildings) - context_blocks = context_blocks.join( - context_blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) - context_blocks['count_buildings'] = context_blocks['count_buildings'].fillna(0).astype(int) - - context_services_dict = await get_context_services(project_id, service_types) - for service_type, services in context_services_dict.items(): - services = services.to_crs(context_blocks.crs) - context_blocks_services, _ = aggregate_objects(scenario_blocks, services) - context_blocks_services['capacity'] = context_blocks_services['capacity'].fillna(0).astype(int) - context_blocks_services['count'] = context_blocks_services['count'].fillna(0).astype(int) - context_blocks = context_blocks.join(context_blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'count': f'count_{service_type}', - })) - buildings_gdf = scenario_buildings.join(context_buildings.drop(columns=['geometry']).rename(columns={})) - blocks = context_blocks.join(scenario_blocks) - - return blocks - -async def aggregate_blocks_layer_scenario( - scenario_id: int, - token: str = None, - functional_zone_source: str = None, - functional_zone_year: int = None) -> gpd.GeoDataFrame: - logger.info(f"Aggregating blocks layer scenario {scenario_id}") - blocks = await get_scenario_blocks(scenario_id) - blocks_crs = blocks.crs - blocks['site_area'] = blocks.area - functional_zones = await get_scenario_functional_zones(scenario_id) - functional_zones = functional_zones.to_crs(blocks_crs) - blocks_lu = assign_land_use(blocks, functional_zones, LAND_USE_RULES) - blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) - return blocks + @staticmethod + async def aggregate_blocks_layer_scenario( + scenario_id: int, + functional_zone_source: str = None, + functional_zone_year: int = None, + token: str = None + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + """ + Params: + scenario_id : int + ID of the scenario whose blocks are processed. + functional_zone_source : str | None, default None + Preferred source of functional-zone polygons + (e.g. "PZZ", "OSM", "User"). + If None, the helper picks the best available source (PZZ). + functional_zone_year : int | None, default None + Year of the functional-zone dataset. `None` → latest available. + token : str | None, default None + Optional bearer token passed to Urban API. + + Returns: + tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] + blocks_gdf– scenario blocks enriched with: + site_area, land-use shares, building counts and per-service + capacities/ counts. + buildings_gdf – original (optionally projected) buildings + used for aggregation.""" + + logger.info(f"Aggregating unified blocks layer for scenario {scenario_id}") + + logger.info("Starting generating scenario blocks layer") + scenario_blocks_gdf = await get_scenario_blocks(scenario_id, token) + scenario_blocks_crs = scenario_blocks_gdf.crs + scenario_blocks_gdf['site_area'] = scenario_blocks_gdf.area + + scenario_functional_zones = await get_scenario_functional_zones(scenario_id, token, functional_zone_source, functional_zone_year) + scenario_functional_zones = scenario_functional_zones.to_crs(scenario_blocks_crs) + scenario_blocks_lu = assign_land_use(scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES) + scenario_blocks_gdf = scenario_blocks_gdf.join(scenario_blocks_lu.drop(columns=['geometry'])) + logger.success(f"Land use for scenario blocks have been assigned {scenario_id}") + + scenario_buildings_gdf = await get_scenario_buildings(scenario_id, token) + scenario_buildings_gdf = scenario_buildings_gdf.to_crs(scenario_blocks_crs) + if scenario_buildings_gdf is not None: + scenario_buildings_gdf = scenario_buildings_gdf.to_crs(scenario_blocks_gdf.crs) + blocks_buildings, _ = aggregate_objects(scenario_blocks_gdf, scenario_buildings_gdf) + scenario_blocks_gdf = scenario_blocks_gdf.join( + blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) + scenario_blocks_gdf['count_buildings'] = scenario_blocks_gdf['count_buildings'].fillna(0).astype(int) + + logger.success(f"Buildings for scenario blocks have been aggregated {scenario_id}") + + service_types = await urban_api_gateway.get_service_types() + service_types = await adapt_service_types(service_types) + + scenario_services_dict = await get_scenario_services(scenario_id, service_types, token) + + for service_type, services in scenario_services_dict.items(): + services = services.to_crs(scenario_blocks_gdf.crs) + scenario_blocks_services, _ = aggregate_objects(scenario_blocks_gdf, services) + scenario_blocks_services['capacity'] = scenario_blocks_services['capacity'].fillna(0).astype(int) + scenario_blocks_services['count'] = scenario_blocks_services['count'].fillna(0).astype(int) + scenario_blocks_gdf = scenario_blocks_gdf.join(scenario_blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'count': f'count_{service_type}', + })) + + scenario_blocks_gdf['is_project'] = True + logger.success(f"Services for scenario blocks have been aggregated {scenario_id}") + + return scenario_blocks_gdf, scenario_buildings_gdf @staticmethod - async def get_services_layer(scenario_id: int): - blocks = await get_scenario_blocks(scenario_id) + async def aggregate_blocks_layer_context( + scenario_id: int, + context_functional_zone_source: str = None, + context_functional_zone_year: int = None, + token: str = None + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + """Build a GeoDataFrame for context blocks (territories neighbouring the + project) and enrich it with land-use, building and service attributes. + + Params: + scenario_id : int + Parent scenario (used only to fetch project ID). + context_functional_zone_source : str | None, default None + Functional-zone source for context territories. + context_functional_zone_year : int | None, default None + Year of the functional-zone dataset for context territories. + token : str | None, default None + Optional bearer token for Urban API. + + Returns: + tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] + context_blocks_gdf – enriched context blocks. + context_buildings_gdf – buildings aggregated into the blocks + """ + + logger.info("Starting generating context blocks layer") + project_id = await urban_api_gateway.get_project_id(scenario_id, token) + context_blocks_gdf = await get_context_blocks(project_id) + context_blocks_crs = context_blocks_gdf.crs + context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) + context_blocks_gdf['site_area'] = context_blocks_gdf.area + + context_functional_zones = await get_context_functional_zones(project_id, context_functional_zone_source, context_functional_zone_year, token) + context_functional_zones = context_functional_zones.to_crs(context_blocks_crs) + context_blocks_lu = assign_land_use(context_blocks_gdf, context_functional_zones, LAND_USE_RULES) + context_blocks_gdf = context_blocks_gdf.join(context_blocks_lu.drop(columns=['geometry'])) + logger.success(f"Land use for context blocks have been assigned {scenario_id}") + + context_buildings_gdf = await get_context_buildings(project_id) + context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_crs) + + if context_buildings_gdf is not None: + context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) + context_blocks_buildings, _ = aggregate_objects(context_blocks_gdf, context_buildings_gdf) + context_blocks_gdf = context_blocks_gdf.join( + context_blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) + context_blocks_gdf['count_buildings'] = context_blocks_gdf['count_buildings'].fillna(0).astype(int) + logger.success(f"Buildings for context blocks have been aggregated {scenario_id}") + + service_types = await urban_api_gateway.get_service_types() + service_types = await adapt_service_types(service_types) + context_services_dict = await get_context_services(project_id, service_types) + + for service_type, services in context_services_dict.items(): + services = services.to_crs(context_blocks_gdf.crs) + context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) + context_blocks_services['capacity'] = context_blocks_services['capacity'].fillna(0).astype(int) + context_blocks_services['count'] = context_blocks_services['count'].fillna(0).astype(int) + context_blocks_gdf = context_blocks_gdf.join(context_blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'count': f'count_{service_type}', + })) + logger.success(f"Services for context blocks have been aggregated {scenario_id}") + + return context_blocks_gdf, context_buildings_gdf + + + @staticmethod + async def get_services_layer(scenario_id: int, token: str): + """ + Fetch every service layer for a scenario, aggregate counts/capacities + into the scenario blocks and return the resulting block layer. + + Params: + scenario_id : int + Scenario whose services are queried and aggregated. + + Returns: + gpd.GeoDataFrame + Scenario block layer with additional columns + `capacity_` and `count_` for each + detected service category. + """ + blocks = await get_scenario_blocks(scenario_id, token) blocks_crs = blocks.crs logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") - service_types = await get_service_types() + service_types = await urban_api_gateway.get_service_types() logger.info(f"{service_types}") - services_dict = await get_scenario_services(scenario_id, service_types) + services_dict = await get_scenario_services(scenario_id, service_types, token) for service_type, services in services_dict.items(): services = services.to_crs(blocks_crs) @@ -186,23 +202,38 @@ async def get_services_layer(scenario_id: int): logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") return blocks + @staticmethod async def run_development_parameters( - self, - scenario_id: int, - token: str, - zone_source: str, - zone_year: int, - ) -> gpd.GeoDataFrame | pd.DataFrame: + blocks_gdf: gpd.GeoDataFrame, + ) -> pd.DataFrame: + """ + Compute core *development* indicators (FSI, GSI, MXI, etc.) for each + block and derive population estimates. + + The routine: + 1. Clips every land-use share to [0, 1]. + 2. Generates an adjacency graph (10 m tolerance). + 3. Uses DensityRegressor to predict density indices. + 4. Converts indices into built-area, footprint, living area, etc. + 5. Estimates population by living_area // 20. + + Params: + blocks_gdf : gpd.GeoDataFrame + Block layer already containing per-land-use **shares** + (0 ≤ share ≤ 1) and `site_area`. - blocks = await self.aggregate_blocks_layer_scenario(scenario_id) + Returns: + pd.DataFrame with added columns: + `build_floor_area`, `footprint_area`, `living_area`, + `non_living_area`, `population`, plus the original density indices. + """ for lu in LandUse: - blocks[lu.value] = blocks[lu.value].apply(lambda v: min(v, 1)) - logger.info(f"adjacency_graph scenario {scenario_id}") - adjacency_graph = generate_adjacency_graph(blocks, 10) + blocks_gdf[lu.value] = blocks_gdf[lu.value].apply(lambda v: min(v, 1)) + + adjacency_graph = generate_adjacency_graph(blocks_gdf, 10) dr = DensityRegressor() - logger.info(f"DensityRegressor scenario {scenario_id}") - density_df = dr.evaluate(blocks, adjacency_graph) + density_df = dr.evaluate(blocks_gdf, adjacency_graph) density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 @@ -211,59 +242,138 @@ async def run_development_parameters( density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 - density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 - density_df['site_area'] = blocks['site_area'] + density_df.loc[blocks_gdf['residential'] == 0, 'mxi'] = 0 + density_df['site_area'] = blocks_gdf['site_area'] - logger.info(f"Calculating density indicators for {scenario_id}") development_df = calculate_development_indicators(density_df) development_df['population'] = development_df['living_area'] // 20 return development_df - async def calc_project_development(self, token: str, params: DevelopmentDTO): - """ - Function calculates development only for project with blocksnet - Args: - token (str): User token to access data from Urban API - params (DevelopmentDTO): development request params - Returns: - -- + @staticmethod + async def evaluate_master_plan( + scenario_id: int, + functional_zone_source: str = None, + functional_zone_year: int = None, + context_functional_zone_source: str = None, + context_functional_zone_year: int = None, + token: str = None + ) -> pd.DataFrame: """ + End-to-end pipeline that fuses *project* and *context* blocks, enriches + them with development parameters and produces socio-economic forecasts + via ``SocialRegressor``. + + Params: + scenario_id : int + Scenario to evaluate. + functional_zone_source, functional_zone_year : str | int | None + Source and year for **project** functional zones. + context_functional_zone_source, context_functional_zone_year : str | int | None + Source and year for **context** functional zones. + token : str | None, default None + Optional bearer token for Urban API. - try: - if not params.proj_func_zone_source or not params.proj_func_zone_year: - ( - params.proj_func_zone_source, - params.proj_func_source_year - ) = await urban_api_gateway.get_optimap_func_zone_request_data() - res = await self.aggregate_blocks_layer_scenario( - params.scenario_id, - token, - params.proj_func_zone_source, - params.proj_func_source_year - ) - return res - except HTTPException as http_e: - logger.exception(http_e) - raise http_e - except Exception as e: - logger.exception(e) - raise http_exception( - 500, - "Error during development calculation", - _input=params.__dict__, - _detail={ - "error": repr(e), - } - ) from e - - async def calc_context_development(self, params: ContextDevelopmentDTO): - """ - Function calculates development for context with project with blocksnet - Args: - params (DevelopmentDTO): Returns: - -- + pd.DataFrame + Two-row DataFrame with columns: + `pred`, `lower`, `upper`, `is_interval` + – predicted socio-economic indicator and its prediction interval. + + Workflow: + 1. Aggregate context blocks and project blocks. + 2. Merge them, clip land-use shares to 1. + 3. Compute development parameters (`run_development_parameters`). + 4. Feed summarised indicators into SocialRegressor. """ + logger.info('Evaluating master plan effects') - pass + context_blocks, context_buildings = await EffectsService.aggregate_blocks_layer_context( + scenario_id, context_functional_zone_source, context_functional_zone_year, token + ) + + scenario_blocks, scenario_buildings = await EffectsService.aggregate_blocks_layer_scenario( + scenario_id, functional_zone_source, functional_zone_year, token + ) + + scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + + blocks = gpd.GeoDataFrame( + pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs + ) + cols = ['residential', 'business', 'recreation', 'industrial', 'transport', 'special', 'agriculture'] + + blocks[cols] = blocks[cols].clip(upper=1) + development_df = await EffectsService.run_development_parameters(blocks) + + cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] + blocks[cols] = development_df[cols].values + for lu in LandUse: + blocks[lu.value] = blocks[lu.value] * blocks['site_area'] + data = [blocks.drop(columns=['land_use', 'geometry']).sum().to_dict()] + input = pd.DataFrame(data) + + input['latitude'] = blocks.geometry.union_all().centroid.x + input['longitude'] = blocks.geometry.union_all().centroid.y + input['buildings_count'] = input['count_buildings'] + sr = SocialRegressor() + y_pred, pi_lower, pi_upper = sr.evaluate(input) + iloc = 0 + result_data = { + 'pred': y_pred.apply(round).astype(int).iloc[iloc].to_dict(), + 'lower': pi_lower.iloc[iloc].to_dict(), + 'upper': pi_upper.iloc[iloc].to_dict(), + } + result_df = pd.DataFrame.from_dict(result_data) + result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) + + return result_df + + # async def calc_project_development(self, token: str, params: DevelopmentDTO): + # """ + # Function calculates development only for project with blocksnet + # Args: + # token (str): User token to access data from Urban API + # params (DevelopmentDTO): development request params + # Returns: + # -- + # """ + # + # try: + # if not params.proj_func_zone_source or not params.proj_func_zone_year: + # ( + # params.proj_func_zone_source, + # params.proj_func_source_year + # ) = await urban_api_gateway.get_optimap_func_zone_request_data() + # res = await self.aggregate_blocks_layer_scenario( + # params.scenario_id, + # token, + # params.proj_func_zone_source, + # params.proj_func_source_year + # ) + # return res + # except HTTPException as http_e: + # logger.exception(http_e) + # raise http_e + # except Exception as e: + # logger.exception(e) + # raise http_exception( + # 500, + # "Error during development calculation", + # _input=params.__dict__, + # _detail={ + # "error": repr(e), + # } + # ) from e + # + # async def calc_context_development(self, params: ContextDevelopmentDTO): + # """ + # Function calculates development for context with project with blocksnet + # Args: + # params (DevelopmentDTO): + # Returns: + # -- + # """ + # + # pass diff --git a/app/effects_api/modules/blocksnet_service.py b/app/effects_api/modules/blocksnet_service.py deleted file mode 100644 index ca97f54..0000000 --- a/app/effects_api/modules/blocksnet_service.py +++ /dev/null @@ -1,316 +0,0 @@ -# import geopandas as gpd -# import pandas as pd -# from blocksnet.blocks.aggregation import aggregate_objects -# from blocksnet.blocks.assignment import assign_land_use -# from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects -# from blocksnet.relations import get_accessibility_graph, calculate_accessibility_matrix -# from loguru import logger -# -# -# from app.common.exceptions.http_exception_wrapper import http_exception -# from app.effects_api.constants import const -# from app.effects_api.constants.const import mapping -# # from app.effects_api.modules.service_type_service import get_zones -# # from app.effects_api.modules.urban_api_gateway import get_scenario_objects -# -# SPEED_M_MIN = 60 * 1000 / 60 -# GAP_TOLERANCE = 5 -# -# def _get_geoms_by_function(function_name, physical_object_types, scenario_gdf): -# valid_type_ids = { -# d['physical_object_type_id'] -# for d in physical_object_types -# if function_name in d['physical_object_function']['name'] -# } -# return scenario_gdf[scenario_gdf['physical_objects'].apply( -# lambda x: any(d.get('physical_object_type').get('id') in valid_type_ids for d in x))] -# -# def _get_water(scenario_gdf, physical_object_types): -# water = _get_geoms_by_function('Водный объект', physical_object_types, scenario_gdf) -# water = water.explode(index_parts=True) -# water = water.reset_index() -# return water -# -# import shapely -# import numpy as np -# -# def close_gaps(gdf, tolerance): # taken from momepy -# geom = gdf.geometry.array -# coords = shapely.get_coordinates(geom) -# indices = shapely.get_num_coordinates(geom) -# -# edges = [0] -# i = 0 -# for ind in indices: -# ix = i + ind -# edges.append(ix - 1) -# edges.append(ix) -# i = ix -# edges = edges[:-1] -# points = shapely.points(np.unique(coords[edges], axis=0)) -# -# buffered = shapely.buffer(points, tolerance / 2) -# -# dissolved = shapely.union_all(buffered) -# -# exploded = [ -# shapely.get_geometry(dissolved, i) -# for i in range(shapely.get_num_geometries(dissolved)) -# ] -# -# centroids = shapely.centroid(exploded) -# -# snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) -# -# return gpd.GeoSeries(snapped, crs=gdf.crs) -# -# def _get_roads(scenario_gdf, physical_object_types): -# roads = _get_geoms_by_function('Дорога', physical_object_types, scenario_gdf) -# merged = roads.unary_union -# if merged.geom_type == 'MultiLineString': -# roads = gpd.GeoDataFrame(geometry=list(merged.geoms), crs=roads.crs) -# else: -# roads = gpd.GeoDataFrame(geometry=[merged], crs=roads.crs) -# roads = roads.explode(index_parts=False).reset_index(drop=True) -# roads.geometry = close_gaps(roads, GAP_TOLERANCE) -# roads = roads[roads.geom_type.isin(['LineString'])] -# return roads -# -# def _get_geoms_by_object_type_id(scenario_gdf, object_type_id): -# return scenario_gdf[scenario_gdf['physical_objects'].apply(lambda x: any(d.get('physical_object_type').get('id') == object_type_id for d in x))] -# -# def _get_buildings(scenario_gdf, physical_object_types): -# LIVING_BUILDINGS_ID = 4 -# NON_LIVING_BUILDINGS_ID = 5 -# living_building = _get_geoms_by_object_type_id(scenario_gdf, LIVING_BUILDINGS_ID) -# living_building['is_living'] = True -# # print(living_building) -# non_living_buildings = _get_geoms_by_object_type_id(scenario_gdf, NON_LIVING_BUILDINGS_ID) -# non_living_buildings['is_living'] = False -# -# buildings = gpd.GeoDataFrame( pd.concat( [living_building, non_living_buildings], ignore_index=True) ) -# # print(buildings) -# # buildings = _get_geoms_by_function('Здание', physical_object_types, scenario_gdf) -# buildings['number_of_floors'] = 1 -# # buildings['is_living'] = True -# buildings['footprint_area'] = buildings.geometry.area -# buildings['build_floor_area'] = buildings['footprint_area'] * buildings['number_of_floors'] -# buildings['living_area'] = buildings.geometry.area -# buildings['population'] = 0 -# buildings['population'][buildings['is_living']] = 100 -# buildings = buildings.reset_index() -# buildings = buildings[buildings.geometry.type != 'Point'] -# return buildings[['geometry', 'number_of_floors', 'footprint_area', 'build_floor_area', 'living_area', 'population']] -# -# -# def _get_services(scenario_gdf) -> gpd.GeoDataFrame | None: -# -# def extract_services(row): -# if isinstance(row['services'], list) and len(row['services']) > 0: -# return [ -# { -# 'service_id': service['service_id'], -# 'service_type_id': service['service_type']['id'], -# 'name': service['name'], -# 'capacity_real': service['capacity'], -# 'geometry': row['geometry'] # Сохраняем геометрию -# } -# for service in row['services'] -# if service.get('capacity') is not None and service['capacity'] > 0 -# ] -# return [] -# -# extracted_data = [] -# for _, row in scenario_gdf.iterrows(): -# extracted_data.extend(extract_services(row)) -# -# if len(extracted_data) == 0: -# raise http_exception(404, f'No services found to extract') -# -# services_gdf = gpd.GeoDataFrame(extracted_data, crs=scenario_gdf.crs) -# -# services_gdf['capacity'] = services_gdf['capacity_real'] -# services_gdf = services_gdf[['geometry', 'service_id', 'service_type_id', 'name', 'capacity']] -# -# services_gdf['area'] = services_gdf.geometry.area -# services_gdf['area'] = services_gdf['area'].apply(lambda a : a if a > 1 else 1) -# # services_gdf.loc[services_gdf.area == 0, 'area'] = 100 -# # services_gdf['area'] = services_gdf -# -# return services_gdf -# -# def _get_boundaries(project_info : dict, is_context: bool = False) -> gpd.GeoDataFrame: -# if is_context == False: -# boundaries = gpd.GeoDataFrame(geometry=[project_info['geometry']]) -# else: -# boundaries = gpd.GeoDataFrame(geometry=[project_info['context']]) -# boundaries = boundaries.set_crs(const.DEFAULT_CRS) -# local_crs = boundaries.estimate_utm_crs() -# return boundaries.to_crs(local_crs) -# -# -# def _generate_blocks(boundaries_gdf : gpd.GeoDataFrame, roads_gdf : gpd.GeoDataFrame, scenario_gdf : gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: -# water_gdf = _get_water(scenario_gdf, physical_object_types).to_crs(boundaries_gdf.crs) -# lines, polygons = preprocess_urban_objects(roads_gdf, None, water_gdf) -# blocks = cut_urban_blocks(boundaries_gdf, lines, polygons) -# blocks['land_use'] = None # TODO ЗАмнить на норм land_use?? >> здесь должен быть этап определения лендюза по тому что есть в бд -# return blocks -# -# -# def _assign_landuse(project_scenario_id: int, blocks: gpd.GeoDataFrame, is_context: bool, token): -# functional_zones = get_zones(project_scenario_id, token, is_context) -# functional_zones = functional_zones.to_crs(functional_zones.estimate_utm_crs()) -# functional_zones = functional_zones[["geometry", "functional_zone"]] -# blocks = assign_land_use(blocks, functional_zones, mapping) -# return blocks -# -# -# def _calculate_acc_mx(boundaries : gpd.GeoDataFrame, blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: -# graph = get_accessibility_graph(boundaries, 'intermodal') -# accessibility_matrix = calculate_accessibility_matrix(blocks_gdf, graph) -# return accessibility_matrix -# -# -# def _update_buildings(scenario_gdf: gpd.GeoDataFrame, physical_object_types : dict) -> gpd.GeoDataFrame: -# buildings_gdf = _get_buildings(scenario_gdf, physical_object_types) -# buildings_gdf = buildings_gdf[buildings_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] -# # buildings_gdf = impute_buildings(buildings_gdf, default_living_demand=30) -# return buildings_gdf -# -# -# -# -# # def _update_services(city : City, service_types : list[ServiceType], scenario_gdf : gpd.GeoDataFrame) -> None: -# # # reset service types -# # city._service_types = {} -# # for st in service_types: -# # city.add_service_type(st) -# # # filter services and add to the model if exist -# # services_gdf = _get_services(scenario_gdf) -# # if services_gdf is None: -# # return -# # services_gdf = services_gdf.to_crs(city.crs).copy() -# # service_type_dict = {service.code: service for service in service_types} -# # for service_type_code, st_gdf in services_gdf.groupby('service_type_id'): -# # gdf = st_gdf.copy().to_crs(city.crs) -# # gdf.geometry = gdf.representative_point() -# # service_type = service_type_dict.get(str(service_type_code), None) -# # if service_type is not None: -# # city.update_services(service_type, gdf) -# -# def _update_services(scenario_gdf : gpd.GeoDataFrame) -> gpd.GeoDataFrame: -# services_gdf = _get_services(scenario_gdf) -# services_gdf = services_gdf[services_gdf.geom_type.isin(['Polygon', 'MultiPolygon'])] -# return services_gdf -# -# # ToDo handle no service case -# -# -# -# # def _update_non_residential_block(block: Block): -# # for building in block.buildings: -# # building.population = 0 -# # -# # def _update_residential_block(block: Block, pop_per_ha: float, service_types: list[ServiceType]): -# # pop_per_m2 = pop_per_ha / SQ_M_IN_HA -# # area = block.site_area -# # population = round(pop_per_m2 * area) -# # # удаляем здания и сервисы -# # block.buildings = [] -# # block.services = [] -# # # добавляем dummy здание и даем ему наше население -# # dummy_building = Building( -# # block=block, -# # geometry=block.geometry.buffer(-0.01), -# # population=population, -# # **const.DUMMY_BUILDING_PARAMS -# # ) -# # block.buildings.append(dummy_building) -# # # добавляем по каждому типу сервиса большой dummy_service -# # for service_type in service_types: -# # capacity = service_type.calculate_in_need(population) -# # dummy_service = BlockService( -# # service_type=service_type, -# # capacity=capacity, -# # is_integrated=False, -# # block=block, -# # geometry=block.geometry.representative_point().buffer(0.01), -# # ) -# # block.services.append(dummy_service) -# # -# # def _update_block(block: Block, zone: str, service_types: list[ServiceType]): -# # if zone in const.residential_mapping: # если квартал жилой -# # pop_min, pop_max = const.residential_mapping[zone] -# # _update_residential_block(block, random.randint(pop_min, pop_max), service_types) -# # else: -# # _update_non_residential_block(block) -# # -# # def update_blocks(city: City, blocks_with_lu: gpd.GeoDataFrame, service_types: list[ServiceType]): -# # for block_id, row in blocks_with_lu.iterrows(): -# # zone = row['zone'] -# # block = city[block_id] -# # _update_block(block, zone, service_types) -# # -# # LU_SHARE = 0.5 -# # SQ_M_IN_HA = 10_000 -# # zones = _process_zones(zones) -# # blocks = city.get_blocks_gdf(True) -# # zones.to_crs(blocks.crs, inplace=True) -# # blocks_with_lu = _get_blocks_to_process(blocks, zones) -# # residential_sts = [city[st_name] for st_name in ['school', 'kindergarten', 'polyclinic'] if st_name in city.services] -# # update_blocks(city, blocks_with_lu, residential_sts) -# # return city -# -# # ToDo move zones to preprocessing and pass them to the function -# -# def fetch_city_model( -# project_info: dict, -# project_scenario_id: int, -# token: str, -# # scenario_gdf: gpd.GeoDataFrame, -# physical_object_types: dict, -# # service_types: list, -# is_context: bool = False -# ): -# # getting boundaries for our model -# logger.info('Fetching city model') -# boundaries_gdf = _get_boundaries(project_info, is_context) -# logger.info('boundaries have been fetched') -# local_crs = boundaries_gdf.crs -# scenario_objects_gdf = get_scenario_objects(project_scenario_id, token, is_context) -# # clipping scenario objects -# scenario_objects_gdf = scenario_objects_gdf.to_crs(local_crs) -# scenario_objects_gdf = scenario_objects_gdf.clip(boundaries_gdf) -# logger.info('scenario objects have been fetched') -# -# roads_gdf = _get_roads(scenario_objects_gdf, physical_object_types) -# roads_gdf = roads_gdf.to_crs(local_crs) -# logger.info('roads have been fetched') -# -# # generating blocks layer -# blocks_gdf = _generate_blocks(boundaries_gdf, roads_gdf, scenario_objects_gdf, physical_object_types) -# blocks_gdf = blocks_gdf.to_crs(local_crs) -# logger.info('blocks have been fetched') -# blocks_gdf = _assign_landuse(project_scenario_id, blocks_gdf, is_context, token) -# logger.info('landuse has been assigned') -# # acc_mx = _calculate_acc_mx(blocks_gdf, roads_gdf) -# # logger.info('acc_mx has been calculated') -# -# -# -# # initializing city model -# # city = City( -# # blocks=blocks_gdf, -# # acc_mx=acc_mx, -# # ) -# -# # updating buildings layer -# buildings_gdf = _update_buildings(scenario_objects_gdf, physical_object_types) -# buildings_blocks_gdf = aggregate_objects(blocks_gdf, buildings_gdf)[0] -# logger.info('buildings blocks have been aggregated') -# -# # services_gdf = _update_services(scenario_objects_gdf) -# # services_blocks_gdf = aggregate_objects(buildings_blocks_gdf, services_gdf)[0] -# # logger.info('services blocks have been aggregated') -# -# return blocks_gdf, buildings_blocks_gdf diff --git a/app/effects_api/modules/buildings_service.py b/app/effects_api/modules/buildings_service.py index 3f43e24..5167533 100644 --- a/app/effects_api/modules/buildings_service.py +++ b/app/effects_api/modules/buildings_service.py @@ -1,34 +1,10 @@ import geopandas as gpd import pandas as pd -BUILDINGS_RULES = { - 'number_of_floors': [ - ['floors'], - ['properties', 'storeys_count'], - ['properties', 'osm_data', 'building:levels'] - ], - 'footprint_area': [ - ['building_area_official'], - ['building_area_modeled'], - ['properties', 'area_total'] - ], - 'build_floor_area': [ - ['properties', 'area_total'], - ], - 'living_area': [ - ['properties', 'living_area_official'], - ['properties', 'living_area'], - ['properties', 'living_area_modeled'] - ], - 'non_living_area': [ - ['properties', 'area_non_residential'], - ], - 'population': [ - ['properties', 'population_balanced'] - ] -} +from app.effects_api.constants.const import BUILDINGS_RULES -def _parse(data : dict | None, *args): + +def _parse(data: dict | None, *args): key = args[0] args = args[1:] if data is not None and key in data and data[key] is not None: @@ -40,17 +16,19 @@ def _parse(data : dict | None, *args): return _parse(data[key], *args) return None -def _adapt(data : dict, rules : list): + +def _adapt(data: dict, rules: list): for rule in rules: value = _parse(data, *rule) if value is not None: return value return None -def adapt_buildings(buildings_gdf : gpd.GeoDataFrame, living_pot_id : int = 4): + +def adapt_buildings(buildings_gdf: gpd.GeoDataFrame): gdf = buildings_gdf[['geometry']].copy() - gdf['is_living'] = buildings_gdf['physical_object_type'].apply(lambda pot : pot['physical_object_type_id'] == 4) + gdf['is_living'] = buildings_gdf['physical_object_type'].apply(lambda pot: pot['physical_object_type_id'] == 4) for column, rules in BUILDINGS_RULES.items(): - series = buildings_gdf['building'].apply(lambda b : _adapt(b, rules)) + series = buildings_gdf['building'].apply(lambda b: _adapt(b, rules)) gdf[column] = pd.to_numeric(series, errors='coerce') return gdf diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 3d49825..c400e1c 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,18 +1,13 @@ -"""Все методы, которые вызывают ручки по контексту -А также методы, которые собиракют геослои по контексту""" - import geopandas as gpd import pandas as pd from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks from blocksnet.preprocessing.imputing import impute_buildings, impute_services +from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.scenario_service import _get_best_functional_zones_source, close_gaps from app.effects_api.modules.services_service import adapt_services -from app.gateways.urban_api_gateway import urban_api_gateway - -LIVING_BUILDING_POT_ID = 4 async def _get_project_boundaries(project_id: int): @@ -27,12 +22,12 @@ async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: async def _get_context_roads(project_id: int): - gdf = await urban_api_gateway.get_physical_objects_scenario(project_id, physical_object_function_id=26) + gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=26) return gdf[['geometry']].reset_index(drop=True) async def _get_context_water(project_id: int): - gdf = await urban_api_gateway.get_physical_objects_scenario(project_id, physical_object_function_id=4) + gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=4) return gdf[['geometry']].reset_index(drop=True) @@ -52,7 +47,7 @@ async def _get_context_blocks(project_id: int, boundaries: gpd.GeoDataFrame) -> async def get_context_blocks(project_id: int): - project_boundaries = await urban_api_gateway.get_project_geometry(project_id) + project_boundaries = await _get_project_boundaries(project_id) context_boundaries = await _get_context_boundaries(project_id) crs = context_boundaries.estimate_utm_crs() @@ -60,24 +55,26 @@ async def get_context_blocks(project_id: int): project_boundaries = project_boundaries.to_crs(crs) context_boundaries = context_boundaries.overlay(project_boundaries, how='difference') - return _get_context_blocks(project_id, context_boundaries) + return await _get_context_blocks(project_id, context_boundaries) -async def get_context_functional_zones(project_id: int, token: str = None) -> gpd.GeoDataFrame: +async def get_context_functional_zones(project_id: int, source: str, year: int, token: str) -> gpd.GeoDataFrame: sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) - year, source = _get_best_functional_zones_source(sources_df) - functional_zones = await urban_api_gateway.get_functional_zones_scenario(project_id, year, source) + year, source = await _get_best_functional_zones_source(sources_df, source, year) + # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) + functional_zones = await urban_api_gateway.get_functional_zones(project_id, year, source) return adapt_functional_zones(functional_zones) async def get_context_buildings(project_id: int): - gdf = await urban_api_gateway.get_physical_objects_scenario(project_id, physical_object_type_id=1, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_type_id=4, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) async def get_context_services(project_id: int, service_types: pd.DataFrame): - gdf = await urban_api_gateway.get_services_scenario(project_id, centers_only=True) + gdf = await urban_api_gateway.get_services(project_id, centers_only=True) + gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/effects_service.py b/app/effects_api/modules/effects_service.py deleted file mode 100644 index c7a1cf9..0000000 --- a/app/effects_api/modules/effects_service.py +++ /dev/null @@ -1,120 +0,0 @@ -import os - -import geopandas as gpd -import warnings -import pandas as pd -from blocksnet.analysis.indicators import calculate_development_indicators -from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor -from blocksnet.relations import generate_adjacency_graph -from urllib3.exceptions import InsecureRequestWarning -from loguru import logger -from app.effects_api.constants import const -from app.effects_api.models import effects_models as em -from app.effects_api.modules import blocksnet_service as bs -from app.gateways import urban_api_gateway as ps - -for warning in [pd.errors.PerformanceWarning, RuntimeWarning, pd.errors.SettingWithCopyWarning, InsecureRequestWarning, - FutureWarning]: - warnings.filterwarnings(action='ignore', category=warning) - -PROVISION_COLUMNS = ['provision', 'demand', 'demand_within'] - - -def _get_file_path(project_scenario_id: int, effect_type: em.EffectType, scale_type: em.ScaleType): - file_path = f'{project_scenario_id}_{effect_type.name}_{scale_type.name}' - return os.path.join(const.DATA_PATH, f'{file_path}.parquet') - -def _evaluate_master_plan(blocks: gpd.GeoDataFrame, buildings_blocks_gdf: gpd.GeoDataFrame) -> pd.DataFrame: - logger.info('Evaluating master plan effects') - blocks = buildings_blocks_gdf.join( - blocks.drop(columns="geometry"), # не тащим геометрию дважды - how="left" - ) - adjacency_graph = generate_adjacency_graph(blocks, 10) - dr = DensityRegressor() - density_df = dr.evaluate(blocks, adjacency_graph) - - - density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 - density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 - density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 - density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 - density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 - density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 - - blocks['site_area'] = blocks.area - density_df['site_area'] = blocks['site_area'] - development_df = calculate_development_indicators(density_df) - development_df['population'] = development_df['living_area'] // 20 - cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] - blocks[cols] = development_df[cols].values - for lu in LandUse: - blocks[lu.value] = blocks[lu.value] * blocks['site_area'] - data = [blocks.drop(columns=['land_use', 'geometry']).sum().to_dict()] - input = pd.DataFrame(data) - - input['latitude'] = blocks.geometry.union_all().centroid.x - input['longitude'] = blocks.geometry.union_all().centroid.y - input['buildings_count'] = input['count'] - sr = SocialRegressor() - y_pred, pi_lower, pi_upper = sr.evaluate(input) - iloc = 0 - result_data = { - 'pred': y_pred.apply(round).astype(int).iloc[iloc].to_dict(), - 'lower': pi_lower.iloc[iloc].to_dict(), - 'upper': pi_upper.iloc[iloc].to_dict(), - } - result_df = pd.DataFrame.from_dict(result_data) - result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) - - return result_df - - -def _evaluation_exists(project_scenario_id: int, token: str): - exists = True - for effect_type in list(em.EffectType): - for scale_type in list(em.ScaleType): - file_path = _get_file_path(project_scenario_id, effect_type, scale_type) - if not os.path.exists(file_path): - exists = False - return exists - - -def delete_evaluation(project_scenario_id: int): - for effect_type in list(em.EffectType): - for scale_type in list(em.ScaleType): - file_path = _get_file_path(project_scenario_id, effect_type, scale_type) - if os.path.exists(file_path): - os.remove(file_path) - - -def evaluate_f22(project_scenario_id: int, token: str, reevaluate: bool = True, is_context: bool = False): - logger.info(f'Fetching {project_scenario_id} project info') - - project_info = ps.get_project_info(project_scenario_id, token) - based_scenario_id = ps.get_based_scenario_id(project_info, token) - if project_scenario_id != based_scenario_id: - evaluate_f22(based_scenario_id, token, reevaluate=False) - - exists = _evaluation_exists(project_scenario_id, token) - if exists and not reevaluate: - logger.info(f'{project_scenario_id} evaluation already exists') - return - - logger.info('Fetching region service types') - logger.info('Fetching physical object types') - physical_object_types = ps.get_physical_object_types() - - logger.info('Fetching project model') - blocks_gdf, buildings_blocks_gdf = bs.fetch_city_model( - project_info=project_info, - project_scenario_id=project_scenario_id, - token=token, - # service_types=service_types, - physical_object_types=physical_object_types, - is_context=is_context - ) - - - _evaluate_master_plan(blocks_gdf, buildings_blocks_gdf) diff --git a/app/effects_api/modules/f22_service.py b/app/effects_api/modules/f22_service.py deleted file mode 100644 index af742fa..0000000 --- a/app/effects_api/modules/f22_service.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Здесь метод который собирает единый слой кварталов для сценраия и метод который считает параметры застройки - https://github.com/vasilstar97/prostor-examples/blob/main/examples/Ф22.ipynb""" - -import pandas as pd -import geopandas as gpd -from loguru import logger -from blocksnet.analysis.indicators import calculate_development_indicators -from blocksnet.blocks.aggregation import aggregate_objects -from blocksnet.blocks.assignment import assign_land_use -from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import DensityRegressor -from blocksnet.relations import generate_adjacency_graph - -from app.effects_api.modules.functional_sources_service import LAND_USE_RULES -from app.effects_api.modules.scenario_service import get_scenario_blocks, get_scenario_functional_zones, \ - get_scenario_buildings, get_scenario_services -from app.effects_api.modules.service_type_service import get_service_types - - -#собираем единый слой кварталов для сецнария -async def aggregate_blocks_layer_scenario( - scenario_id: int, - token: str = None, - functional_zone_source: str = None, - functional_zone_year: int = None) -> gpd.GeoDataFrame: - logger.info(f"Aggregating blocks layer scenario {scenario_id}") - blocks = await get_scenario_blocks(scenario_id) - blocks_crs = blocks.crs - logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") - logger.info(f"Aggregating functional_zones layer scenario {scenario_id}") - functional_zones = await get_scenario_functional_zones(scenario_id) - functional_zones = functional_zones.to_crs(blocks_crs) - logger.info(f"assign_land_use layer scenario {scenario_id}") - blocks_lu = assign_land_use(blocks, functional_zones, LAND_USE_RULES) - blocks = blocks.join(blocks_lu.drop(columns=['geometry'])) - logger.info(f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") - logger.info(f"buildings layer scenario {scenario_id}") - # #TODO жилых зданий может нге быть в сценарии, пока ломается здесь - # # buildings = await get_scenario_buildings(scenario_id) - logger.info( - f"{len(blocks)} BLOCKS WITH BUILDINGS layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") - buildings = buildings.to_crs(blocks_crs) - if buildings is not None: - buildings = buildings.to_crs(blocks.crs) - blocks_buildings, _ = aggregate_objects(blocks, buildings) - blocks = blocks.join( - blocks_buildings.drop(columns=['geometry']).rename(columns={'objects_count': 'count_buildings'})) - blocks['count_buildings'] = blocks['count_buildings'].fillna(0).astype(int) - logger.info(f"service_types layer scenario {scenario_id}") - service_types = await get_service_types() - logger.info( - f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, CRS: {blocks.crs}, {blocks.columns}") - services_dict = await get_scenario_services(scenario_id, service_types) - logger.info( - f"{len(blocks)} BLOCKS WITH LANDUSE blocks layer scenario {scenario_id}, service_dict {services_dict}") - for service_type, services in services_dict.items(): - services = services.to_crs(blocks.crs) - blocks_services, _ = aggregate_objects(blocks, services) - blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) - blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) - blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'objects_count': f'count_{service_type}', - })) - logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") - return blocks - -async def get_services_layer(scenario_id: int): - blocks = await get_scenario_blocks(scenario_id) - blocks_crs = blocks.crs - logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") - service_types = await get_service_types() - logger.info(f"{service_types}") - services_dict = await get_scenario_services(scenario_id, service_types) - - for service_type, services in services_dict.items(): - services = services.to_crs(blocks_crs) - blocks_services, _ = aggregate_objects(blocks, services) - blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) - blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) - blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'objects_count': f'count_{service_type}', - })) - logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") - return blocks - -async def run_development_parameters(scenario_id: int) -> gpd.GeoDataFrame | pd.DataFrame: - blocks = await aggregate_blocks_layer_scenario(scenario_id) - for lu in LandUse: - blocks[lu.value] = blocks[lu.value].apply(lambda v: min(v, 1)) - logger.info(f"adjacency_graph scenario {scenario_id}") - adjacency_graph = generate_adjacency_graph(blocks, 10) - dr = DensityRegressor() - - logger.info(f"DensityRegressor scenario {scenario_id}") - density_df = dr.evaluate(blocks, adjacency_graph) - density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 - - density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 - density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 - - density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 - density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 - - density_df.loc[blocks['residential'] == 0, 'mxi'] = 0 - density_df['site_area'] = blocks['site_area'] - - logger.info(f"Calculating density indicators for {scenario_id}") - development_df = calculate_development_indicators(density_df) - development_df['population'] = development_df['living_area'] // 20 - - mask = blocks['is_project'] - columns = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] - blocks.loc[mask, columns] = development_df.loc[mask, columns] - return blocks diff --git a/app/effects_api/modules/functional_sources_service.py b/app/effects_api/modules/functional_sources_service.py index b3ecb5f..2cd69e0 100644 --- a/app/effects_api/modules/functional_sources_service.py +++ b/app/effects_api/modules/functional_sources_service.py @@ -1,19 +1,4 @@ import geopandas as gpd -from blocksnet.enums import LandUse - -LAND_USE_RULES = { - 'residential' : LandUse.RESIDENTIAL, - 'recreation' : LandUse.RECREATION, - 'special' : LandUse.SPECIAL, - 'industrial' : LandUse.INDUSTRIAL, - 'agriculture' : LandUse.AGRICULTURE, - 'transport' : LandUse.TRANSPORT, - 'business' : LandUse.BUSINESS, - 'residential_individual' : LandUse.RESIDENTIAL, - 'residential_lowrise' : LandUse.RESIDENTIAL, - 'residential_midrise' : LandUse.RESIDENTIAL, - 'residential_multistorey' : LandUse.RESIDENTIAL, -} def _adapt_functional_zone(data : dict): functional_zone_type_id = data['name'] diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 78994a9..3e204be 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,5 +1,3 @@ -"""Все методы, которые вызывают ручки по сценарию -А также методы, которые собиракют геослои по сценарию""" import shapely import numpy as np from blocksnet.preprocessing.imputing import impute_services @@ -9,12 +7,12 @@ import pandas as pd from app.common.exceptions.http_exception_wrapper import http_exception +from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.services_service import adapt_services -from app.gateways.urban_api_gateway import urban_api_gateway -SOURCES_PRIORITY = ['User', 'PZZ', 'OSM'] +SOURCES_PRIORITY = ['PZZ', 'OSM', "User"] def close_gaps(gdf, tolerance): # taken from momepy @@ -52,26 +50,26 @@ async def _get_project_boundaries(project_id: int): return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) -async def _get_scenario_roads(scenario_id: int): - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=26) +async def _get_scenario_roads(scenario_id: int, token: str): + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=26) return gdf[['geometry']].reset_index(drop=True) -async def _get_scenario_water(scenario_id: int): - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, physical_object_function_id=4) +async def _get_scenario_water(scenario_id: int, token: str): + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=4) return gdf[['geometry']].reset_index(drop=True) async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, - boundaries: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + boundaries: gpd.GeoDataFrame, token) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await _get_scenario_water(user_scenario_id) + water = await _get_scenario_water(user_scenario_id, token) water = water.to_crs(crs) - user_roads = await _get_scenario_roads(user_scenario_id) + user_roads = await _get_scenario_roads(user_scenario_id, token) user_roads = user_roads.to_crs(crs) - base_roads =await _get_scenario_roads(base_scenario_id) + base_roads =await _get_scenario_roads(base_scenario_id, token) base_roads = base_roads.to_crs(crs) roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) roads.geometry = close_gaps(roads, 1) @@ -81,44 +79,78 @@ async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, return blocks -async def _get_scenario_info(scenario_id: int) -> tuple[int, int]: - scenario = await urban_api_gateway.get_scenario(scenario_id) +async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: + scenario = await urban_api_gateway.get_scenario(scenario_id, token) project_id = scenario['project']['project_id'] project = await urban_api_gateway.get_project(project_id) base_scenario_id = project['base_scenario']['id'] return project_id, base_scenario_id -async def _get_best_functional_zones_source(sources_df: pd.DataFrame) -> tuple[int | None, str | None]: - sources = sources_df['source'].unique() - for source in SOURCES_PRIORITY: - if source in sources: - sources_df = sources_df[sources_df['source'] == source] - year = sources_df.year.max() - return int(year), source - return None, None # FIXME ??? - - -async def get_scenario_blocks(user_scenario_id: int): - project_id, base_scenario_id = await _get_scenario_info(user_scenario_id) +async def _get_best_functional_zones_source( + sources_df: pd.DataFrame, + source: str | None = None, + year: int | None = None, +) -> tuple[int | None, str | None]: + """ + Pick the (year, source) pair that should be fetched. + + Rules + ----- + 1. Nothing is given: latest year of the highest-priority source (PZZ). + 2. Only `source`: latest year available for that source. + 3. Only `year`: try that year for priority = PZZ, OSM, User. + 4. 'Both given': use them as-is if a match exists, otherwise fall back to rule 3. + """ + if source and year: + row = sources_df.query("source == @source and year == @year") + if not row.empty: + return year, source + year = int(year) + source = None + + if source and year is None: + rows = sources_df.query("source == @source") + if not rows.empty: + return int(rows["year"].max()), source + source = None + + if year is not None and source is None: + for s in SOURCES_PRIORITY: + row = sources_df.query("source == @s and year == @year") + if not row.empty: + return year, s + + # else: + # raise http_exception(404, "No source or year were found:", [source, year]) + + for s in SOURCES_PRIORITY: + rows = sources_df.query("source == @s") + if not rows.empty: + return int(rows["year"].max()), s + + +async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataFrame: + project_id, base_scenario_id = await _get_scenario_info(user_scenario_id, token) project_boundaries = await _get_project_boundaries(project_id) crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) - return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries) + return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries, token) async def get_scenario_functional_zones(scenario_id: int, token: str, source: str = None, year: int = None) -> gpd.GeoDataFrame: - sources_df = await urban_api_gateway.get_functional_zones_sources_scenario(scenario_id) - year, source = await _get_best_functional_zones_source(sources_df) - functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, year, source) + sources_df = await urban_api_gateway.get_functional_zones_sources_scenario(scenario_id, token) + year, source = await _get_best_functional_zones_source(sources_df, source, year) + # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) + functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, token, year, source) return adapt_functional_zones(functional_zones) -async def get_scenario_buildings(scenario_id: int): +async def get_scenario_buildings(scenario_id: int, token: str): try: - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, physical_object_type_id=4, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_type_id=4, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) @@ -126,11 +158,12 @@ async def get_scenario_buildings(scenario_id: int): http_exception(404, f'No buildings found for scenario {scenario_id}', str(e)) -async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame): +async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame, token: str): try: - gdf = await urban_api_gateway.get_services_scenario(scenario_id, centers_only=True) + gdf = await urban_api_gateway.get_services_scenario(scenario_id, centers_only=True, token=token) + gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} except Exception as e: - http_exception(404, f'No buildings found for scenario {scenario_id}', str(e)) + print(f'No buildings found for scenario: {str(e)}') diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 8a57f76..55e88ad 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,108 +1,11 @@ -"""Здесь предобработка типов сервисов для блокснета""" +import asyncio +from typing import Optional, List from app.dependencies import urban_api_gateway import pandas as pd from blocksnet.config import service_types_config - -def get_indicators(parent_id: int | None = None, **kwargs): - res = urban_api_gateway.get('/api/v1/indicators_by_parent', params={ - 'parent_id': parent_id, - **kwargs - }) - return pd.DataFrame(res).set_index('indicator_id') - - -async def get_service_types(**kwargs): - res = await urban_api_gateway.get('/api/v1/service_types', params=kwargs) - return pd.DataFrame(res).set_index('service_type_id') - - -def get_social_values(**kwargs): - res = urban_api_gateway.get('/api/v1/social_values', params=kwargs) - return pd.DataFrame(res).set_index('soc_value_id') - - -def get_social_value_service_types(soc_value_id: int, **kwargs): - res = urban_api_gateway.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) - if len(res) == 0: - return None - return pd.DataFrame(res).set_index('service_type_id') - - -def get_service_type_social_values(service_type_id: int, **kwargs): - res = urban_api_gateway.get(f'/api/v1/service_types/{service_type_id}/social_values', params=kwargs) - if len(res) == 0: - return None - return pd.DataFrame(res).set_index('soc_value_id') - - -SERVICE_TYPES_MAPPING = { - # basic - 1: 'park', - 21: 'kindergarten', - 22: 'school', - 28: 'polyclinic', - 34: 'pharmacy', - 61: 'cafe', - 66: 'pitch', - 68: None, # спортивный зал - 74: 'playground', - 78: 'police', - # additional - 30: None, # стоматология - 35: 'hospital', - 50: 'museum', - 56: 'cinema', - 57: 'mall', - 59: 'stadium', - 62: 'restaurant', - 63: 'bar', - 77: None, # скейт парк - 79: None, # пожарная станция - 80: 'train_station', - 89: 'supermarket', - 99: None, # пункт выдачи - 100: 'bank', - 107: 'veterinary', - 143: 'sanatorium', - # comfort - 5: 'beach', - 27: 'university', - 36: None, # роддом - 48: 'library', - 51: 'theatre', - 91: 'market', - 93: None, # одежда и обувь - 94: None, # бытовая техника - 95: None, # книжный магазин - 96: None, # детские товары - 97: None, # спортивный магазин - 108: None, # зоомагазин - 110: 'hotel', - 114: 'religion', # религиозный объект - # others - 26: None, # ССУЗ - 32: None, # женская консультация - 39: None, # скорая помощь - 40: None, # травматология - 45: 'recruitment', - 47: 'multifunctional_center', - 55: 'zoo', - 65: 'bakery', - 67: 'swimming_pool', - 75: None, # парк аттракционов - 81: 'train_building', - 82: 'aeroway_terminal', # аэропорт?? - 86: 'bus_station', - 88: 'subway_entrance', - 102: 'lawyer', - 103: 'notary', - 109: 'dog_park', - 111: 'hostel', - 112: None, # база отдыха - 113: None, # памятник -} +from app.effects_api.constants.const import SERVICE_TYPES_MAPPING for st_id, st_name in SERVICE_TYPES_MAPPING.items(): if st_name is None: @@ -110,28 +13,34 @@ def get_service_type_social_values(service_type_id: int, **kwargs): assert st_name in service_types_config, f'{st_id}:{st_name} not in config' -def _adapt_name(service_type_id: int): +async def _adapt_name(service_type_id: int): return SERVICE_TYPES_MAPPING.get(service_type_id) -def _adapt_infrastructure_weight(data: dict): - return data.get('weight_value', None) - - -def _adapt_social_values(service_type_id: int): - social_values = get_service_type_social_values(service_type_id) +async def _adapt_social_values(service_type_id: int): + social_values = await urban_api_gateway.get_service_type_social_values(service_type_id) if social_values is None: return None else: return list(social_values.index) -def adapt_service_types(service_types_df: pd.DataFrame): - df = service_types_df[['infrastructure_type']].copy() - df['infrastructure_weight'] = service_types_df['properties'].apply(_adapt_infrastructure_weight) - df['name'] = df.apply(lambda s: _adapt_name(s.name), axis=1) - df = df[~df['name'].isna()].copy() +async def adapt_service_types(service_types_df: pd.DataFrame) -> pd.DataFrame: + df = service_types_df[["infrastructure_type"]].copy() + df["infrastructure_weight"] = service_types_df["weight_value"] + + service_type_ids = df.index.tolist() + + names: List[Optional[str]] = await asyncio.gather( + *(_adapt_name(st_id) for st_id in service_type_ids) + ) + df["name"] = names + + df = df.dropna(subset=["name"]).copy() - df['social_values'] = df.apply(lambda s: _adapt_social_values(s.name), axis=1) + social_vals: List[Optional[List[int]]] = await asyncio.gather( + *(_adapt_social_values(st_id) for st_id in df.index) + ) + df["social_values"] = social_vals - return df[['name', 'infrastructure_type', 'infrastructure_weight', 'social_values']].copy() + return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] diff --git a/app/effects_api/modules/task_api_service.py b/app/effects_api/modules/task_api_service.py deleted file mode 100644 index 4a2494e..0000000 --- a/app/effects_api/modules/task_api_service.py +++ /dev/null @@ -1,52 +0,0 @@ -from typing import Optional - -import requests -from fastapi import HTTPException -from iduconfig import Config - -from app.common.exceptions.http_exception_wrapper import http_exception -from app.dependencies import urban_api_handler - -config = Config() - -# from app.effects_api.constants.const import URBAN_API - - -def get_headers(token: Optional[str] = None) -> dict[str, str] | None: - if token: - headers = { - "Authorization": f"Bearer {token}" - } - return headers - return None - -async def get_project_id(scenario_id: int, token: Optional[str] = None) -> int: - endpoint = f"/api/v1/scenarios/{scenario_id}" - response = await urban_api_handler.get(endpoint, headers={ - "Authorization": f"Bearer {token}""" - }) - try: - project_id = response.get("project", {}).get("project_id") - except Exception: - raise http_exception(404, "Project ID is missing in scenario data.", scenario_id) - - return project_id - -def get_all_project_info(project_id: int, token: Optional[str] = None) -> dict: - url = config.get("URBAN_API") + f"/api/v1/projects/{project_id}" - headers = get_headers(token) - - response = requests.get(url, headers=headers) - if response.status_code != 200: - raise HTTPException(response.status_code, response.text) - result = response.json() - return result - -def get_scenario_info(target_scenario_id: int, token) -> dict: - - url = config.get("URBAN_API") + f"/api/v1/scenarios/{target_scenario_id}" - headers = get_headers(token) - response = requests.get(url, headers=headers) - if response.status_code != 200: - raise HTTPException(response.status_code, response.text) - return response.json() diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 5e64ea5..2b13de3 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -2,29 +2,31 @@ import geopandas as gpd import pandas as pd import shapely -from typing import Any, Dict, Literal +from typing import Any, Dict, Literal, Optional -from sqlalchemy.util import await_only from app.common.api_handlers.json_api_handler import JSONAPIHandler from app.common.exceptions.http_exception_wrapper import http_exception - class UrbanAPIGateway: def __init__(self, base_url: str) -> None: self.json_handler = JSONAPIHandler(base_url) - + # TODO context async def get_physical_objects( - self, - project_id: int, - **kwargs: Any + self, + project_id: int, + **kwargs: Any ) -> gpd.GeoDataFrame: + params = { + k: (str(v).lower() if isinstance(v, bool) else v) + for k, v in kwargs.items() + } res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", - params=kwargs + params=params ) features = res["features"] return ( @@ -37,9 +39,13 @@ async def get_services( project_id: int, **kwargs: Any ) -> gpd.GeoDataFrame: + params = { + k: (str(v).lower() if isinstance(v, bool) else v) + for k, v in kwargs.items() + } res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/services_with_geometry", - params=kwargs + params=params ) features = res["features"] return ( @@ -59,8 +65,8 @@ async def get_functional_zones_sources( async def get_functional_zones( self, project_id: int, - year: int, - source: int + year: int, + source: int ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zones", @@ -72,7 +78,6 @@ async def get_functional_zones( .set_index("functional_zone_id") ) - async def get_project(self, project_id: int) -> Dict[str, Any]: res = await self.json_handler.get(f"/api/v1/projects/{project_id}") return res @@ -85,7 +90,18 @@ async def get_project_geometry(self, project_id: int): return shapely.from_geojson(geometry_json) # TODO scenario - async def get_scenario(self, scenario_id: int) -> Dict[str, Any]: + async def get_scenario_info(self, target_scenario_id: int, token: str) -> dict: + + url = f"/api/v1/scenarios/{target_scenario_id}" + headers = await UrbanAPIGateway.get_headers(self, token) + try: + response = await self.json_handler.get(url, headers) + except Exception as e: + raise http_exception(404, f"Scenario info for ID {target_scenario_id} is missing", str(e)) + return response + + async def get_scenario(self, scenario_id: int, token: str) -> Dict[str, Any]: + headers = await UrbanAPIGateway.get_headers(self, token) res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}") return res @@ -93,28 +109,25 @@ async def get_functional_zones_sources_scenario( self, scenario_id: int, token: str, - source: str, - year: int ) -> pd.DataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/functional_zone_sources", headers=headers, - params={ - "year": year, - "source": source, - } ) return pd.DataFrame(res) async def get_functional_zones_scenario( self, scenario_id: int, - year: int, - source: str + token: str, + year: int, + source: str ) -> gpd.GeoDataFrame: + headers = await UrbanAPIGateway.get_headers(self, token) res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/functional_zones", + headers=headers, params={"year": year, "source": source} ) features = res["features"] @@ -126,14 +139,17 @@ async def get_functional_zones_scenario( async def get_physical_objects_scenario( self, scenario_id: int, + token: str, **kwargs: Any ) -> gpd.GeoDataFrame: + headers = await UrbanAPIGateway.get_headers(self, token) params = { k: (str(v).lower() if isinstance(v, bool) else v) for k, v in kwargs.items() } res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", + headers=headers, params=params ) features = res["features"] @@ -145,11 +161,19 @@ async def get_physical_objects_scenario( async def get_services_scenario( self, scenario_id: int, + token: str, **kwargs: Any ) -> gpd.GeoDataFrame: + headers = await UrbanAPIGateway.get_headers(self, token) + params = { + k: (str(v).lower() if isinstance(v, bool) else v) + for k, v in kwargs.items() + } + res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/services_with_geometry", - params=kwargs + headers=headers, + params=params ) features = res["features"] return ( @@ -157,7 +181,7 @@ async def get_services_scenario( .set_index("service_id") ) - async def get_optimap_func_zone_request_data( + async def get_optimal_func_zone_request_data( self, token: str, scenario_id: int, @@ -244,4 +268,124 @@ async def get_optimal_source( source_df = sources_df[sources_df["source"] == source] return await get_optimal_source(source_df, year, project) + async def get_headers(self, token: Optional[str] = None) -> dict[str, str] | None: + if token: + headers = { + "Authorization": f"Bearer {token}" + } + return headers + return None + + async def get_project_id( + self, + scenario_id: int, + token: str | None = None, + ) -> int: + endpoint = f"/api/v1/scenarios/{scenario_id}" + + headers = await UrbanAPIGateway.get_headers(self, token) + + response = await self.json_handler.get(endpoint, headers=headers) + + project_id = response.get("project", {}).get("project_id") + if project_id is None: + raise http_exception( + 404, + "Project ID is missing in scenario data.", + scenario_id, + ) + + return project_id + + async def get_all_project_info(self, project_id: int, token: Optional[str] = None) -> dict: + url = f"/api/v1/projects/{project_id}" + headers = await UrbanAPIGateway.get_headers(self, token) + try: + response = await self.json_handler.get(url, headers) + except Exception as e: + raise http_exception(404, f"Project info for ID {project_id} is missing", str(e)) + return response + + async def get_service_types(self, **kwargs): + params = { + k: (str(v).lower() if isinstance(v, bool) else v) + for k, v in kwargs.items() + if v is not None + } + + data = await self.json_handler.get("/api/v1/service_types", params=params) + + items = ( + data + if isinstance(data, list) + else data.get("service_types") or data.get("data") or [] + ) + + rows = [ + { + "service_type_id": it.get("service_type_id"), + "infrastructure_type": it.get("infrastructure_type"), + "weight_value": it.get("properties", {}).get("weight_value"), + } + for it in items + ] + + return ( + pd.DataFrame(rows) + .set_index("service_type_id") + ) + + async def get_social_values(self, **kwargs): + res = await self.json_handler.get('/api/v1/social_values', params=kwargs) + return pd.DataFrame(res).set_index('soc_value_id') + + async def get_social_value_service_types(self, soc_value_id: int, **kwargs): + data = await self.json_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) + if not data: + return None + + if isinstance(data, list): + items = data + elif isinstance(data, dict): + items = data.get('service_types') or data.get('data') or [] + else: + items = [] + + rows = [] + for it in items: + rows.append({ + 'soc_value_id': it.get('soc_value_id'), + }) + df = pd.DataFrame(rows).set_index('soc_value_id') + return df + + async def get_service_type_social_values(self, service_type_id: int, **kwargs): + data = await self.json_handler.get( + f"/api/v1/service_types/{service_type_id}/social_values", + params=kwargs, + ) + + if isinstance(data, dict): + data = data.get("service_types") or data.get("data") or [] + + idx = [it["soc_value_id"] for it in data if "soc_value_id" in it] + if not idx: + return None + + df = pd.DataFrame(index=idx) + df.index.name = "soc_value_id" + return df + + async def get_indicators(self, parent_id: int | None = None, **kwargs): + res = self.json_handler.get('/api/v1/indicators_by_parent', params={ + 'parent_id': parent_id, + **kwargs + }) + return pd.DataFrame(res).set_index('indicator_id') + async def get_territory_geometry(self, territory_id: int): + res = await self.json_handler.get(f'/api/v1/territory/{territory_id}') + geom = res["geometry"] + if isinstance(geom, dict): + geom = json.dumps(geom) + return shapely.from_geojson(geom) diff --git a/app/main.py b/app/main.py index 51b6e7a..a9a9fc0 100644 --- a/app/main.py +++ b/app/main.py @@ -1,7 +1,7 @@ from contextlib import asynccontextmanager from app.effects_api import effects_controller -from app.effects_api.schemas.task_schema import TaskSchema +# from app.effects_api.schemas.task_schema import TaskSchema from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware @@ -15,7 +15,7 @@ async def lifespan(router : FastAPI): app = FastAPI( title="Effects API", description="API for calculating effects of territory transformation with BlocksNet library", - lifespan=lifespan + lifespan=lifespan, ) # disable cors @@ -27,16 +27,8 @@ async def lifespan(router : FastAPI): allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) -app.include_router(effects_controller.router) +app.include_router(effects_controller.development_router) @app.get("/", include_in_schema=False) async def read_root(): return RedirectResponse('/docs') - -@app.get('/tasks', tags=['Tasks']) -def get_tasks() -> dict[int, TaskSchema]: - return effects_controller.tasks - -@app.get('/task_status', tags=['Tasks']) -def get_task_status(task_id : int) -> TaskSchema: - return effects_controller.tasks[task_id] diff --git a/testing.ipynb b/testing.ipynb deleted file mode 100644 index 54f657b..0000000 --- a/testing.ipynb +++ /dev/null @@ -1,37 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "initial_id", - "metadata": { - "collapsed": true - }, - "outputs": [], - "source": [ - "" - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 2 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython2", - "version": "2.7.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 01bf375318bce511ae3c2f924b90be750bcc2539 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 02:19:59 +0300 Subject: [PATCH 016/161] feat(effects_service): - added redevelopment endpoints to effects api --- app/common/api_handlers/json_api_handler.py | 20 +- .../controller_exception_handler.py | 30 +++ app/dependencies.py | 4 +- app/effects_api/dto/development_dto.py | 5 +- app/effects_api/dto/socio_economic_dto.py | 43 ---- app/effects_api/effects_controller.py | 45 ++-- app/effects_api/effects_service.py | 228 +++++++++++------- app/effects_api/models/effects_models.py | 26 -- app/effects_api/modules/scenario_service.py | 11 +- .../schemas/development_response_schema.py | 30 +++ .../schemas/socio_economic_response_schema.py | 23 ++ app/gateways/urban_api_gateway.py | 132 +++++----- .../models => logs_router}/__init__.py | 0 app/logs_router/logs_controller.py | 41 ++++ app/main.py | 11 +- requirements.txt | Bin 682 -> 622 bytes 16 files changed, 379 insertions(+), 270 deletions(-) create mode 100644 app/common/exceptions/controller_exception_handler.py delete mode 100644 app/effects_api/dto/socio_economic_dto.py delete mode 100644 app/effects_api/models/effects_models.py create mode 100644 app/effects_api/schemas/socio_economic_response_schema.py rename app/{effects_api/models => logs_router}/__init__.py (100%) create mode 100644 app/logs_router/logs_controller.py diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index 5ea23cf..7b0cd11 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -57,8 +57,8 @@ async def _check_response_status( ) @staticmethod - async def check_request_params( - params: dict[str, str | int | float | bool] | None, + async def _check_request_params( + params: dict[str, str | int | float | bool] | None, ) -> dict | None: """ Function checks request parameters @@ -101,6 +101,7 @@ async def get( session=session, ) url = self.base_url + endpoint_url + params = await self._check_request_params(params) async with session.get( url=url, headers=headers, @@ -131,7 +132,7 @@ async def post( params: dict | None = None, data: dict | None = None, session: aiohttp.ClientSession | None = None, - ) -> dict | list: + ) -> dict | list: """Function to post data from api Args: endpoint_url (str): Endpoint url @@ -153,11 +154,12 @@ async def post( session=session, ) url = self.base_url + endpoint_url + params = await self._check_request_params(params) async with session.post( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: @@ -198,6 +200,7 @@ async def put( session=session, ) url = self.base_url + endpoint_url + params = await self._check_request_params(params) async with session.put( url=url, headers=headers, @@ -243,6 +246,7 @@ async def delete( session=session, ) url = self.base_url + endpoint_url + params = await self._check_request_params(params) async with session.delete( url=url, headers=headers, @@ -257,4 +261,4 @@ async def delete( params=params, session=session, ) - return result \ No newline at end of file + return result diff --git a/app/common/exceptions/controller_exception_handler.py b/app/common/exceptions/controller_exception_handler.py new file mode 100644 index 0000000..774b027 --- /dev/null +++ b/app/common/exceptions/controller_exception_handler.py @@ -0,0 +1,30 @@ +from fastapi.encoders import jsonable_encoder +from fastapi import HTTPException +from loguru import logger + +from .http_exception_wrapper import http_exception + +base_types = (int, float, str, bool, None) + + +async def handle_controller_exception(func, is_async: bool = True, **kwargs): + try: + if is_async: + return await func(**kwargs) + else: + return func(**kwargs) + except HTTPException as http_e: + raise http_e + except Exception as e: + logger.exception(e) + raise http_exception( + 500, + msg="Internal server error", + _input={ + "func": str(func), + "is_async": is_async, + "kwargs": jsonable_encoder( + {i: (v if type(v) in base_types else v.as_dict()) for i, v in kwargs.items()}), + }, + _detail={"error": repr(e)}, + ) from e diff --git a/app/dependencies.py b/app/dependencies.py index 8bb7d88..071297d 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -6,7 +6,6 @@ from app.gateways.urban_api_gateway import UrbanAPIGateway logger.remove() -logger.add(sys.stderr, level="INFO") log_level = "INFO" log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}" logger.add( @@ -15,11 +14,12 @@ level=log_level, colorize=True ) +logger.add(".log", level=log_level, format=log_format, colorize=False, backtrace=True, diagnose=True) config = Config() logger.add( - f"{config.get('LOGS_FILE')}.log", + ".log", format=log_format, level="INFO", ) diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index 224bf7d..f8804aa 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -22,6 +22,9 @@ class DevelopmentDTO(BaseModel): description="Year of the chosen project functional-zone source.", ) + def as_dict(self): + return self.model_dump(by_alias=False) + class ContextDevelopmentDTO(DevelopmentDTO): context_func_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( @@ -36,4 +39,4 @@ class ContextDevelopmentDTO(DevelopmentDTO): None, examples=[2023, 2024], description="Year of the chosen context functional-zone source.", - ) \ No newline at end of file + ) diff --git a/app/effects_api/dto/socio_economic_dto.py b/app/effects_api/dto/socio_economic_dto.py deleted file mode 100644 index 424718a..0000000 --- a/app/effects_api/dto/socio_economic_dto.py +++ /dev/null @@ -1,43 +0,0 @@ -from typing import Literal, Optional -from pydantic import BaseModel, Field - - -class SocioEconomicDTO(BaseModel): - """Input payload for socio-economic evaluation endpoint.""" - scenario_id: int = Field( - ..., - examples=[788], - description="Project-scenario ID to retrieve data from.", - ) - project_functional_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( - None, - examples=["User", "PZZ", "OSM"], - description=( - "Preferred source for PROJECT functional zones. " - "If omitted: priority User, PZZ, OSM." - ), - ) - project_functional_zone_year: Optional[int] = Field( - None, - examples=[2023, 2024], - description=( - "Year of the chosen PROJECT functional-zone source. " - "If omitted, the newest year is used." - ), - ) - context_functional_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( - None, - examples=["PZZ", "OSM"], - description=( - "Preferred source for CONTEXT functional zones. " - "If omitted: priority PZZ → OSM." - ), - ) - context_functional_zone_year: Optional[int] = Field( - None, - examples=[2023, 2024], - description=( - "Year of the chosen CONTEXT functional-zone source. " - "If omitted, the newest year is used." - ), - ) \ No newline at end of file diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index e913090..c86c0e1 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -1,44 +1,43 @@ from typing import Annotated from fastapi import APIRouter -from fastapi.encoders import jsonable_encoder from fastapi.params import Depends -from starlette.responses import JSONResponse from app.common.auth.auth import verify_token +from app.common.exceptions.controller_exception_handler import handle_controller_exception from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO -from .dto.socio_economic_dto import SocioEconomicDTO -from .effects_service import EffectsService +from .effects_service import effects_service +from .schemas.development_response_schema import DevelopmentResponseSchema +from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema + development_router = APIRouter(prefix='/redevelopment', tags=['Effects']) -#TODO describe output schemas -@development_router.get("/project_redevelopment") + +@development_router.get("/project_redevelopment", response_model=DevelopmentResponseSchema) async def get_project_redevelopment( params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], token: str = Depends(verify_token), -): - pass +) -> DevelopmentResponseSchema: + + return await handle_controller_exception(effects_service.calc_project_development, token=token, params=params) + -@development_router.get("/context_redevelopment") +@development_router.get("/context_redevelopment", response_model=DevelopmentResponseSchema) async def get_context_redevelopment( params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), -): - pass +) -> DevelopmentResponseSchema: -@development_router.get("/socio_economic_prediction") + return await handle_controller_exception(effects_service.calc_context_development, token=token, params=params) + + +@development_router.get("/socio_economic_prediction", response_model=SocioEconomicResponseSchema) async def get_socio_economic_prediction( - params: Annotated[SocioEconomicDTO, Depends(SocioEconomicDTO)], + params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), -): - socio_economic_prediction = await EffectsService.evaluate_master_plan( - params.scenario_id, - params.project_functional_zone_source, - params.project_functional_zone_year, - params.context_functional_zone_source, - params.context_functional_zone_year, - token +) -> SocioEconomicResponseSchema: + + return await handle_controller_exception( + effects_service.evaluate_master_plan, token=token, params=params ) - payload = jsonable_encoder(socio_economic_prediction.to_dict(orient="index")) - return JSONResponse(content=payload) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index e7813d8..420c53a 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,5 +1,3 @@ -# from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO - import pandas as pd import geopandas as gpd from blocksnet.machine_learning.regression import SocialRegressor @@ -19,13 +17,55 @@ from .modules.context_service import get_context_blocks, get_context_functional_zones, get_context_buildings, \ get_context_services -from ..dependencies import urban_api_gateway +from app.dependencies import urban_api_gateway +from .dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO +from .schemas.development_response_schema import DevelopmentResponseSchema +from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema +#TODO add caching service class EffectsService: - def __init__(self): - pass + @staticmethod + async def get_optimal_func_zone_data( + params: DevelopmentDTO | ContextDevelopmentDTO, + token: str + ) -> DevelopmentDTO: + """ + Get optimal functional zone source and year for the project scenario. + If not provided, fetches the best available source and year. + + Params: + params (DevelopmentDTO): DTO with scenario ID and optional + Returns: + DevelopmentDTO: DTO with updated functional zone source and year. + """ + + if not params.proj_func_zone_source or not params.proj_func_source_year: + ( + params.proj_func_zone_source, + params.proj_func_source_year + ) = await urban_api_gateway.get_optimal_func_zone_request_data( + token, + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year + ) + if isinstance(params, ContextDevelopmentDTO): + if not params.context_func_zone_source or not params.context_func_source_year: + project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) + ( + params.context_func_zone_source, + params.context_func_source_year + ) = await urban_api_gateway.get_optimal_func_zone_request_data( + token, + project_id, + params.context_func_zone_source, + params.context_func_source_year, + project=False + ) + return params + return params @staticmethod async def aggregate_blocks_layer_scenario( @@ -33,7 +73,7 @@ async def aggregate_blocks_layer_scenario( functional_zone_source: str = None, functional_zone_year: int = None, token: str = None - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: """ Params: scenario_id : int @@ -62,20 +102,22 @@ async def aggregate_blocks_layer_scenario( scenario_blocks_crs = scenario_blocks_gdf.crs scenario_blocks_gdf['site_area'] = scenario_blocks_gdf.area - scenario_functional_zones = await get_scenario_functional_zones(scenario_id, token, functional_zone_source, functional_zone_year) + scenario_functional_zones = await get_scenario_functional_zones(scenario_id, token, functional_zone_source, + functional_zone_year) scenario_functional_zones = scenario_functional_zones.to_crs(scenario_blocks_crs) scenario_blocks_lu = assign_land_use(scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES) scenario_blocks_gdf = scenario_blocks_gdf.join(scenario_blocks_lu.drop(columns=['geometry'])) logger.success(f"Land use for scenario blocks have been assigned {scenario_id}") scenario_buildings_gdf = await get_scenario_buildings(scenario_id, token) - scenario_buildings_gdf = scenario_buildings_gdf.to_crs(scenario_blocks_crs) if scenario_buildings_gdf is not None: scenario_buildings_gdf = scenario_buildings_gdf.to_crs(scenario_blocks_gdf.crs) blocks_buildings, _ = aggregate_objects(scenario_blocks_gdf, scenario_buildings_gdf) scenario_blocks_gdf = scenario_blocks_gdf.join( blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) scenario_blocks_gdf['count_buildings'] = scenario_blocks_gdf['count_buildings'].fillna(0).astype(int) + if "is_living" not in scenario_blocks_gdf.columns: + scenario_blocks_gdf["count_buildings"], scenario_blocks_gdf["is_living"] = 0, None logger.success(f"Buildings for scenario blocks have been aggregated {scenario_id}") @@ -89,10 +131,11 @@ async def aggregate_blocks_layer_scenario( scenario_blocks_services, _ = aggregate_objects(scenario_blocks_gdf, services) scenario_blocks_services['capacity'] = scenario_blocks_services['capacity'].fillna(0).astype(int) scenario_blocks_services['count'] = scenario_blocks_services['count'].fillna(0).astype(int) - scenario_blocks_gdf = scenario_blocks_gdf.join(scenario_blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'count': f'count_{service_type}', - })) + scenario_blocks_gdf = scenario_blocks_gdf.join( + scenario_blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'count': f'count_{service_type}', + })) scenario_blocks_gdf['is_project'] = True logger.success(f"Services for scenario blocks have been aggregated {scenario_id}") @@ -132,14 +175,14 @@ async def aggregate_blocks_layer_context( context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) context_blocks_gdf['site_area'] = context_blocks_gdf.area - context_functional_zones = await get_context_functional_zones(project_id, context_functional_zone_source, context_functional_zone_year, token) + context_functional_zones = await get_context_functional_zones(project_id, context_functional_zone_source, + context_functional_zone_year, token) context_functional_zones = context_functional_zones.to_crs(context_blocks_crs) context_blocks_lu = assign_land_use(context_blocks_gdf, context_functional_zones, LAND_USE_RULES) context_blocks_gdf = context_blocks_gdf.join(context_blocks_lu.drop(columns=['geometry'])) logger.success(f"Land use for context blocks have been assigned {scenario_id}") context_buildings_gdf = await get_context_buildings(project_id) - context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_crs) if context_buildings_gdf is not None: context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) @@ -147,6 +190,8 @@ async def aggregate_blocks_layer_context( context_blocks_gdf = context_blocks_gdf.join( context_blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) context_blocks_gdf['count_buildings'] = context_blocks_gdf['count_buildings'].fillna(0).astype(int) + if "is_living" not in context_blocks_gdf.columns: + context_blocks_gdf["count_buildings"], context_blocks_gdf["is_living"] = 0, None logger.success(f"Buildings for context blocks have been aggregated {scenario_id}") service_types = await urban_api_gateway.get_service_types() @@ -158,15 +203,15 @@ async def aggregate_blocks_layer_context( context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) context_blocks_services['capacity'] = context_blocks_services['capacity'].fillna(0).astype(int) context_blocks_services['count'] = context_blocks_services['count'].fillna(0).astype(int) - context_blocks_gdf = context_blocks_gdf.join(context_blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'count': f'count_{service_type}', - })) + context_blocks_gdf = context_blocks_gdf.join( + context_blocks_services.drop(columns=['geometry']).rename(columns={ + 'capacity': f'capacity_{service_type}', + 'count': f'count_{service_type}', + })) logger.success(f"Services for context blocks have been aggregated {scenario_id}") return context_blocks_gdf, context_buildings_gdf - @staticmethod async def get_services_layer(scenario_id: int, token: str): """ @@ -250,33 +295,30 @@ async def run_development_parameters( return development_df - @staticmethod async def evaluate_master_plan( - scenario_id: int, - functional_zone_source: str = None, - functional_zone_year: int = None, - context_functional_zone_source: str = None, - context_functional_zone_year: int = None, + self, + params: ContextDevelopmentDTO, token: str = None - ) -> pd.DataFrame: + ) -> SocioEconomicResponseSchema: """ End-to-end pipeline that fuses *project* and *context* blocks, enriches them with development parameters and produces socio-economic forecasts via ``SocialRegressor``. Params: - scenario_id : int - Scenario to evaluate. - functional_zone_source, functional_zone_year : str | int | None - Source and year for **project** functional zones. - context_functional_zone_source, context_functional_zone_year : str | int | None - Source and year for **context** functional zones. + params (ContextDevelopmentDTO): dto class containing following parameters: + scenario_id : int + Scenario to evaluate. + functional_zone_source, functional_zone_year : str | int | None + Source and year for **project** functional zones. + context_functional_zone_source, context_functional_zone_year : str | int | None + Source and year for **context** functional zones. token : str | None, default None Optional bearer token for Urban API. Returns: - pd.DataFrame - Two-row DataFrame with columns: + SocioEconomicResponseSchema: + pd.DataFrame.to_dict(orient="index") representation as schema with additional params keys: `pred`, `lower`, `upper`, `is_interval` – predicted socio-economic indicator and its prediction interval. @@ -286,14 +328,15 @@ async def evaluate_master_plan( 3. Compute development parameters (`run_development_parameters`). 4. Feed summarised indicators into SocialRegressor. """ - logger.info('Evaluating master plan effects') - context_blocks, context_buildings = await EffectsService.aggregate_blocks_layer_context( - scenario_id, context_functional_zone_source, context_functional_zone_year, token + logger.info('Evaluating master plan effects') + params = await self.get_optimal_func_zone_data(params, token) + context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token ) - scenario_blocks, scenario_buildings = await EffectsService.aggregate_blocks_layer_scenario( - scenario_id, functional_zone_source, functional_zone_year, token + scenario_blocks, scenario_buildings = await self.aggregate_blocks_layer_scenario( + params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) @@ -305,7 +348,7 @@ async def evaluate_master_plan( cols = ['residential', 'business', 'recreation', 'industrial', 'transport', 'special', 'agriculture'] blocks[cols] = blocks[cols].clip(upper=1) - development_df = await EffectsService.run_development_parameters(blocks) + development_df = await self.run_development_parameters(blocks) cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] blocks[cols] = development_df[cols].values @@ -327,53 +370,60 @@ async def evaluate_master_plan( } result_df = pd.DataFrame.from_dict(result_data) result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) + res = result_df.to_dict(orient="index") + res = {"socio_economic_prediction": res, "params_data": params.as_dict()} + return SocioEconomicResponseSchema(**res) + + async def calc_project_development(self, token: str, params: DevelopmentDTO) -> DevelopmentResponseSchema: + """ + Function calculates development only for project with blocksnet + Args: + token (str): User token to access data from Urban API + params (DevelopmentDTO): development request params + Returns: + DevelopmentResponseSchema: Response schema with development indicators + """ + + params = await self.get_optimal_func_zone_data(params, token) + blocks, buildings = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token + ) + res = await self.run_development_parameters(blocks) + res = res.to_dict(orient="list") + res.update({"params_data": params.as_dict()}) + return DevelopmentResponseSchema(**res) + + async def calc_context_development(self, token: str, params: ContextDevelopmentDTO) -> DevelopmentResponseSchema: + """ + Function calculates development for context with project with blocksnet + Args: + token (str): User token to access data from Urban API + params (DevelopmentDTO): + Returns: + DevelopmentResponseSchema: Response schema with development indicators + """ + + params = await self.get_optimal_func_zone_data(params, token) + context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token + ) + scenario_blocks, scenario_buildings = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token + ) + blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) + res = await self.run_development_parameters(blocks) + res = res.to_dict(orient="list") + res.update({"params_data": params.as_dict()}) + return DevelopmentResponseSchema(**res) + - return result_df - - # async def calc_project_development(self, token: str, params: DevelopmentDTO): - # """ - # Function calculates development only for project with blocksnet - # Args: - # token (str): User token to access data from Urban API - # params (DevelopmentDTO): development request params - # Returns: - # -- - # """ - # - # try: - # if not params.proj_func_zone_source or not params.proj_func_zone_year: - # ( - # params.proj_func_zone_source, - # params.proj_func_source_year - # ) = await urban_api_gateway.get_optimap_func_zone_request_data() - # res = await self.aggregate_blocks_layer_scenario( - # params.scenario_id, - # token, - # params.proj_func_zone_source, - # params.proj_func_source_year - # ) - # return res - # except HTTPException as http_e: - # logger.exception(http_e) - # raise http_e - # except Exception as e: - # logger.exception(e) - # raise http_exception( - # 500, - # "Error during development calculation", - # _input=params.__dict__, - # _detail={ - # "error": repr(e), - # } - # ) from e - # - # async def calc_context_development(self, params: ContextDevelopmentDTO): - # """ - # Function calculates development for context with project with blocksnet - # Args: - # params (DevelopmentDTO): - # Returns: - # -- - # """ - # - # pass +effects_service = EffectsService() diff --git a/app/effects_api/models/effects_models.py b/app/effects_api/models/effects_models.py deleted file mode 100644 index a3bb0a2..0000000 --- a/app/effects_api/models/effects_models.py +++ /dev/null @@ -1,26 +0,0 @@ -from enum import Enum -from typing import Literal - -from pydantic import BaseModel, Field - - -class EffectType(Enum): - TRANSPORT='Транспорт' - PROVISION='Обеспеченность' - CONNECTIVITY='Связность' - - -class ScaleType(Enum): - PROJECT='Проект' - CONTEXT='Контекст' - - -class ScaleTypeModel(BaseModel): - scale_type: ScaleType = Field(...) - - -class ChartData(BaseModel): - name : str - before : float - after : float - delta : float \ No newline at end of file diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 3e204be..192f397 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -5,6 +5,7 @@ from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks import geopandas as gpd import pandas as pd +from loguru import logger from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway @@ -151,11 +152,19 @@ async def get_scenario_functional_zones(scenario_id: int, token: str, source: st async def get_scenario_buildings(scenario_id: int, token: str): try: gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_type_id=4, centers_only=True) + if gdf is None: + return None gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) except Exception as e: - http_exception(404, f'No buildings found for scenario {scenario_id}', str(e)) + logger.exception(e) + raise http_exception( + 404, + f'No buildings found for scenario {scenario_id}', + _input={"scenario_id": scenario_id}, + _detail={"error": repr(e)} + ) from e async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame, token: str): diff --git a/app/effects_api/schemas/development_response_schema.py b/app/effects_api/schemas/development_response_schema.py index e69de29..f270e18 100644 --- a/app/effects_api/schemas/development_response_schema.py +++ b/app/effects_api/schemas/development_response_schema.py @@ -0,0 +1,30 @@ +from typing_extensions import Self + +from pydantic import BaseModel, Field, model_validator + +from app.effects_api.dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO + + +class DevelopmentResponseSchema(BaseModel): + """ + Schema for the response of the development endpoint. + """ + + site_area: list[float] + fsi: list[float] + gsi: list[float] + mxi: list[float] + build_floor_area: list[float] + footprint_area: list[float] + living_area: list[float] + non_living_area: list[float] + population: list[float] + params_data: DevelopmentDTO | ContextDevelopmentDTO + + @model_validator(mode="after") + def round_floats(self) -> Self: + for field, value in self.model_fields.items(): + if self.model_fields[field].annotation == list[float]: + rounded = [round(i, 2) for i in getattr(self, field)] + setattr(self, field, rounded) + return self diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py new file mode 100644 index 0000000..66c7f85 --- /dev/null +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, field_validator + +from app.effects_api.dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO + + +class SocioEconomicParams(BaseModel): + + pred: int + lower: int + upper: float + is_interval: bool + + @field_validator("upper", mode="after") + @classmethod + def validate_upper(cls, v: float) -> float: + return round(v, 2) + + +class SocioEconomicResponseSchema(BaseModel): + + socio_economic_prediction: dict[str, SocioEconomicParams] + params_data: DevelopmentDTO | ContextDevelopmentDTO + diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 2b13de3..e20e9f7 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -4,6 +4,7 @@ import shapely from typing import Any, Dict, Literal, Optional +from loguru import logger from app.common.api_handlers.json_api_handler import JSONAPIHandler from app.common.exceptions.http_exception_wrapper import http_exception @@ -20,13 +21,9 @@ async def get_physical_objects( project_id: int, **kwargs: Any ) -> gpd.GeoDataFrame: - params = { - k: (str(v).lower() if isinstance(v, bool) else v) - for k, v in kwargs.items() - } res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", - params=params + params=kwargs, ) features = res["features"] return ( @@ -39,13 +36,9 @@ async def get_services( project_id: int, **kwargs: Any ) -> gpd.GeoDataFrame: - params = { - k: (str(v).lower() if isinstance(v, bool) else v) - for k, v in kwargs.items() - } res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/services_with_geometry", - params=params + params=kwargs ) features = res["features"] return ( @@ -93,16 +86,22 @@ async def get_project_geometry(self, project_id: int): async def get_scenario_info(self, target_scenario_id: int, token: str) -> dict: url = f"/api/v1/scenarios/{target_scenario_id}" - headers = await UrbanAPIGateway.get_headers(self, token) + headers = {"Authorization": f"Bearer {token}"} try: response = await self.json_handler.get(url, headers) + return response except Exception as e: - raise http_exception(404, f"Scenario info for ID {target_scenario_id} is missing", str(e)) - return response + logger.exception(e) + raise http_exception( + 404, + f"Scenario info for ID {target_scenario_id} is missing", + _input={"target_scenario_id": target_scenario_id}, + _detail={"error": repr(e)} + ) from e async def get_scenario(self, scenario_id: int, token: str) -> Dict[str, Any]: - headers = await UrbanAPIGateway.get_headers(self, token) - res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}") + headers = {"Authorization": f"Bearer {token}"} + res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}", headers=headers) return res async def get_functional_zones_sources_scenario( @@ -124,10 +123,9 @@ async def get_functional_zones_scenario( year: int, source: str ) -> gpd.GeoDataFrame: - headers = await UrbanAPIGateway.get_headers(self, token) res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/functional_zones", - headers=headers, + headers={"Authorization": f"Bearer {token}"}, params={"year": year, "source": source} ) features = res["features"] @@ -141,22 +139,18 @@ async def get_physical_objects_scenario( scenario_id: int, token: str, **kwargs: Any - ) -> gpd.GeoDataFrame: - headers = await UrbanAPIGateway.get_headers(self, token) - params = { - k: (str(v).lower() if isinstance(v, bool) else v) - for k, v in kwargs.items() - } + ) -> gpd.GeoDataFrame | None: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", - headers=headers, - params=params - ) - features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("physical_object_id") + headers={"Authorization": f"Bearer {token}"}, + params=kwargs ) + if res["features"]: + return ( + gpd.GeoDataFrame.from_features(res, crs=4326) + .set_index("physical_object_id") + ) + return None async def get_services_scenario( self, @@ -164,16 +158,10 @@ async def get_services_scenario( token: str, **kwargs: Any ) -> gpd.GeoDataFrame: - headers = await UrbanAPIGateway.get_headers(self, token) - params = { - k: (str(v).lower() if isinstance(v, bool) else v) - for k, v in kwargs.items() - } - res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/services_with_geometry", - headers=headers, - params=params + headers={"Authorization": f"Bearer {token}"}, + params=kwargs ) features = res["features"] return ( @@ -184,7 +172,7 @@ async def get_services_scenario( async def get_optimal_func_zone_request_data( self, token: str, - scenario_id: int, + data_id: int, source: Literal["PZZ", "OSM", "User"] | None, year: int | None, project: bool = True @@ -193,19 +181,21 @@ async def get_optimal_func_zone_request_data( Function retrieves best matching zone sources based on given source and year. Args: token (str): user token to access data in Urban API. - scenario_id (int): id of scenario to retrieve data by. + data_id (int): id of scenario or project to retrieve data by. If scenario retrieves from project scenario, + otherwise from project source (Literal["PZZ", "OSM", "User"] | None): Functional zone source from urban api. If None in order User -> PZZ -> OSM priority is retrieved. year (int | None): year to retrieve zones for. If None retrieves latest available year. - project (bool): If True retrieves with User source. + project (bool): If True retrieves with User source and from project scenario, + otherwise retrieves from context. Returns: tuple[str, int]: Tuple with source and year. """ - async def get_optimal_source( + async def _get_optimal_source( sources_data: pd.DataFrame, target_year: int | None, - is_project: bool + is_project: bool, ) -> tuple[str, int]: """ Function estimates the best source and year @@ -232,7 +222,7 @@ async def get_optimal_source( source_year = sources_data[sources_data["year"] == target_year].iloc[0] else: source_year = sources_data["year"].max() - return source_name, source_year + return source_name, int(source_year) raise http_exception( 404, "No source found", @@ -257,24 +247,22 @@ async def get_optimal_source( } ) headers = {"Authorization": f"Bearer {token}"} - available_sources = await self.json_handler.get( - f"/api/v1/{scenario_id}/available_sources", - headers=headers - ) + if project: + available_sources = await self.json_handler.get( + f"/api/v1/scenarios/{data_id}/functional_zone_sources", + headers=headers + ) + else: + available_sources = await self.json_handler.get( + f"/api/v1/projects/{data_id}/context/functional_zone_sources", + headers=headers + ) sources_df = pd.DataFrame.from_records(available_sources) if not source: - return await get_optimal_source(sources_df, year, project) + return await _get_optimal_source(sources_df, year, project) else: source_df = sources_df[sources_df["source"] == source] - return await get_optimal_source(source_df, year, project) - - async def get_headers(self, token: Optional[str] = None) -> dict[str, str] | None: - if token: - headers = { - "Authorization": f"Bearer {token}" - } - return headers - return None + return await _get_optimal_source(source_df, year, project) async def get_project_id( self, @@ -282,11 +270,7 @@ async def get_project_id( token: str | None = None, ) -> int: endpoint = f"/api/v1/scenarios/{scenario_id}" - - headers = await UrbanAPIGateway.get_headers(self, token) - - response = await self.json_handler.get(endpoint, headers=headers) - + response = await self.json_handler.get(endpoint, headers={"Authorization": f"Bearer {token}"}) project_id = response.get("project", {}).get("project_id") if project_id is None: raise http_exception( @@ -299,21 +283,21 @@ async def get_project_id( async def get_all_project_info(self, project_id: int, token: Optional[str] = None) -> dict: url = f"/api/v1/projects/{project_id}" - headers = await UrbanAPIGateway.get_headers(self, token) try: - response = await self.json_handler.get(url, headers) + response = await self.json_handler.get(url, headers={"Authorization": f"Bearer {token}"}) + return response except Exception as e: - raise http_exception(404, f"Project info for ID {project_id} is missing", str(e)) - return response + logger.exception(e) + raise http_exception( + 404, + f"Project info for ID {project_id} is missing", + _input={"project_id": project_id}, + _detail={"error": repr(e)} + ) from e async def get_service_types(self, **kwargs): - params = { - k: (str(v).lower() if isinstance(v, bool) else v) - for k, v in kwargs.items() - if v is not None - } - data = await self.json_handler.get("/api/v1/service_types", params=params) + data = await self.json_handler.get("/api/v1/service_types", params=kwargs) items = ( data @@ -377,7 +361,7 @@ async def get_service_type_social_values(self, service_type_id: int, **kwargs): return df async def get_indicators(self, parent_id: int | None = None, **kwargs): - res = self.json_handler.get('/api/v1/indicators_by_parent', params={ + res = await self.json_handler.get('/api/v1/indicators_by_parent', params={ 'parent_id': parent_id, **kwargs }) diff --git a/app/effects_api/models/__init__.py b/app/logs_router/__init__.py similarity index 100% rename from app/effects_api/models/__init__.py rename to app/logs_router/__init__.py diff --git a/app/logs_router/logs_controller.py b/app/logs_router/logs_controller.py new file mode 100644 index 0000000..d4a1990 --- /dev/null +++ b/app/logs_router/logs_controller.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter +from fastapi.responses import FileResponse + +from app.common.exceptions.http_exception_wrapper import http_exception + + +logs_router = APIRouter(prefix="/system", tags=["System"]) + + +@logs_router.get("/logs") +async def get_logs(): + """ + Get logs file from app + """ + + try: + return FileResponse( + ".log", + media_type='application/octet-stream', + filename=f"effects.log", + ) + except FileNotFoundError as e: + raise http_exception( + status_code=404, + msg="Log file not found", + _input={ + "log_path": ".log", + "log_file_name": "effects.log" + }, + _detail={"error": repr(e)} + ) from e + except Exception as e: + raise http_exception( + status_code=500, + msg="Internal server error during reading logs", + _input={ + "log_path": ".log", + "log_file_name": "effects.log" + }, + _detail={"error": repr(e)} + ) from e diff --git a/app/main.py b/app/main.py index a9a9fc0..3219cdb 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,7 @@ from contextlib import asynccontextmanager -from app.effects_api import effects_controller +from app.effects_api.effects_controller import development_router +from app.logs_router.logs_controller import logs_router # from app.effects_api.schemas.task_schema import TaskSchema from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -18,17 +19,21 @@ async def lifespan(router : FastAPI): lifespan=lifespan, ) +origins = ["*"] + # disable cors app.add_middleware( CORSMiddleware, - allow_origin_regex='http://.*', + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) -app.include_router(effects_controller.development_router) @app.get("/", include_in_schema=False) async def read_root(): return RedirectResponse('/docs') + +app.include_router(logs_router) +app.include_router(development_router) diff --git a/requirements.txt b/requirements.txt index 31d70de3a7d904822ff0bed0d2617916e22fcd29..77369fbb8d37738870657b0238f615a8bd36d217 100644 GIT binary patch delta 11 ScmZ3*`i^D88^*~zOichCTm)hO delta 68 zcmaFIvWj)X8%F6;h9ZU>Ak1V)VlZZ?W3Xi~V$fqS0b+y6&l%fgcp11D@)?R5avAa% KD&X=4U^M_X5)In` From 42642c1eb73656e825507e9f1e8588a5049f9cd8 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 13:10:46 +0300 Subject: [PATCH 017/161] refactor(system_controller): - added logs path from env variable - removed lifespan --- app/dependencies.py | 4 +++- app/main.py | 10 ++-------- app/{logs_router => system_router}/__init__.py | 0 .../system_controller.py} | 17 ++++++++++------- 4 files changed, 15 insertions(+), 16 deletions(-) rename app/{logs_router => system_router}/__init__.py (100%) rename app/{logs_router/logs_controller.py => system_router/system_controller.py} (66%) diff --git a/app/dependencies.py b/app/dependencies.py index 071297d..b66be5b 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,4 +1,5 @@ import sys +from pathlib import Path from loguru import logger from iduconfig import Config @@ -16,10 +17,11 @@ ) logger.add(".log", level=log_level, format=log_format, colorize=False, backtrace=True, diagnose=True) +absolute_app_path = Path().absolute() config = Config() logger.add( - ".log", + absolute_app_path / f"{config.get('LOG_NAME')}", format=log_format, level="INFO", ) diff --git a/app/main.py b/app/main.py index 3219cdb..2edccf9 100644 --- a/app/main.py +++ b/app/main.py @@ -1,22 +1,16 @@ from contextlib import asynccontextmanager from app.effects_api.effects_controller import development_router -from app.logs_router.logs_controller import logs_router -# from app.effects_api.schemas.task_schema import TaskSchema +from app.system_router.system_controller import system_router from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse -@asynccontextmanager -async def lifespan(router : FastAPI): - yield - app = FastAPI( title="Effects API", description="API for calculating effects of territory transformation with BlocksNet library", - lifespan=lifespan, ) origins = ["*"] @@ -35,5 +29,5 @@ async def lifespan(router : FastAPI): async def read_root(): return RedirectResponse('/docs') -app.include_router(logs_router) +app.include_router(system_router) app.include_router(development_router) diff --git a/app/logs_router/__init__.py b/app/system_router/__init__.py similarity index 100% rename from app/logs_router/__init__.py rename to app/system_router/__init__.py diff --git a/app/logs_router/logs_controller.py b/app/system_router/system_controller.py similarity index 66% rename from app/logs_router/logs_controller.py rename to app/system_router/system_controller.py index d4a1990..fd11605 100644 --- a/app/logs_router/logs_controller.py +++ b/app/system_router/system_controller.py @@ -1,13 +1,15 @@ from fastapi import APIRouter from fastapi.responses import FileResponse +from app.dependencies import config, absolute_app_path from app.common.exceptions.http_exception_wrapper import http_exception -logs_router = APIRouter(prefix="/system", tags=["System"]) +LOGS_PATH = absolute_app_path / f"{config.get('LOG_NAME')}" +system_router = APIRouter(prefix="/system", tags=["System"]) -@logs_router.get("/logs") +@system_router.get("/logs") async def get_logs(): """ Get logs file from app @@ -15,7 +17,8 @@ async def get_logs(): try: return FileResponse( - ".log", + LOGS_PATH, + media_type='application/octet-stream', filename=f"effects.log", ) @@ -24,8 +27,8 @@ async def get_logs(): status_code=404, msg="Log file not found", _input={ - "log_path": ".log", - "log_file_name": "effects.log" + "log_path": LOGS_PATH, + "log_file_name": config.get('LOG_NAME') }, _detail={"error": repr(e)} ) from e @@ -34,8 +37,8 @@ async def get_logs(): status_code=500, msg="Internal server error during reading logs", _input={ - "log_path": ".log", - "log_file_name": "effects.log" + "log_path": LOGS_PATH, + "log_file_name": config.get('LOG_NAME') }, _detail={"error": repr(e)} ) from e From c4998965d22ecf0e879442328e1773f0d37b685d Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 13:35:16 +0300 Subject: [PATCH 018/161] refactor(controller_exception_handler): - HTTP raise changed --- app/common/exceptions/controller_exception_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/common/exceptions/controller_exception_handler.py b/app/common/exceptions/controller_exception_handler.py index 774b027..6a10916 100644 --- a/app/common/exceptions/controller_exception_handler.py +++ b/app/common/exceptions/controller_exception_handler.py @@ -13,8 +13,8 @@ async def handle_controller_exception(func, is_async: bool = True, **kwargs): return await func(**kwargs) else: return func(**kwargs) - except HTTPException as http_e: - raise http_e + except HTTPException: + raise except Exception as e: logger.exception(e) raise http_exception( From 66712981aa4155df5133634cd4da866bbece25d5 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 13:41:15 +0300 Subject: [PATCH 019/161] refactor(json_api_handler): - request params checker refactored --- app/common/api_handlers/json_api_handler.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index 7b0cd11..aeaf796 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -71,9 +71,8 @@ async def _check_request_params( if params: for key, param in params.items(): if isinstance(param, bool): - params[key] = {True: "true", False: "false"}[param] - return params - return params + params[key] = str(param).lower() + async def get( self, From 77ab104cb11d3cccedcfbaa210fd718f17dfe43b Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 13:48:52 +0300 Subject: [PATCH 020/161] refactor(scenario_service): - removed commented code --- app/effects_api/modules/scenario_service.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 192f397..023d256 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -122,9 +122,6 @@ async def _get_best_functional_zones_source( if not row.empty: return year, s - # else: - # raise http_exception(404, "No source or year were found:", [source, year]) - for s in SOURCES_PRIORITY: rows = sources_df.query("source == @s") if not rows.empty: From d3937a20c159f1095211d54ec78b093d83680405 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 14:17:02 +0300 Subject: [PATCH 021/161] refactor(scenario_service): - removed commented code - added error log - changed source and year retrievment --- app/effects_api/modules/scenario_service.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 023d256..84bd86e 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -139,9 +139,7 @@ async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataF async def get_scenario_functional_zones(scenario_id: int, token: str, source: str = None, year: int = None) -> gpd.GeoDataFrame: - sources_df = await urban_api_gateway.get_functional_zones_sources_scenario(scenario_id, token) - year, source = await _get_best_functional_zones_source(sources_df, source, year) - # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) + source, year = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, token, year, source) return adapt_functional_zones(functional_zones) @@ -171,5 +169,4 @@ async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame, t gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} except Exception as e: - print(f'No buildings found for scenario: {str(e)}') - + logger.error("No buildings found for scenario", e) From e9ff41d97a03650996cc825a817f4444f38954e5 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 14:28:52 +0300 Subject: [PATCH 022/161] refactor(json_api_handler): - replaced .__str__() with str() --- app/common/api_handlers/json_api_handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index aeaf796..d514365 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -45,14 +45,14 @@ async def _check_response_status( raise http_exception( response.status, "Couldn't get data from API", - _input=response.url.__str__(), + _input=str(response.url), _detail=response_info, ) else: raise http_exception( response.status, "Couldn't get data from API", - _input=response.url.__str__(), + _input=str(response.url), _detail=await response.json(), ) From 6503c639b80218664c773607983e3ce3c5bb7a9a Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 14:30:12 +0300 Subject: [PATCH 023/161] chore(requirements): - updated blocksnet version --- requirements.txt | Bin 622 -> 622 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 77369fbb8d37738870657b0238f615a8bd36d217..cbe3c215bd4d0ee9f5b6efe73d17f843de740d4d 100644 GIT binary patch delta 12 TcmaFI@{VPK5u@QoV{0Y=AASS+ delta 12 TcmaFI@{VPK5u?FIV{0Y=A9(}$ From 9af90f8508d8340cfe4cb0d38af2cab1337e4e17 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 16:11:05 +0300 Subject: [PATCH 024/161] refactor(effects_controller): - renamed all to development - added TODO for autogeneration --- app/effects_api/constants/const.py | 2 +- app/effects_api/effects_controller.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index a2ea7b3..b25efba 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -16,7 +16,7 @@ } -# UrbanDB to Blocksnet service mapping +#TODO add map autogeneration SERVICE_TYPES_MAPPING = { # basic 1: 'park', diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index c86c0e1..fb678e6 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -11,11 +11,11 @@ from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema -development_router = APIRouter(prefix='/redevelopment', tags=['Effects']) +development_router = APIRouter(prefix='/development', tags=['Effects']) -@development_router.get("/project_redevelopment", response_model=DevelopmentResponseSchema) -async def get_project_redevelopment( +@development_router.get("/project_development", response_model=DevelopmentResponseSchema) +async def get_project_development( params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], token: str = Depends(verify_token), ) -> DevelopmentResponseSchema: @@ -23,8 +23,8 @@ async def get_project_redevelopment( return await handle_controller_exception(effects_service.calc_project_development, token=token, params=params) -@development_router.get("/context_redevelopment", response_model=DevelopmentResponseSchema) -async def get_context_redevelopment( +@development_router.get("/context_development", response_model=DevelopmentResponseSchema) +async def get_context_development( params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), ) -> DevelopmentResponseSchema: From 56de0ff3c4b2b57207a2a697e280e0b2292f8a1d Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 18:12:52 +0300 Subject: [PATCH 025/161] chore(json_api_handler): - added return statement --- app/common/api_handlers/json_api_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index d514365..13b784f 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -72,7 +72,7 @@ async def _check_request_params( for key, param in params.items(): if isinstance(param, bool): params[key] = str(param).lower() - + return params async def get( self, From 05360435a8b822a92477d875d111f2b89bad1d77 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 18:45:18 +0300 Subject: [PATCH 026/161] style: - run precommit with isort and black - added ipynbs to gitignore - added pre-commit config --- .pre-commit-config.yaml | 11 + app/common/api_handlers/json_api_handler.py | 88 +++-- app/common/auth/auth.py | 11 +- .../controller_exception_handler.py | 8 +- .../exceptions/http_exception_wrapper.py | 11 +- app/dependencies.py | 12 +- app/effects_api/constants/const.py | 142 ++++---- app/effects_api/dto/development_dto.py | 1 + app/effects_api/effects_controller.py | 41 ++- app/effects_api/effects_service.py | 344 ++++++++++++------ app/effects_api/modules/buildings_service.py | 12 +- app/effects_api/modules/context_service.py | 53 ++- .../modules/functional_sources_service.py | 14 +- app/effects_api/modules/scenario_service.py | 79 ++-- .../modules/service_type_service.py | 10 +- app/effects_api/modules/services_service.py | 25 +- .../schemas/development_response_schema.py | 6 +- .../schemas/socio_economic_response_schema.py | 4 +- app/gateways/urban_api_gateway.py | 195 +++++----- app/main.py | 8 +- app/system_router/system_controller.py | 20 +- 21 files changed, 629 insertions(+), 466 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..03a34e6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: https://github.com/psf/black + rev: 25.1.0 + hooks: + - id: black + language_version: python3.11 + + - repo: https://github.com/pycqa/isort + rev: 6.0.1 + hooks: + - id: isort \ No newline at end of file diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index 13b784f..f034dd4 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -6,8 +6,8 @@ class JSONAPIHandler: def __init__( - self, - base_url: str, + self, + base_url: str, ) -> None: """Initialisation function @@ -21,7 +21,7 @@ def __init__( @staticmethod async def _check_response_status( - response: aiohttp.ClientResponse + response: aiohttp.ClientResponse, ) -> list | dict | None: """Function handles response @@ -58,7 +58,7 @@ async def _check_response_status( @staticmethod async def _check_request_params( - params: dict[str, str | int | float | bool] | None, + params: dict[str, str | int | float | bool] | None, ) -> dict | None: """ Function checks request parameters @@ -75,11 +75,11 @@ async def _check_request_params( return params async def get( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to get data from api Args: @@ -101,11 +101,7 @@ async def get( ) url = self.base_url + endpoint_url params = await self._check_request_params(params) - async with session.get( - url=url, - headers=headers, - params=params - ) as response: + async with session.get(url=url, headers=headers, params=params) as response: result = await self._check_response_status(response) if isinstance(result, list): return result @@ -125,12 +121,12 @@ async def get( return result async def post( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -155,10 +151,10 @@ async def post( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.post( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: @@ -171,12 +167,12 @@ async def post( return result async def put( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -201,14 +197,14 @@ async def put( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.put( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: - return await self.put( + return await self.put( endpoint_url=endpoint_url, headers=headers, params=params, @@ -217,12 +213,12 @@ async def put( return result async def delete( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -247,14 +243,14 @@ async def delete( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.delete( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: - return await self.delete( + return await self.delete( endpoint_url=endpoint_url, headers=headers, params=params, diff --git a/app/common/auth/auth.py b/app/common/auth/auth.py index 38853ac..f42f41b 100644 --- a/app/common/auth/auth.py +++ b/app/common/auth/auth.py @@ -3,6 +3,7 @@ http_bearer = HTTPBearer() + def _get_token_from_header(credentials: HTTPAuthorizationCredentials) -> str: if not credentials: raise HTTPException( @@ -14,11 +15,13 @@ def _get_token_from_header(credentials: HTTPAuthorizationCredentials) -> str: if not token: raise HTTPException( - status_code=400, - detail="Token is missing in the authorization header" + status_code=400, detail="Token is missing in the authorization header" ) - + return token -async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(http_bearer)): + +async def verify_token( + credentials: HTTPAuthorizationCredentials = Depends(http_bearer), +): return _get_token_from_header(credentials) diff --git a/app/common/exceptions/controller_exception_handler.py b/app/common/exceptions/controller_exception_handler.py index 6a10916..05246a0 100644 --- a/app/common/exceptions/controller_exception_handler.py +++ b/app/common/exceptions/controller_exception_handler.py @@ -1,5 +1,5 @@ -from fastapi.encoders import jsonable_encoder from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder from loguru import logger from .http_exception_wrapper import http_exception @@ -24,7 +24,11 @@ async def handle_controller_exception(func, is_async: bool = True, **kwargs): "func": str(func), "is_async": is_async, "kwargs": jsonable_encoder( - {i: (v if type(v) in base_types else v.as_dict()) for i, v in kwargs.items()}), + { + i: (v if type(v) in base_types else v.as_dict()) + for i, v in kwargs.items() + } + ), }, _detail={"error": repr(e)}, ) from e diff --git a/app/common/exceptions/http_exception_wrapper.py b/app/common/exceptions/http_exception_wrapper.py index ce9718e..07ccf5d 100644 --- a/app/common/exceptions/http_exception_wrapper.py +++ b/app/common/exceptions/http_exception_wrapper.py @@ -1,12 +1,9 @@ from fastapi import HTTPException -def http_exception(status_code: int, msg: str, _input=None, _detail=None) -> HTTPException: +def http_exception( + status_code: int, msg: str, _input=None, _detail=None +) -> HTTPException: return HTTPException( - status_code=status_code, - detail={ - "msg": msg, - "input": _input, - "detail": _detail - } + status_code=status_code, detail={"msg": msg, "input": _input, "detail": _detail} ) diff --git a/app/dependencies.py b/app/dependencies.py index b66be5b..6679a49 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,21 +1,23 @@ import sys from pathlib import Path -from loguru import logger from iduconfig import Config +from loguru import logger from app.gateways.urban_api_gateway import UrbanAPIGateway logger.remove() log_level = "INFO" log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}" +logger.add(sys.stderr, format=log_format, level=log_level, colorize=True) logger.add( - sys.stderr, - format=log_format, + ".log", level=log_level, - colorize=True + format=log_format, + colorize=False, + backtrace=True, + diagnose=True, ) -logger.add(".log", level=log_level, format=log_format, colorize=False, backtrace=True, diagnose=True) absolute_app_path = Path().absolute() config = Config() diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index b25efba..ce85cac 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -2,112 +2,110 @@ # UrbanDB to Blocksnet land use types mapping LAND_USE_RULES = { - 'residential': LandUse.RESIDENTIAL, - 'recreation': LandUse.RECREATION, - 'special': LandUse.SPECIAL, - 'industrial': LandUse.INDUSTRIAL, - 'agriculture': LandUse.AGRICULTURE, - 'transport': LandUse.TRANSPORT, - 'business': LandUse.BUSINESS, - 'residential_individual': LandUse.RESIDENTIAL, - 'residential_lowrise': LandUse.RESIDENTIAL, - 'residential_midrise': LandUse.RESIDENTIAL, - 'residential_multistorey': LandUse.RESIDENTIAL, + "residential": LandUse.RESIDENTIAL, + "recreation": LandUse.RECREATION, + "special": LandUse.SPECIAL, + "industrial": LandUse.INDUSTRIAL, + "agriculture": LandUse.AGRICULTURE, + "transport": LandUse.TRANSPORT, + "business": LandUse.BUSINESS, + "residential_individual": LandUse.RESIDENTIAL, + "residential_lowrise": LandUse.RESIDENTIAL, + "residential_midrise": LandUse.RESIDENTIAL, + "residential_multistorey": LandUse.RESIDENTIAL, } -#TODO add map autogeneration +# TODO add map autogeneration SERVICE_TYPES_MAPPING = { # basic - 1: 'park', - 21: 'kindergarten', - 22: 'school', - 28: 'polyclinic', - 34: 'pharmacy', - 61: 'cafe', - 66: 'pitch', + 1: "park", + 21: "kindergarten", + 22: "school", + 28: "polyclinic", + 34: "pharmacy", + 61: "cafe", + 66: "pitch", 68: None, # спортивный зал - 74: 'playground', - 78: 'police', + 74: "playground", + 78: "police", # additional 30: None, # стоматология - 35: 'hospital', - 50: 'museum', - 56: 'cinema', - 57: 'mall', - 59: 'stadium', - 62: 'restaurant', - 63: 'bar', + 35: "hospital", + 50: "museum", + 56: "cinema", + 57: "mall", + 59: "stadium", + 62: "restaurant", + 63: "bar", 77: None, # скейт парк 79: None, # пожарная станция - 80: 'train_station', - 89: 'supermarket', + 80: "train_station", + 89: "supermarket", 99: None, # пункт выдачи - 100: 'bank', - 107: 'veterinary', - 143: 'sanatorium', + 100: "bank", + 107: "veterinary", + 143: "sanatorium", # comfort - 5: 'beach', - 27: 'university', + 5: "beach", + 27: "university", 36: None, # роддом - 48: 'library', - 51: 'theatre', - 91: 'market', + 48: "library", + 51: "theatre", + 91: "market", 93: None, # одежда и обувь 94: None, # бытовая техника 95: None, # книжный магазин 96: None, # детские товары 97: None, # спортивный магазин 108: None, # зоомагазин - 110: 'hotel', - 114: 'religion', # религиозный объект + 110: "hotel", + 114: "religion", # религиозный объект # others 26: None, # ССУЗ 32: None, # женская консультация 39: None, # скорая помощь 40: None, # травматология - 45: 'recruitment', - 47: 'multifunctional_center', - 55: 'zoo', - 65: 'bakery', - 67: 'swimming_pool', + 45: "recruitment", + 47: "multifunctional_center", + 55: "zoo", + 65: "bakery", + 67: "swimming_pool", 75: None, # парк аттракционов - 81: 'train_building', - 82: 'aeroway_terminal', # аэропорт?? - 86: 'bus_station', - 88: 'subway_entrance', - 102: 'lawyer', - 103: 'notary', - 109: 'dog_park', - 111: 'hostel', + 81: "train_building", + 82: "aeroway_terminal", # аэропорт?? + 86: "bus_station", + 88: "subway_entrance", + 102: "lawyer", + 103: "notary", + 109: "dog_park", + 111: "hostel", 112: None, # база отдыха 113: None, # памятник } # Rules for agregating building properties from UrbanDB API BUILDINGS_RULES = { - 'number_of_floors': [ - ['floors'], - ['properties', 'storeys_count'], - ['properties', 'osm_data', 'building:levels'] + "number_of_floors": [ + ["floors"], + ["properties", "storeys_count"], + ["properties", "osm_data", "building:levels"], ], - 'footprint_area': [ - ['building_area_official'], - ['building_area_modeled'], - ['properties', 'area_total'] + "footprint_area": [ + ["building_area_official"], + ["building_area_modeled"], + ["properties", "area_total"], ], - 'build_floor_area': [ - ['properties', 'area_total'], + "build_floor_area": [ + ["properties", "area_total"], ], - 'living_area': [ - ['properties', 'living_area_official'], - ['properties', 'living_area'], - ['properties', 'living_area_modeled'] + "living_area": [ + ["properties", "living_area_official"], + ["properties", "living_area"], + ["properties", "living_area_modeled"], ], - 'non_living_area': [ - ['properties', 'area_non_residential'], + "non_living_area": [ + ["properties", "area_non_residential"], ], - 'population': [ - ['properties', 'population_balanced'] - ] + "population": [["properties", "population_balanced"]], } diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index f8804aa..4ede62d 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -1,4 +1,5 @@ from typing import Literal, Optional + from pydantic import BaseModel, Field diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index fb678e6..d6e5bb6 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -4,38 +4,49 @@ from fastapi.params import Depends from app.common.auth.auth import verify_token -from app.common.exceptions.controller_exception_handler import handle_controller_exception -from .dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +from app.common.exceptions.controller_exception_handler import \ + handle_controller_exception + +from .dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO from .effects_service import effects_service from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema - -development_router = APIRouter(prefix='/development', tags=['Effects']) +development_router = APIRouter(prefix="/development", tags=["Effects"]) -@development_router.get("/project_development", response_model=DevelopmentResponseSchema) +@development_router.get( + "/project_development", response_model=DevelopmentResponseSchema +) async def get_project_development( - params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], - token: str = Depends(verify_token), + params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], + token: str = Depends(verify_token), ) -> DevelopmentResponseSchema: - return await handle_controller_exception(effects_service.calc_project_development, token=token, params=params) + return await handle_controller_exception( + effects_service.calc_project_development, token=token, params=params + ) -@development_router.get("/context_development", response_model=DevelopmentResponseSchema) +@development_router.get( + "/context_development", response_model=DevelopmentResponseSchema +) async def get_context_development( - params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], - token: str = Depends(verify_token), + params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], + token: str = Depends(verify_token), ) -> DevelopmentResponseSchema: - return await handle_controller_exception(effects_service.calc_context_development, token=token, params=params) + return await handle_controller_exception( + effects_service.calc_context_development, token=token, params=params + ) -@development_router.get("/socio_economic_prediction", response_model=SocioEconomicResponseSchema) +@development_router.get( + "/socio_economic_prediction", response_model=SocioEconomicResponseSchema +) async def get_socio_economic_prediction( - params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], - token: str = Depends(verify_token), + params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], + token: str = Depends(verify_token), ) -> SocioEconomicResponseSchema: return await handle_controller_exception( diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 420c53a..63c9cc5 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,35 +1,36 @@ -import pandas as pd import geopandas as gpd -from blocksnet.machine_learning.regression import SocialRegressor -from loguru import logger +import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import DensityRegressor +from blocksnet.machine_learning.regression import (DensityRegressor, + SocialRegressor) from blocksnet.relations import generate_adjacency_graph +from loguru import logger -from .constants.const import LAND_USE_RULES - -from app.effects_api.modules.scenario_service import get_scenario_blocks, get_scenario_functional_zones, \ - get_scenario_services, get_scenario_buildings +from app.dependencies import urban_api_gateway +from app.effects_api.modules.scenario_service import ( + get_scenario_blocks, get_scenario_buildings, get_scenario_functional_zones, + get_scenario_services) from app.effects_api.modules.service_type_service import adapt_service_types -from .modules.context_service import get_context_blocks, get_context_functional_zones, get_context_buildings, \ - get_context_services -from app.dependencies import urban_api_gateway +from .constants.const import LAND_USE_RULES from .dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO +from .modules.context_service import (get_context_blocks, + get_context_buildings, + get_context_functional_zones, + get_context_services) from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema -#TODO add caching service +# TODO add caching service class EffectsService: @staticmethod async def get_optimal_func_zone_data( - params: DevelopmentDTO | ContextDevelopmentDTO, - token: str + params: DevelopmentDTO | ContextDevelopmentDTO, token: str ) -> DevelopmentDTO: """ Get optimal functional zone source and year for the project scenario. @@ -42,37 +43,41 @@ async def get_optimal_func_zone_data( """ if not params.proj_func_zone_source or not params.proj_func_source_year: - ( - params.proj_func_zone_source, - params.proj_func_source_year - ) = await urban_api_gateway.get_optimal_func_zone_request_data( - token, - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year + (params.proj_func_zone_source, params.proj_func_source_year) = ( + await urban_api_gateway.get_optimal_func_zone_request_data( + token, + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + ) ) if isinstance(params, ContextDevelopmentDTO): - if not params.context_func_zone_source or not params.context_func_source_year: - project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) + if ( + not params.context_func_zone_source + or not params.context_func_source_year + ): + project_id = await urban_api_gateway.get_project_id( + params.scenario_id, token + ) ( params.context_func_zone_source, - params.context_func_source_year + params.context_func_source_year, ) = await urban_api_gateway.get_optimal_func_zone_request_data( token, project_id, params.context_func_zone_source, params.context_func_source_year, - project=False + project=False, ) return params return params @staticmethod async def aggregate_blocks_layer_scenario( - scenario_id: int, - functional_zone_source: str = None, - functional_zone_year: int = None, - token: str = None + scenario_id: int, + functional_zone_source: str = None, + functional_zone_year: int = None, + token: str = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: """ Params: @@ -100,54 +105,88 @@ async def aggregate_blocks_layer_scenario( logger.info("Starting generating scenario blocks layer") scenario_blocks_gdf = await get_scenario_blocks(scenario_id, token) scenario_blocks_crs = scenario_blocks_gdf.crs - scenario_blocks_gdf['site_area'] = scenario_blocks_gdf.area + scenario_blocks_gdf["site_area"] = scenario_blocks_gdf.area - scenario_functional_zones = await get_scenario_functional_zones(scenario_id, token, functional_zone_source, - functional_zone_year) - scenario_functional_zones = scenario_functional_zones.to_crs(scenario_blocks_crs) - scenario_blocks_lu = assign_land_use(scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES) - scenario_blocks_gdf = scenario_blocks_gdf.join(scenario_blocks_lu.drop(columns=['geometry'])) + scenario_functional_zones = await get_scenario_functional_zones( + scenario_id, token, functional_zone_source, functional_zone_year + ) + scenario_functional_zones = scenario_functional_zones.to_crs( + scenario_blocks_crs + ) + scenario_blocks_lu = assign_land_use( + scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES + ) + scenario_blocks_gdf = scenario_blocks_gdf.join( + scenario_blocks_lu.drop(columns=["geometry"]) + ) logger.success(f"Land use for scenario blocks have been assigned {scenario_id}") scenario_buildings_gdf = await get_scenario_buildings(scenario_id, token) if scenario_buildings_gdf is not None: - scenario_buildings_gdf = scenario_buildings_gdf.to_crs(scenario_blocks_gdf.crs) - blocks_buildings, _ = aggregate_objects(scenario_blocks_gdf, scenario_buildings_gdf) + scenario_buildings_gdf = scenario_buildings_gdf.to_crs( + scenario_blocks_gdf.crs + ) + blocks_buildings, _ = aggregate_objects( + scenario_blocks_gdf, scenario_buildings_gdf + ) scenario_blocks_gdf = scenario_blocks_gdf.join( - blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) - scenario_blocks_gdf['count_buildings'] = scenario_blocks_gdf['count_buildings'].fillna(0).astype(int) + blocks_buildings.drop(columns=["geometry"]).rename( + columns={"count": "count_buildings"} + ) + ) + scenario_blocks_gdf["count_buildings"] = ( + scenario_blocks_gdf["count_buildings"].fillna(0).astype(int) + ) if "is_living" not in scenario_blocks_gdf.columns: - scenario_blocks_gdf["count_buildings"], scenario_blocks_gdf["is_living"] = 0, None + ( + scenario_blocks_gdf["count_buildings"], + scenario_blocks_gdf["is_living"], + ) = (0, None) - logger.success(f"Buildings for scenario blocks have been aggregated {scenario_id}") + logger.success( + f"Buildings for scenario blocks have been aggregated {scenario_id}" + ) service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) - scenario_services_dict = await get_scenario_services(scenario_id, service_types, token) + scenario_services_dict = await get_scenario_services( + scenario_id, service_types, token + ) for service_type, services in scenario_services_dict.items(): services = services.to_crs(scenario_blocks_gdf.crs) - scenario_blocks_services, _ = aggregate_objects(scenario_blocks_gdf, services) - scenario_blocks_services['capacity'] = scenario_blocks_services['capacity'].fillna(0).astype(int) - scenario_blocks_services['count'] = scenario_blocks_services['count'].fillna(0).astype(int) + scenario_blocks_services, _ = aggregate_objects( + scenario_blocks_gdf, services + ) + scenario_blocks_services["capacity"] = ( + scenario_blocks_services["capacity"].fillna(0).astype(int) + ) + scenario_blocks_services["count"] = ( + scenario_blocks_services["count"].fillna(0).astype(int) + ) scenario_blocks_gdf = scenario_blocks_gdf.join( - scenario_blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'count': f'count_{service_type}', - })) + scenario_blocks_services.drop(columns=["geometry"]).rename( + columns={ + "capacity": f"capacity_{service_type}", + "count": f"count_{service_type}", + } + ) + ) - scenario_blocks_gdf['is_project'] = True - logger.success(f"Services for scenario blocks have been aggregated {scenario_id}") + scenario_blocks_gdf["is_project"] = True + logger.success( + f"Services for scenario blocks have been aggregated {scenario_id}" + ) return scenario_blocks_gdf, scenario_buildings_gdf @staticmethod async def aggregate_blocks_layer_context( - scenario_id: int, - context_functional_zone_source: str = None, - context_functional_zone_year: int = None, - token: str = None + scenario_id: int, + context_functional_zone_source: str = None, + context_functional_zone_year: int = None, + token: str = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: """Build a GeoDataFrame for context blocks (territories neighbouring the project) and enrich it with land-use, building and service attributes. @@ -173,26 +212,46 @@ async def aggregate_blocks_layer_context( context_blocks_gdf = await get_context_blocks(project_id) context_blocks_crs = context_blocks_gdf.crs context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) - context_blocks_gdf['site_area'] = context_blocks_gdf.area + context_blocks_gdf["site_area"] = context_blocks_gdf.area - context_functional_zones = await get_context_functional_zones(project_id, context_functional_zone_source, - context_functional_zone_year, token) + context_functional_zones = await get_context_functional_zones( + project_id, + context_functional_zone_source, + context_functional_zone_year, + token, + ) context_functional_zones = context_functional_zones.to_crs(context_blocks_crs) - context_blocks_lu = assign_land_use(context_blocks_gdf, context_functional_zones, LAND_USE_RULES) - context_blocks_gdf = context_blocks_gdf.join(context_blocks_lu.drop(columns=['geometry'])) + context_blocks_lu = assign_land_use( + context_blocks_gdf, context_functional_zones, LAND_USE_RULES + ) + context_blocks_gdf = context_blocks_gdf.join( + context_blocks_lu.drop(columns=["geometry"]) + ) logger.success(f"Land use for context blocks have been assigned {scenario_id}") context_buildings_gdf = await get_context_buildings(project_id) if context_buildings_gdf is not None: context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) - context_blocks_buildings, _ = aggregate_objects(context_blocks_gdf, context_buildings_gdf) + context_blocks_buildings, _ = aggregate_objects( + context_blocks_gdf, context_buildings_gdf + ) context_blocks_gdf = context_blocks_gdf.join( - context_blocks_buildings.drop(columns=['geometry']).rename(columns={'count': 'count_buildings'})) - context_blocks_gdf['count_buildings'] = context_blocks_gdf['count_buildings'].fillna(0).astype(int) + context_blocks_buildings.drop(columns=["geometry"]).rename( + columns={"count": "count_buildings"} + ) + ) + context_blocks_gdf["count_buildings"] = ( + context_blocks_gdf["count_buildings"].fillna(0).astype(int) + ) if "is_living" not in context_blocks_gdf.columns: - context_blocks_gdf["count_buildings"], context_blocks_gdf["is_living"] = 0, None - logger.success(f"Buildings for context blocks have been aggregated {scenario_id}") + ( + context_blocks_gdf["count_buildings"], + context_blocks_gdf["is_living"], + ) = (0, None) + logger.success( + f"Buildings for context blocks have been aggregated {scenario_id}" + ) service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) @@ -201,14 +260,23 @@ async def aggregate_blocks_layer_context( for service_type, services in context_services_dict.items(): services = services.to_crs(context_blocks_gdf.crs) context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) - context_blocks_services['capacity'] = context_blocks_services['capacity'].fillna(0).astype(int) - context_blocks_services['count'] = context_blocks_services['count'].fillna(0).astype(int) + context_blocks_services["capacity"] = ( + context_blocks_services["capacity"].fillna(0).astype(int) + ) + context_blocks_services["count"] = ( + context_blocks_services["count"].fillna(0).astype(int) + ) context_blocks_gdf = context_blocks_gdf.join( - context_blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'count': f'count_{service_type}', - })) - logger.success(f"Services for context blocks have been aggregated {scenario_id}") + context_blocks_services.drop(columns=["geometry"]).rename( + columns={ + "capacity": f"capacity_{service_type}", + "count": f"count_{service_type}", + } + ) + ) + logger.success( + f"Services for context blocks have been aggregated {scenario_id}" + ) return context_blocks_gdf, context_buildings_gdf @@ -230,7 +298,9 @@ async def get_services_layer(scenario_id: int, token: str): """ blocks = await get_scenario_blocks(scenario_id, token) blocks_crs = blocks.crs - logger.info(f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}") + logger.info( + f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}" + ) service_types = await urban_api_gateway.get_service_types() logger.info(f"{service_types}") services_dict = await get_scenario_services(scenario_id, service_types, token) @@ -238,18 +308,28 @@ async def get_services_layer(scenario_id: int, token: str): for service_type, services in services_dict.items(): services = services.to_crs(blocks_crs) blocks_services, _ = aggregate_objects(blocks, services) - blocks_services['capacity'] = blocks_services['capacity'].fillna(0).astype(int) - blocks_services['objects_count'] = blocks_services['objects_count'].fillna(0).astype(int) - blocks = blocks.join(blocks_services.drop(columns=['geometry']).rename(columns={ - 'capacity': f'capacity_{service_type}', - 'objects_count': f'count_{service_type}', - })) - logger.info(f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}") + blocks_services["capacity"] = ( + blocks_services["capacity"].fillna(0).astype(int) + ) + blocks_services["objects_count"] = ( + blocks_services["objects_count"].fillna(0).astype(int) + ) + blocks = blocks.join( + blocks_services.drop(columns=["geometry"]).rename( + columns={ + "capacity": f"capacity_{service_type}", + "objects_count": f"count_{service_type}", + } + ) + ) + logger.info( + f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}" + ) return blocks @staticmethod async def run_development_parameters( - blocks_gdf: gpd.GeoDataFrame, + blocks_gdf: gpd.GeoDataFrame, ) -> pd.DataFrame: """ Compute core *development* indicators (FSI, GSI, MXI, etc.) for each @@ -279,26 +359,24 @@ async def run_development_parameters( dr = DensityRegressor() density_df = dr.evaluate(blocks_gdf, adjacency_graph) - density_df.loc[density_df['fsi'] < 0, 'fsi'] = 0 + density_df.loc[density_df["fsi"] < 0, "fsi"] = 0 - density_df.loc[density_df['gsi'] < 0, 'gsi'] = 0 - density_df.loc[density_df['gsi'] > 1, 'gsi'] = 1 + density_df.loc[density_df["gsi"] < 0, "gsi"] = 0 + density_df.loc[density_df["gsi"] > 1, "gsi"] = 1 - density_df.loc[density_df['mxi'] < 0, 'mxi'] = 0 - density_df.loc[density_df['mxi'] > 1, 'mxi'] = 1 + density_df.loc[density_df["mxi"] < 0, "mxi"] = 0 + density_df.loc[density_df["mxi"] > 1, "mxi"] = 1 - density_df.loc[blocks_gdf['residential'] == 0, 'mxi'] = 0 - density_df['site_area'] = blocks_gdf['site_area'] + density_df.loc[blocks_gdf["residential"] == 0, "mxi"] = 0 + density_df["site_area"] = blocks_gdf["site_area"] development_df = calculate_development_indicators(density_df) - development_df['population'] = development_df['living_area'] // 20 + development_df["population"] = development_df["living_area"] // 20 return development_df async def evaluate_master_plan( - self, - params: ContextDevelopmentDTO, - token: str = None + self, params: ContextDevelopmentDTO, token: str = None ) -> SocioEconomicResponseSchema: """ End-to-end pipeline that fuses *project* and *context* blocks, enriches @@ -329,52 +407,78 @@ async def evaluate_master_plan( 4. Feed summarised indicators into SocialRegressor. """ - logger.info('Evaluating master plan effects') + logger.info("Evaluating master plan effects") params = await self.get_optimal_func_zone_data(params, token) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( - params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, ) - scenario_blocks, scenario_buildings = await self.aggregate_blocks_layer_scenario( - params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token + scenario_blocks, scenario_buildings = ( + await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) blocks = gpd.GeoDataFrame( pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs + crs=context_blocks.crs, ) - cols = ['residential', 'business', 'recreation', 'industrial', 'transport', 'special', 'agriculture'] + cols = [ + "residential", + "business", + "recreation", + "industrial", + "transport", + "special", + "agriculture", + ] blocks[cols] = blocks[cols].clip(upper=1) development_df = await self.run_development_parameters(blocks) - cols = ['build_floor_area', 'footprint_area', 'living_area', 'non_living_area', 'population'] + cols = [ + "build_floor_area", + "footprint_area", + "living_area", + "non_living_area", + "population", + ] blocks[cols] = development_df[cols].values for lu in LandUse: - blocks[lu.value] = blocks[lu.value] * blocks['site_area'] - data = [blocks.drop(columns=['land_use', 'geometry']).sum().to_dict()] + blocks[lu.value] = blocks[lu.value] * blocks["site_area"] + data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] input = pd.DataFrame(data) - input['latitude'] = blocks.geometry.union_all().centroid.x - input['longitude'] = blocks.geometry.union_all().centroid.y - input['buildings_count'] = input['count_buildings'] + input["latitude"] = blocks.geometry.union_all().centroid.x + input["longitude"] = blocks.geometry.union_all().centroid.y + input["buildings_count"] = input["count_buildings"] sr = SocialRegressor() y_pred, pi_lower, pi_upper = sr.evaluate(input) iloc = 0 result_data = { - 'pred': y_pred.apply(round).astype(int).iloc[iloc].to_dict(), - 'lower': pi_lower.iloc[iloc].to_dict(), - 'upper': pi_upper.iloc[iloc].to_dict(), + "pred": y_pred.apply(round).astype(int).iloc[iloc].to_dict(), + "lower": pi_lower.iloc[iloc].to_dict(), + "upper": pi_upper.iloc[iloc].to_dict(), } result_df = pd.DataFrame.from_dict(result_data) - result_df['is_interval'] = (result_df['pred'] <= result_df['upper']) & (result_df['pred'] >= result_df['lower']) + result_df["is_interval"] = (result_df["pred"] <= result_df["upper"]) & ( + result_df["pred"] >= result_df["lower"] + ) res = result_df.to_dict(orient="index") res = {"socio_economic_prediction": res, "params_data": params.as_dict()} return SocioEconomicResponseSchema(**res) - async def calc_project_development(self, token: str, params: DevelopmentDTO) -> DevelopmentResponseSchema: + async def calc_project_development( + self, token: str, params: DevelopmentDTO + ) -> DevelopmentResponseSchema: """ Function calculates development only for project with blocksnet Args: @@ -389,14 +493,16 @@ async def calc_project_development(self, token: str, params: DevelopmentDTO) -> params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, - token + token, ) res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") res.update({"params_data": params.as_dict()}) return DevelopmentResponseSchema(**res) - async def calc_context_development(self, token: str, params: ContextDevelopmentDTO) -> DevelopmentResponseSchema: + async def calc_context_development( + self, token: str, params: ContextDevelopmentDTO + ) -> DevelopmentResponseSchema: """ Function calculates development for context with project with blocksnet Args: @@ -411,13 +517,15 @@ async def calc_context_development(self, token: str, params: ContextDevelopmentD params.scenario_id, params.context_func_zone_source, params.context_func_source_year, - token + token, ) - scenario_blocks, scenario_buildings = await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token + scenario_blocks, scenario_buildings = ( + await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) ) blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) res = await self.run_development_parameters(blocks) diff --git a/app/effects_api/modules/buildings_service.py b/app/effects_api/modules/buildings_service.py index 5167533..4ff5c44 100644 --- a/app/effects_api/modules/buildings_service.py +++ b/app/effects_api/modules/buildings_service.py @@ -11,7 +11,7 @@ def _parse(data: dict | None, *args): if len(args) == 0: value = data[key] if isinstance(value, str): - value = value.replace(',', '.') + value = value.replace(",", ".") return value return _parse(data[key], *args) return None @@ -26,9 +26,11 @@ def _adapt(data: dict, rules: list): def adapt_buildings(buildings_gdf: gpd.GeoDataFrame): - gdf = buildings_gdf[['geometry']].copy() - gdf['is_living'] = buildings_gdf['physical_object_type'].apply(lambda pot: pot['physical_object_type_id'] == 4) + gdf = buildings_gdf[["geometry"]].copy() + gdf["is_living"] = buildings_gdf["physical_object_type"].apply( + lambda pot: pot["physical_object_type_id"] == 4 + ) for column, rules in BUILDINGS_RULES.items(): - series = buildings_gdf['building'].apply(lambda b: _adapt(b, rules)) - gdf[column] = pd.to_numeric(series, errors='coerce') + series = buildings_gdf["building"].apply(lambda b: _adapt(b, rules)) + gdf[column] = pd.to_numeric(series, errors="coerce") return gdf diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index c400e1c..055eb65 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,37 +1,50 @@ import geopandas as gpd import pandas as pd -from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks +from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services -from app.dependencies import urban_api_gateway +from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import adapt_functional_zones -from app.effects_api.modules.scenario_service import _get_best_functional_zones_source, close_gaps +from app.effects_api.modules.functional_sources_service import \ + adapt_functional_zones +from app.effects_api.modules.scenario_service import ( + _get_best_functional_zones_source, close_gaps) from app.effects_api.modules.services_service import adapt_services async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) + return gpd.GeoDataFrame( + geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + ) async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: project = await urban_api_gateway.get_project(project_id) - context_ids = project['properties']['context'] - geometries = [await urban_api_gateway.get_territory_geometry(territory_id) for territory_id in context_ids] + context_ids = project["properties"]["context"] + geometries = [ + await urban_api_gateway.get_territory_geometry(territory_id) + for territory_id in context_ids + ] return gpd.GeoDataFrame(geometry=geometries, crs=4326) async def _get_context_roads(project_id: int): - gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=26) - return gdf[['geometry']].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects( + project_id, physical_object_function_id=26 + ) + return gdf[["geometry"]].reset_index(drop=True) async def _get_context_water(project_id: int): - gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=4) - return gdf[['geometry']].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects( + project_id, physical_object_function_id=4 + ) + return gdf[["geometry"]].reset_index(drop=True) -async def _get_context_blocks(project_id: int, boundaries: gpd.GeoDataFrame) -> gpd.GeoDataFrame: +async def _get_context_blocks( + project_id: int, boundaries: gpd.GeoDataFrame +) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) @@ -54,20 +67,28 @@ async def get_context_blocks(project_id: int): context_boundaries = context_boundaries.to_crs(crs) project_boundaries = project_boundaries.to_crs(crs) - context_boundaries = context_boundaries.overlay(project_boundaries, how='difference') + context_boundaries = context_boundaries.overlay( + project_boundaries, how="difference" + ) return await _get_context_blocks(project_id, context_boundaries) -async def get_context_functional_zones(project_id: int, source: str, year: int, token: str) -> gpd.GeoDataFrame: +async def get_context_functional_zones( + project_id: int, source: str, year: int, token: str +) -> gpd.GeoDataFrame: sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) year, source = await _get_best_functional_zones_source(sources_df, source, year) # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) - functional_zones = await urban_api_gateway.get_functional_zones(project_id, year, source) + functional_zones = await urban_api_gateway.get_functional_zones( + project_id, year, source + ) return adapt_functional_zones(functional_zones) async def get_context_buildings(project_id: int): - gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_type_id=4, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects( + project_id, physical_object_type_id=4, centers_only=True + ) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) diff --git a/app/effects_api/modules/functional_sources_service.py b/app/effects_api/modules/functional_sources_service.py index 2cd69e0..6b96a81 100644 --- a/app/effects_api/modules/functional_sources_service.py +++ b/app/effects_api/modules/functional_sources_service.py @@ -1,10 +1,14 @@ import geopandas as gpd -def _adapt_functional_zone(data : dict): - functional_zone_type_id = data['name'] + +def _adapt_functional_zone(data: dict): + functional_zone_type_id = data["name"] return functional_zone_type_id -def adapt_functional_zones(functional_zones_gdf : gpd.GeoDataFrame): - gdf = functional_zones_gdf[['geometry']].copy() - gdf['functional_zone'] = functional_zones_gdf['functional_zone_type'].apply(_adapt_functional_zone) + +def adapt_functional_zones(functional_zones_gdf: gpd.GeoDataFrame): + gdf = functional_zones_gdf[["geometry"]].copy() + gdf["functional_zone"] = functional_zones_gdf["functional_zone_type"].apply( + _adapt_functional_zone + ) return gdf diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 84bd86e..4c9912d 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,19 +1,19 @@ -import shapely -import numpy as np -from blocksnet.preprocessing.imputing import impute_services -from blocksnet.preprocessing.imputing import impute_buildings -from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks import geopandas as gpd +import numpy as np import pandas as pd +import shapely +from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects +from blocksnet.preprocessing.imputing import impute_buildings, impute_services from loguru import logger from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import adapt_functional_zones +from app.effects_api.modules.functional_sources_service import \ + adapt_functional_zones from app.effects_api.modules.services_service import adapt_services -SOURCES_PRIORITY = ['PZZ', 'OSM', "User"] +SOURCES_PRIORITY = ["PZZ", "OSM", "User"] def close_gaps(gdf, tolerance): # taken from momepy @@ -48,21 +48,28 @@ def close_gaps(gdf, tolerance): # taken from momepy async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) + return gpd.GeoDataFrame( + geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + ) async def _get_scenario_roads(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=26) - return gdf[['geometry']].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects_scenario( + scenario_id, token, physical_object_function_id=26 + ) + return gdf[["geometry"]].reset_index(drop=True) async def _get_scenario_water(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=4) - return gdf[['geometry']].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects_scenario( + scenario_id, token, physical_object_function_id=4 + ) + return gdf[["geometry"]].reset_index(drop=True) -async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, - boundaries: gpd.GeoDataFrame, token) -> gpd.GeoDataFrame: +async def _get_scenario_blocks( + user_scenario_id: int, base_scenario_id: int, boundaries: gpd.GeoDataFrame, token +) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) @@ -70,7 +77,7 @@ async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, water = water.to_crs(crs) user_roads = await _get_scenario_roads(user_scenario_id, token) user_roads = user_roads.to_crs(crs) - base_roads =await _get_scenario_roads(base_scenario_id, token) + base_roads = await _get_scenario_roads(base_scenario_id, token) base_roads = base_roads.to_crs(crs) roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) roads.geometry = close_gaps(roads, 1) @@ -82,16 +89,16 @@ async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: scenario = await urban_api_gateway.get_scenario(scenario_id, token) - project_id = scenario['project']['project_id'] + project_id = scenario["project"]["project_id"] project = await urban_api_gateway.get_project(project_id) - base_scenario_id = project['base_scenario']['id'] + base_scenario_id = project["base_scenario"]["id"] return project_id, base_scenario_id async def _get_best_functional_zones_source( - sources_df: pd.DataFrame, - source: str | None = None, - year: int | None = None, + sources_df: pd.DataFrame, + source: str | None = None, + year: int | None = None, ) -> tuple[int | None, str | None]: """ Pick the (year, source) pair that should be fetched. @@ -135,18 +142,28 @@ async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataF crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) - return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries, token) + return await _get_scenario_blocks( + user_scenario_id, base_scenario_id, project_boundaries, token + ) -async def get_scenario_functional_zones(scenario_id: int, token: str, source: str = None, year: int = None) -> gpd.GeoDataFrame: - source, year = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) - functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, token, year, source) +async def get_scenario_functional_zones( + scenario_id: int, token: str, source: str = None, year: int = None +) -> gpd.GeoDataFrame: + source, year = await urban_api_gateway.get_optimal_func_zone_request_data( + token, scenario_id, year, source + ) + functional_zones = await urban_api_gateway.get_functional_zones_scenario( + scenario_id, token, year, source + ) return adapt_functional_zones(functional_zones) async def get_scenario_buildings(scenario_id: int, token: str): try: - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_type_id=4, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects_scenario( + scenario_id, token, physical_object_type_id=4, centers_only=True + ) if gdf is None: return None gdf = adapt_buildings(gdf.reset_index(drop=True)) @@ -156,15 +173,19 @@ async def get_scenario_buildings(scenario_id: int, token: str): logger.exception(e) raise http_exception( 404, - f'No buildings found for scenario {scenario_id}', + f"No buildings found for scenario {scenario_id}", _input={"scenario_id": scenario_id}, - _detail={"error": repr(e)} + _detail={"error": repr(e)}, ) from e -async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame, token: str): +async def get_scenario_services( + scenario_id: int, service_types: pd.DataFrame, token: str +): try: - gdf = await urban_api_gateway.get_services_scenario(scenario_id, centers_only=True, token=token) + gdf = await urban_api_gateway.get_services_scenario( + scenario_id, centers_only=True, token=token + ) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 55e88ad..143d760 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,16 +1,16 @@ import asyncio -from typing import Optional, List +from typing import List, Optional -from app.dependencies import urban_api_gateway import pandas as pd from blocksnet.config import service_types_config +from app.dependencies import urban_api_gateway from app.effects_api.constants.const import SERVICE_TYPES_MAPPING for st_id, st_name in SERVICE_TYPES_MAPPING.items(): if st_name is None: continue - assert st_name in service_types_config, f'{st_id}:{st_name} not in config' + assert st_name in service_types_config, f"{st_id}:{st_name} not in config" async def _adapt_name(service_type_id: int): @@ -18,7 +18,9 @@ async def _adapt_name(service_type_id: int): async def _adapt_social_values(service_type_id: int): - social_values = await urban_api_gateway.get_service_type_social_values(service_type_id) + social_values = await urban_api_gateway.get_service_type_social_values( + service_type_id + ) if social_values is None: return None else: diff --git a/app/effects_api/modules/services_service.py b/app/effects_api/modules/services_service.py index 169d4b9..ef546c1 100644 --- a/app/effects_api/modules/services_service.py +++ b/app/effects_api/modules/services_service.py @@ -1,15 +1,24 @@ import geopandas as gpd import pandas as pd -def _adapt_service_type(data : dict, service_types : pd.DataFrame) -> int: - service_type_id = int(data['service_type_id']) + +def _adapt_service_type(data: dict, service_types: pd.DataFrame) -> int: + service_type_id = int(data["service_type_id"]) if service_type_id in service_types.index: - service_type_name = service_types.loc[service_type_id, 'name'] + service_type_name = service_types.loc[service_type_id, "name"] return service_type_name return None -def adapt_services(buildings_gdf : gpd.GeoDataFrame, service_types : pd.DataFrame) -> dict[int, gpd.GeoDataFrame]: - gdf = buildings_gdf[['geometry', 'capacity']].copy() - gdf['service_type'] = buildings_gdf['service_type'].apply(lambda st : _adapt_service_type(st, service_types)) - gdf = gdf[~gdf['service_type'].isna()].copy() - return {st:gdf[gdf['service_type']==st].drop(columns=['service_type']) for st in sorted(gdf['service_type'].unique())} \ No newline at end of file + +def adapt_services( + buildings_gdf: gpd.GeoDataFrame, service_types: pd.DataFrame +) -> dict[int, gpd.GeoDataFrame]: + gdf = buildings_gdf[["geometry", "capacity"]].copy() + gdf["service_type"] = buildings_gdf["service_type"].apply( + lambda st: _adapt_service_type(st, service_types) + ) + gdf = gdf[~gdf["service_type"].isna()].copy() + return { + st: gdf[gdf["service_type"] == st].drop(columns=["service_type"]) + for st in sorted(gdf["service_type"].unique()) + } diff --git a/app/effects_api/schemas/development_response_schema.py b/app/effects_api/schemas/development_response_schema.py index f270e18..3674c6a 100644 --- a/app/effects_api/schemas/development_response_schema.py +++ b/app/effects_api/schemas/development_response_schema.py @@ -1,8 +1,8 @@ -from typing_extensions import Self - from pydantic import BaseModel, Field, model_validator +from typing_extensions import Self -from app.effects_api.dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +from app.effects_api.dto.development_dto import (ContextDevelopmentDTO, + DevelopmentDTO) class DevelopmentResponseSchema(BaseModel): diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index 66c7f85..c0e25d9 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, field_validator -from app.effects_api.dto.development_dto import DevelopmentDTO, ContextDevelopmentDTO +from app.effects_api.dto.development_dto import (ContextDevelopmentDTO, + DevelopmentDTO) class SocioEconomicParams(BaseModel): @@ -20,4 +21,3 @@ class SocioEconomicResponseSchema(BaseModel): socio_economic_prediction: dict[str, SocioEconomicParams] params_data: DevelopmentDTO | ContextDevelopmentDTO - diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index e20e9f7..e3bc5d7 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -1,9 +1,9 @@ import json +from typing import Any, Dict, Literal, Optional + import geopandas as gpd import pandas as pd import shapely -from typing import Any, Dict, Literal, Optional - from loguru import logger from app.common.api_handlers.json_api_handler import JSONAPIHandler @@ -17,58 +17,43 @@ def __init__(self, base_url: str) -> None: # TODO context async def get_physical_objects( - self, - project_id: int, - **kwargs: Any + self, project_id: int, **kwargs: Any ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", params=kwargs, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("physical_object_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "physical_object_id" ) - async def get_services( - self, - project_id: int, - **kwargs: Any - ) -> gpd.GeoDataFrame: + async def get_services(self, project_id: int, **kwargs: Any) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/services_with_geometry", - params=kwargs + params=kwargs, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("service_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "service_id" ) - async def get_functional_zones_sources( - self, - project_id: int - ) -> pd.DataFrame: + async def get_functional_zones_sources(self, project_id: int) -> pd.DataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zone_sources" ) return pd.DataFrame(res) async def get_functional_zones( - self, - project_id: int, - year: int, - source: int + self, project_id: int, year: int, source: int ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zones", - params={"year": year, "source": source} + params={"year": year, "source": source}, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("functional_zone_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "functional_zone_id" ) async def get_project(self, project_id: int) -> Dict[str, Any]: @@ -76,9 +61,7 @@ async def get_project(self, project_id: int) -> Dict[str, Any]: return res async def get_project_geometry(self, project_id: int): - res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/territory" - ) + res = await self.json_handler.get(f"/api/v1/projects/{project_id}/territory") geometry_json = json.dumps(res["geometry"]) return shapely.from_geojson(geometry_json) @@ -93,21 +76,23 @@ async def get_scenario_info(self, target_scenario_id: int, token: str) -> dict: except Exception as e: logger.exception(e) raise http_exception( - 404, - f"Scenario info for ID {target_scenario_id} is missing", - _input={"target_scenario_id": target_scenario_id}, - _detail={"error": repr(e)} + 404, + f"Scenario info for ID {target_scenario_id} is missing", + _input={"target_scenario_id": target_scenario_id}, + _detail={"error": repr(e)}, ) from e async def get_scenario(self, scenario_id: int, token: str) -> Dict[str, Any]: headers = {"Authorization": f"Bearer {token}"} - res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}", headers=headers) + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}", headers=headers + ) return res async def get_functional_zones_sources_scenario( - self, - scenario_id: int, - token: str, + self, + scenario_id: int, + token: str, ) -> pd.DataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( @@ -117,65 +102,52 @@ async def get_functional_zones_sources_scenario( return pd.DataFrame(res) async def get_functional_zones_scenario( - self, - scenario_id: int, - token: str, - year: int, - source: str + self, scenario_id: int, token: str, year: int, source: str ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/functional_zones", headers={"Authorization": f"Bearer {token}"}, - params={"year": year, "source": source} + params={"year": year, "source": source}, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("functional_zone_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "functional_zone_id" ) async def get_physical_objects_scenario( - self, - scenario_id: int, - token: str, - **kwargs: Any + self, scenario_id: int, token: str, **kwargs: Any ) -> gpd.GeoDataFrame | None: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", headers={"Authorization": f"Bearer {token}"}, - params=kwargs + params=kwargs, ) if res["features"]: - return ( - gpd.GeoDataFrame.from_features(res, crs=4326) - .set_index("physical_object_id") + return gpd.GeoDataFrame.from_features(res, crs=4326).set_index( + "physical_object_id" ) return None async def get_services_scenario( - self, - scenario_id: int, - token: str, - **kwargs: Any + self, scenario_id: int, token: str, **kwargs: Any ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/services_with_geometry", headers={"Authorization": f"Bearer {token}"}, - params=kwargs + params=kwargs, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("service_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "service_id" ) async def get_optimal_func_zone_request_data( - self, - token: str, - data_id: int, - source: Literal["PZZ", "OSM", "User"] | None, - year: int | None, - project: bool = True + self, + token: str, + data_id: int, + source: Literal["PZZ", "OSM", "User"] | None, + year: int | None, + project: bool = True, ) -> tuple[str, int]: """ Function retrieves best matching zone sources based on given source and year. @@ -193,9 +165,9 @@ async def get_optimal_func_zone_request_data( """ async def _get_optimal_source( - sources_data: pd.DataFrame, - target_year: int | None, - is_project: bool, + sources_data: pd.DataFrame, + target_year: int | None, + is_project: bool, ) -> tuple[str, int]: """ Function estimates the best source and year @@ -219,7 +191,9 @@ async def _get_optimal_source( sources_data = sources_data[sources_data["source"] == i] source_name = sources_data["source"].iloc[0] if year: - source_year = sources_data[sources_data["year"] == target_year].iloc[0] + source_year = sources_data[ + sources_data["year"] == target_year + ].iloc[0] else: source_year = sources_data["year"].max() return source_name, int(source_year) @@ -230,7 +204,7 @@ async def _get_optimal_source( "source": source, "year": year, "is_project": is_project, - } + }, ) if not project and source == "User": @@ -244,18 +218,17 @@ async def _get_optimal_source( }, _detail={ "available_sources": ["PZZ", "OSM"], - } + }, ) headers = {"Authorization": f"Bearer {token}"} if project: available_sources = await self.json_handler.get( - f"/api/v1/scenarios/{data_id}/functional_zone_sources", - headers=headers + f"/api/v1/scenarios/{data_id}/functional_zone_sources", headers=headers ) else: available_sources = await self.json_handler.get( f"/api/v1/projects/{data_id}/context/functional_zone_sources", - headers=headers + headers=headers, ) sources_df = pd.DataFrame.from_records(available_sources) if not source: @@ -265,12 +238,14 @@ async def _get_optimal_source( return await _get_optimal_source(source_df, year, project) async def get_project_id( - self, - scenario_id: int, - token: str | None = None, + self, + scenario_id: int, + token: str | None = None, ) -> int: endpoint = f"/api/v1/scenarios/{scenario_id}" - response = await self.json_handler.get(endpoint, headers={"Authorization": f"Bearer {token}"}) + response = await self.json_handler.get( + endpoint, headers={"Authorization": f"Bearer {token}"} + ) project_id = response.get("project", {}).get("project_id") if project_id is None: raise http_exception( @@ -281,18 +256,22 @@ async def get_project_id( return project_id - async def get_all_project_info(self, project_id: int, token: Optional[str] = None) -> dict: + async def get_all_project_info( + self, project_id: int, token: Optional[str] = None + ) -> dict: url = f"/api/v1/projects/{project_id}" try: - response = await self.json_handler.get(url, headers={"Authorization": f"Bearer {token}"}) + response = await self.json_handler.get( + url, headers={"Authorization": f"Bearer {token}"} + ) return response except Exception as e: logger.exception(e) raise http_exception( - 404, - f"Project info for ID {project_id} is missing", - _input={"project_id": project_id}, - _detail={"error": repr(e)} + 404, + f"Project info for ID {project_id} is missing", + _input={"project_id": project_id}, + _detail={"error": repr(e)}, ) from e async def get_service_types(self, **kwargs): @@ -314,33 +293,34 @@ async def get_service_types(self, **kwargs): for it in items ] - return ( - pd.DataFrame(rows) - .set_index("service_type_id") - ) + return pd.DataFrame(rows).set_index("service_type_id") async def get_social_values(self, **kwargs): - res = await self.json_handler.get('/api/v1/social_values', params=kwargs) - return pd.DataFrame(res).set_index('soc_value_id') + res = await self.json_handler.get("/api/v1/social_values", params=kwargs) + return pd.DataFrame(res).set_index("soc_value_id") async def get_social_value_service_types(self, soc_value_id: int, **kwargs): - data = await self.json_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) + data = await self.json_handler.get( + f"/api/v1/social_values/{soc_value_id}/service_types", params=kwargs + ) if not data: return None if isinstance(data, list): items = data elif isinstance(data, dict): - items = data.get('service_types') or data.get('data') or [] + items = data.get("service_types") or data.get("data") or [] else: items = [] rows = [] for it in items: - rows.append({ - 'soc_value_id': it.get('soc_value_id'), - }) - df = pd.DataFrame(rows).set_index('soc_value_id') + rows.append( + { + "soc_value_id": it.get("soc_value_id"), + } + ) + df = pd.DataFrame(rows).set_index("soc_value_id") return df async def get_service_type_social_values(self, service_type_id: int, **kwargs): @@ -361,14 +341,13 @@ async def get_service_type_social_values(self, service_type_id: int, **kwargs): return df async def get_indicators(self, parent_id: int | None = None, **kwargs): - res = await self.json_handler.get('/api/v1/indicators_by_parent', params={ - 'parent_id': parent_id, - **kwargs - }) - return pd.DataFrame(res).set_index('indicator_id') + res = await self.json_handler.get( + "/api/v1/indicators_by_parent", params={"parent_id": parent_id, **kwargs} + ) + return pd.DataFrame(res).set_index("indicator_id") async def get_territory_geometry(self, territory_id: int): - res = await self.json_handler.get(f'/api/v1/territory/{territory_id}') + res = await self.json_handler.get(f"/api/v1/territory/{territory_id}") geom = res["geometry"] if isinstance(geom, dict): geom = json.dumps(geom) diff --git a/app/main.py b/app/main.py index 2edccf9..f354554 100644 --- a/app/main.py +++ b/app/main.py @@ -1,12 +1,12 @@ from contextlib import asynccontextmanager -from app.effects_api.effects_controller import development_router -from app.system_router.system_controller import system_router from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse +from app.effects_api.effects_controller import development_router +from app.system_router.system_controller import system_router app = FastAPI( title="Effects API", @@ -25,9 +25,11 @@ ) app.add_middleware(GZipMiddleware, minimum_size=100) + @app.get("/", include_in_schema=False) async def read_root(): - return RedirectResponse('/docs') + return RedirectResponse("/docs") + app.include_router(system_router) app.include_router(development_router) diff --git a/app/system_router/system_controller.py b/app/system_router/system_controller.py index fd11605..f058dbf 100644 --- a/app/system_router/system_controller.py +++ b/app/system_router/system_controller.py @@ -1,9 +1,8 @@ from fastapi import APIRouter from fastapi.responses import FileResponse -from app.dependencies import config, absolute_app_path from app.common.exceptions.http_exception_wrapper import http_exception - +from app.dependencies import absolute_app_path, config LOGS_PATH = absolute_app_path / f"{config.get('LOG_NAME')}" system_router = APIRouter(prefix="/system", tags=["System"]) @@ -18,27 +17,20 @@ async def get_logs(): try: return FileResponse( LOGS_PATH, - - media_type='application/octet-stream', + media_type="application/octet-stream", filename=f"effects.log", ) except FileNotFoundError as e: raise http_exception( status_code=404, msg="Log file not found", - _input={ - "log_path": LOGS_PATH, - "log_file_name": config.get('LOG_NAME') - }, - _detail={"error": repr(e)} + _input={"log_path": LOGS_PATH, "log_file_name": config.get("LOG_NAME")}, + _detail={"error": repr(e)}, ) from e except Exception as e: raise http_exception( status_code=500, msg="Internal server error during reading logs", - _input={ - "log_path": LOGS_PATH, - "log_file_name": config.get('LOG_NAME') - }, - _detail={"error": repr(e)} + _input={"log_path": LOGS_PATH, "log_file_name": config.get("LOG_NAME")}, + _detail={"error": repr(e)}, ) from e From 4d50c4e8873895acbf009edf73442404842e3a59 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 18:46:03 +0300 Subject: [PATCH 027/161] chore(gitignore): - added ipynb to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1374b5c..c7c0891 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ target/ # IPython profile_default/ ipython_config.py +*.ipynb # pyenv # For a library or package, you might want to ignore these files since the code is From 4a0ecd9c8b3530afd430f4f03372b251d5003bad Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 18:49:39 +0300 Subject: [PATCH 028/161] chore(system_controller): - added TODO for structlog --- app/system_router/system_controller.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/system_router/system_controller.py b/app/system_router/system_controller.py index f058dbf..6e1bd8d 100644 --- a/app/system_router/system_controller.py +++ b/app/system_router/system_controller.py @@ -8,6 +8,7 @@ system_router = APIRouter(prefix="/system", tags=["System"]) +#TODO use structlog instead of loguru @system_router.get("/logs") async def get_logs(): """ From b947510636a40af98fdd6d07c6dc5b56d8115313 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 30 Jun 2025 19:21:53 +0300 Subject: [PATCH 029/161] feat(scenario logic): - docstring for adapt_services - fixes for when no object for cutting are provided --- app/common/api_handlers/json_api_handler.py | 89 ++++----- app/effects_api/modules/context_service.py | 56 ++---- app/effects_api/modules/scenario_service.py | 107 ++++++----- app/effects_api/modules/services_service.py | 45 +++-- app/gateways/urban_api_gateway.py | 196 +++++++++++--------- 5 files changed, 256 insertions(+), 237 deletions(-) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index f034dd4..a92fe98 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -6,8 +6,8 @@ class JSONAPIHandler: def __init__( - self, - base_url: str, + self, + base_url: str, ) -> None: """Initialisation function @@ -21,7 +21,7 @@ def __init__( @staticmethod async def _check_response_status( - response: aiohttp.ClientResponse, + response: aiohttp.ClientResponse ) -> list | dict | None: """Function handles response @@ -58,7 +58,7 @@ async def _check_response_status( @staticmethod async def _check_request_params( - params: dict[str, str | int | float | bool] | None, + params: dict[str, str | int | float | bool] | None, ) -> dict | None: """ Function checks request parameters @@ -74,12 +74,13 @@ async def _check_request_params( params[key] = str(param).lower() return params + async def get( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to get data from api Args: @@ -101,7 +102,11 @@ async def get( ) url = self.base_url + endpoint_url params = await self._check_request_params(params) - async with session.get(url=url, headers=headers, params=params) as response: + async with session.get( + url=url, + headers=headers, + params=params + ) as response: result = await self._check_response_status(response) if isinstance(result, list): return result @@ -121,12 +126,12 @@ async def get( return result async def post( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -151,10 +156,10 @@ async def post( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.post( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: @@ -167,12 +172,12 @@ async def post( return result async def put( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -197,14 +202,14 @@ async def put( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.put( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: - return await self.put( + return await self.put( endpoint_url=endpoint_url, headers=headers, params=params, @@ -213,12 +218,12 @@ async def put( return result async def delete( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -243,14 +248,14 @@ async def delete( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.delete( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: - return await self.delete( + return await self.delete( endpoint_url=endpoint_url, headers=headers, params=params, diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 055eb65..64d4219 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,50 +1,37 @@ import geopandas as gpd import pandas as pd -from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects +from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks from blocksnet.preprocessing.imputing import impute_buildings, impute_services - from app.dependencies import urban_api_gateway + from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import \ - adapt_functional_zones -from app.effects_api.modules.scenario_service import ( - _get_best_functional_zones_source, close_gaps) +from app.effects_api.modules.functional_sources_service import adapt_functional_zones +from app.effects_api.modules.scenario_service import _get_best_functional_zones_source, close_gaps from app.effects_api.modules.services_service import adapt_services async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 - ) + return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: project = await urban_api_gateway.get_project(project_id) - context_ids = project["properties"]["context"] - geometries = [ - await urban_api_gateway.get_territory_geometry(territory_id) - for territory_id in context_ids - ] + context_ids = project['properties']['context'] + geometries = [await urban_api_gateway.get_territory_geometry(territory_id) for territory_id in context_ids] return gpd.GeoDataFrame(geometry=geometries, crs=4326) async def _get_context_roads(project_id: int): - gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=26 - ) - return gdf[["geometry"]].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=26) + return gdf[['geometry']].reset_index(drop=True) async def _get_context_water(project_id: int): - gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=4 - ) - return gdf[["geometry"]].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=4) + return gdf[['geometry']].reset_index(drop=True) -async def _get_context_blocks( - project_id: int, boundaries: gpd.GeoDataFrame -) -> gpd.GeoDataFrame: +async def _get_context_blocks(project_id: int, boundaries: gpd.GeoDataFrame) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) @@ -67,28 +54,23 @@ async def get_context_blocks(project_id: int): context_boundaries = context_boundaries.to_crs(crs) project_boundaries = project_boundaries.to_crs(crs) - context_boundaries = context_boundaries.overlay( - project_boundaries, how="difference" - ) + context_boundaries = context_boundaries.overlay(project_boundaries, how='difference') return await _get_context_blocks(project_id, context_boundaries) -async def get_context_functional_zones( - project_id: int, source: str, year: int, token: str -) -> gpd.GeoDataFrame: +async def get_context_functional_zones(project_id: int, source: str, year: int, token: str) -> gpd.GeoDataFrame: sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) year, source = await _get_best_functional_zones_source(sources_df, source, year) # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) - functional_zones = await urban_api_gateway.get_functional_zones( - project_id, year, source - ) + functional_zones = await urban_api_gateway.get_functional_zones(project_id, year, source) + functional_zones = functional_zones.loc[ + functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) + ].reset_index(drop=True) return adapt_functional_zones(functional_zones) async def get_context_buildings(project_id: int): - gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_type_id=4, centers_only=True - ) + gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_type_id=4, centers_only=True) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 4c9912d..bfd53dc 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,19 +1,21 @@ -import geopandas as gpd +from typing import Optional + +import shapely import numpy as np +from blocksnet.preprocessing.imputing import impute_services +from blocksnet.preprocessing.imputing import impute_buildings +from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks +import geopandas as gpd import pandas as pd -import shapely -from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects -from blocksnet.preprocessing.imputing import impute_buildings, impute_services from loguru import logger from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import \ - adapt_functional_zones +from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.services_service import adapt_services -SOURCES_PRIORITY = ["PZZ", "OSM", "User"] +SOURCES_PRIORITY = ['PZZ', 'OSM', "User"] def close_gaps(gdf, tolerance): # taken from momepy @@ -48,39 +50,46 @@ def close_gaps(gdf, tolerance): # taken from momepy async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 - ) + return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) async def _get_scenario_roads(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_function_id=26 - ) - return gdf[["geometry"]].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=26) + if gdf is None: + return None + return gdf[['geometry']].reset_index(drop=True) async def _get_scenario_water(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_function_id=4 - ) - return gdf[["geometry"]].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=4) + if gdf is None: + return None + return gdf[['geometry']].reset_index(drop=True) -async def _get_scenario_blocks( - user_scenario_id: int, base_scenario_id: int, boundaries: gpd.GeoDataFrame, token -) -> gpd.GeoDataFrame: +async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, + boundaries: gpd.GeoDataFrame, token) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) water = await _get_scenario_water(user_scenario_id, token) - water = water.to_crs(crs) + if water is not None and not water.empty: + water = water.to_crs(crs) + water = water.explode() user_roads = await _get_scenario_roads(user_scenario_id, token) - user_roads = user_roads.to_crs(crs) - base_roads = await _get_scenario_roads(base_scenario_id, token) - base_roads = base_roads.to_crs(crs) - roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) - roads.geometry = close_gaps(roads, 1) + if user_roads is not None and not user_roads.empty: + user_roads = user_roads.to_crs(crs) + user_roads = user_roads.explode() + base_roads =await _get_scenario_roads(base_scenario_id, token) + if base_roads is not None and not base_roads.empty: + base_roads = base_roads.to_crs(crs) + base_roads = base_roads.explode() + if base_roads is not None and not base_roads.empty and user_roads is not None and not user_roads.empty: + roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) + roads.geometry = close_gaps(roads, 1) + roads = roads.explode(column='geometry') + else: + raise http_exception(404, "No objects found for polygons cutting") lines, polygons = preprocess_urban_objects(roads, None, water) blocks = cut_urban_blocks(boundaries, lines, polygons) @@ -89,16 +98,16 @@ async def _get_scenario_blocks( async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: scenario = await urban_api_gateway.get_scenario(scenario_id, token) - project_id = scenario["project"]["project_id"] + project_id = scenario['project']['project_id'] project = await urban_api_gateway.get_project(project_id) - base_scenario_id = project["base_scenario"]["id"] + base_scenario_id = project['base_scenario']['id'] return project_id, base_scenario_id async def _get_best_functional_zones_source( - sources_df: pd.DataFrame, - source: str | None = None, - year: int | None = None, + sources_df: pd.DataFrame, + source: str | None = None, + year: int | None = None, ) -> tuple[int | None, str | None]: """ Pick the (year, source) pair that should be fetched. @@ -142,28 +151,20 @@ async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataF crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) - return await _get_scenario_blocks( - user_scenario_id, base_scenario_id, project_boundaries, token - ) + return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries, token) -async def get_scenario_functional_zones( - scenario_id: int, token: str, source: str = None, year: int = None -) -> gpd.GeoDataFrame: - source, year = await urban_api_gateway.get_optimal_func_zone_request_data( - token, scenario_id, year, source - ) - functional_zones = await urban_api_gateway.get_functional_zones_scenario( - scenario_id, token, year, source - ) +async def get_scenario_functional_zones(scenario_id: int, token: str, source: str = None, year: int = None) -> gpd.GeoDataFrame: + functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, token, year, source) + functional_zones = functional_zones.loc[ + functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) + ].reset_index(drop=True) return adapt_functional_zones(functional_zones) async def get_scenario_buildings(scenario_id: int, token: str): try: - gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_type_id=4, centers_only=True - ) + gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_type_id=4, centers_only=True) if gdf is None: return None gdf = adapt_buildings(gdf.reset_index(drop=True)) @@ -173,19 +174,15 @@ async def get_scenario_buildings(scenario_id: int, token: str): logger.exception(e) raise http_exception( 404, - f"No buildings found for scenario {scenario_id}", + f'No buildings found for scenario {scenario_id}', _input={"scenario_id": scenario_id}, - _detail={"error": repr(e)}, + _detail={"error": repr(e)} ) from e -async def get_scenario_services( - scenario_id: int, service_types: pd.DataFrame, token: str -): +async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame, token: str): try: - gdf = await urban_api_gateway.get_services_scenario( - scenario_id, centers_only=True, token=token - ) + gdf = await urban_api_gateway.get_services_scenario(scenario_id, centers_only=True, token=token) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/services_service.py b/app/effects_api/modules/services_service.py index ef546c1..d03b8f7 100644 --- a/app/effects_api/modules/services_service.py +++ b/app/effects_api/modules/services_service.py @@ -1,24 +1,37 @@ import geopandas as gpd import pandas as pd - -def _adapt_service_type(data: dict, service_types: pd.DataFrame) -> int: - service_type_id = int(data["service_type_id"]) +def _adapt_service_type(data : dict, service_types : pd.DataFrame) -> int: + service_type_id = int(data['service_type_id']) if service_type_id in service_types.index: - service_type_name = service_types.loc[service_type_id, "name"] + service_type_name = service_types.loc[service_type_id, 'name'] return service_type_name return None +def adapt_services(buildings_gdf : gpd.GeoDataFrame, service_types : pd.DataFrame) -> dict[int, gpd.GeoDataFrame]: + """ + Convert the raw building GeoDataFrame into a dictionary where each key is a + canonical service-type ID and the value is a GeoDataFrame of buildings of + that service type. + + Parameters: + buildings_gdf : gpd.GeoDataFrame + Required columns: + • geometry – building footprint or centroid + • capacity – numeric design capacity + • service_type – raw service-type ID + service_types : pd.DataFrame + Lookup table used by the helper _adapt_service_type to map raw + service_type IDs onto canonical IDs. -def adapt_services( - buildings_gdf: gpd.GeoDataFrame, service_types: pd.DataFrame -) -> dict[int, gpd.GeoDataFrame]: - gdf = buildings_gdf[["geometry", "capacity"]].copy() - gdf["service_type"] = buildings_gdf["service_type"].apply( - lambda st: _adapt_service_type(st, service_types) - ) - gdf = gdf[~gdf["service_type"].isna()].copy() - return { - st: gdf[gdf["service_type"] == st].drop(columns=["service_type"]) - for st in sorted(gdf["service_type"].unique()) - } + Returns: + dict[int, gpd.GeoDataFrame] + Keys are canonical service-type IDs (int). + Each value contains only geometry and capacity columns; the temporary + service_type column is removed. + Buildings whose service_type cannot be mapped are discarded. + """ + gdf = buildings_gdf[['geometry', 'capacity']].copy() + gdf['service_type'] = buildings_gdf['service_type'].apply(lambda st : _adapt_service_type(st, service_types)) + gdf = gdf[~gdf['service_type'].isna()].copy() + return {st:gdf[gdf['service_type']==st].drop(columns=['service_type']) for st in sorted(gdf['service_type'].unique())} \ No newline at end of file diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index e3bc5d7..1755e3f 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -1,9 +1,9 @@ import json -from typing import Any, Dict, Literal, Optional - import geopandas as gpd import pandas as pd import shapely +from typing import Any, Dict, Literal, Optional + from loguru import logger from app.common.api_handlers.json_api_handler import JSONAPIHandler @@ -17,43 +17,58 @@ def __init__(self, base_url: str) -> None: # TODO context async def get_physical_objects( - self, project_id: int, **kwargs: Any + self, + project_id: int, + **kwargs: dict ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", params=kwargs, ) features = res["features"] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "physical_object_id" + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("physical_object_id") ) - async def get_services(self, project_id: int, **kwargs: Any) -> gpd.GeoDataFrame: + async def get_services( + self, + project_id: int, + **kwargs: Any + ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/services_with_geometry", - params=kwargs, + params=kwargs ) features = res["features"] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "service_id" + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("service_id") ) - async def get_functional_zones_sources(self, project_id: int) -> pd.DataFrame: + async def get_functional_zones_sources( + self, + project_id: int + ) -> pd.DataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zone_sources" ) return pd.DataFrame(res) async def get_functional_zones( - self, project_id: int, year: int, source: int + self, + project_id: int, + year: int, + source: int ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zones", - params={"year": year, "source": source}, + params={"year": year, "source": source} ) features = res["features"] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "functional_zone_id" + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("functional_zone_id") ) async def get_project(self, project_id: int) -> Dict[str, Any]: @@ -61,7 +76,9 @@ async def get_project(self, project_id: int) -> Dict[str, Any]: return res async def get_project_geometry(self, project_id: int): - res = await self.json_handler.get(f"/api/v1/projects/{project_id}/territory") + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/territory" + ) geometry_json = json.dumps(res["geometry"]) return shapely.from_geojson(geometry_json) @@ -76,23 +93,21 @@ async def get_scenario_info(self, target_scenario_id: int, token: str) -> dict: except Exception as e: logger.exception(e) raise http_exception( - 404, - f"Scenario info for ID {target_scenario_id} is missing", - _input={"target_scenario_id": target_scenario_id}, - _detail={"error": repr(e)}, + 404, + f"Scenario info for ID {target_scenario_id} is missing", + _input={"target_scenario_id": target_scenario_id}, + _detail={"error": repr(e)} ) from e async def get_scenario(self, scenario_id: int, token: str) -> Dict[str, Any]: headers = {"Authorization": f"Bearer {token}"} - res = await self.json_handler.get( - f"/api/v1/scenarios/{scenario_id}", headers=headers - ) + res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}", headers=headers) return res async def get_functional_zones_sources_scenario( - self, - scenario_id: int, - token: str, + self, + scenario_id: int, + token: str, ) -> pd.DataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( @@ -102,52 +117,65 @@ async def get_functional_zones_sources_scenario( return pd.DataFrame(res) async def get_functional_zones_scenario( - self, scenario_id: int, token: str, year: int, source: str + self, + scenario_id: int, + token: str, + year: int, + source: str ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/functional_zones", headers={"Authorization": f"Bearer {token}"}, - params={"year": year, "source": source}, + params={"year": year, "source": source} ) features = res["features"] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "functional_zone_id" + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("functional_zone_id") ) async def get_physical_objects_scenario( - self, scenario_id: int, token: str, **kwargs: Any + self, + scenario_id: int, + token: str, + **kwargs: Any ) -> gpd.GeoDataFrame | None: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", headers={"Authorization": f"Bearer {token}"}, - params=kwargs, + params=kwargs ) if res["features"]: - return gpd.GeoDataFrame.from_features(res, crs=4326).set_index( - "physical_object_id" + return ( + gpd.GeoDataFrame.from_features(res, crs=4326) + .set_index("physical_object_id") ) return None async def get_services_scenario( - self, scenario_id: int, token: str, **kwargs: Any + self, + scenario_id: int, + token: str, + **kwargs: Any ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/services_with_geometry", headers={"Authorization": f"Bearer {token}"}, - params=kwargs, + params=kwargs ) features = res["features"] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "service_id" + return ( + gpd.GeoDataFrame.from_features(features, crs=4326) + .set_index("service_id") ) async def get_optimal_func_zone_request_data( - self, - token: str, - data_id: int, - source: Literal["PZZ", "OSM", "User"] | None, - year: int | None, - project: bool = True, + self, + token: str, + data_id: int, + source: Literal["PZZ", "OSM", "User"] | None, + year: int | None, + project: bool = True ) -> tuple[str, int]: """ Function retrieves best matching zone sources based on given source and year. @@ -165,9 +193,9 @@ async def get_optimal_func_zone_request_data( """ async def _get_optimal_source( - sources_data: pd.DataFrame, - target_year: int | None, - is_project: bool, + sources_data: pd.DataFrame, + target_year: int | None, + is_project: bool, ) -> tuple[str, int]: """ Function estimates the best source and year @@ -191,11 +219,10 @@ async def _get_optimal_source( sources_data = sources_data[sources_data["source"] == i] source_name = sources_data["source"].iloc[0] if year: - source_year = sources_data[ - sources_data["year"] == target_year - ].iloc[0] + source_year = sources_data[sources_data["year"] == target_year].iloc[0] else: source_year = sources_data["year"].max() + logger.info(f"{source_name}, {int(source_year)}") return source_name, int(source_year) raise http_exception( 404, @@ -204,7 +231,7 @@ async def _get_optimal_source( "source": source, "year": year, "is_project": is_project, - }, + } ) if not project and source == "User": @@ -218,17 +245,18 @@ async def _get_optimal_source( }, _detail={ "available_sources": ["PZZ", "OSM"], - }, + } ) headers = {"Authorization": f"Bearer {token}"} if project: available_sources = await self.json_handler.get( - f"/api/v1/scenarios/{data_id}/functional_zone_sources", headers=headers + f"/api/v1/scenarios/{data_id}/functional_zone_sources", + headers=headers ) else: available_sources = await self.json_handler.get( f"/api/v1/projects/{data_id}/context/functional_zone_sources", - headers=headers, + headers=headers ) sources_df = pd.DataFrame.from_records(available_sources) if not source: @@ -238,14 +266,12 @@ async def _get_optimal_source( return await _get_optimal_source(source_df, year, project) async def get_project_id( - self, - scenario_id: int, - token: str | None = None, + self, + scenario_id: int, + token: str | None = None, ) -> int: endpoint = f"/api/v1/scenarios/{scenario_id}" - response = await self.json_handler.get( - endpoint, headers={"Authorization": f"Bearer {token}"} - ) + response = await self.json_handler.get(endpoint, headers={"Authorization": f"Bearer {token}"}) project_id = response.get("project", {}).get("project_id") if project_id is None: raise http_exception( @@ -256,22 +282,18 @@ async def get_project_id( return project_id - async def get_all_project_info( - self, project_id: int, token: Optional[str] = None - ) -> dict: + async def get_all_project_info(self, project_id: int, token: Optional[str] = None) -> dict: url = f"/api/v1/projects/{project_id}" try: - response = await self.json_handler.get( - url, headers={"Authorization": f"Bearer {token}"} - ) + response = await self.json_handler.get(url, headers={"Authorization": f"Bearer {token}"}) return response except Exception as e: logger.exception(e) raise http_exception( - 404, - f"Project info for ID {project_id} is missing", - _input={"project_id": project_id}, - _detail={"error": repr(e)}, + 404, + f"Project info for ID {project_id} is missing", + _input={"project_id": project_id}, + _detail={"error": repr(e)} ) from e async def get_service_types(self, **kwargs): @@ -293,34 +315,33 @@ async def get_service_types(self, **kwargs): for it in items ] - return pd.DataFrame(rows).set_index("service_type_id") + return ( + pd.DataFrame(rows) + .set_index("service_type_id") + ) async def get_social_values(self, **kwargs): - res = await self.json_handler.get("/api/v1/social_values", params=kwargs) - return pd.DataFrame(res).set_index("soc_value_id") + res = await self.json_handler.get('/api/v1/social_values', params=kwargs) + return pd.DataFrame(res).set_index('soc_value_id') async def get_social_value_service_types(self, soc_value_id: int, **kwargs): - data = await self.json_handler.get( - f"/api/v1/social_values/{soc_value_id}/service_types", params=kwargs - ) + data = await self.json_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) if not data: return None if isinstance(data, list): items = data elif isinstance(data, dict): - items = data.get("service_types") or data.get("data") or [] + items = data.get('service_types') or data.get('data') or [] else: items = [] rows = [] for it in items: - rows.append( - { - "soc_value_id": it.get("soc_value_id"), - } - ) - df = pd.DataFrame(rows).set_index("soc_value_id") + rows.append({ + 'soc_value_id': it.get('soc_value_id'), + }) + df = pd.DataFrame(rows).set_index('soc_value_id') return df async def get_service_type_social_values(self, service_type_id: int, **kwargs): @@ -341,13 +362,14 @@ async def get_service_type_social_values(self, service_type_id: int, **kwargs): return df async def get_indicators(self, parent_id: int | None = None, **kwargs): - res = await self.json_handler.get( - "/api/v1/indicators_by_parent", params={"parent_id": parent_id, **kwargs} - ) - return pd.DataFrame(res).set_index("indicator_id") + res = await self.json_handler.get('/api/v1/indicators_by_parent', params={ + 'parent_id': parent_id, + **kwargs + }) + return pd.DataFrame(res).set_index('indicator_id') async def get_territory_geometry(self, territory_id: int): - res = await self.json_handler.get(f"/api/v1/territory/{territory_id}") + res = await self.json_handler.get(f'/api/v1/territory/{territory_id}') geom = res["geometry"] if isinstance(geom, dict): geom = json.dumps(geom) From cba151b53562c0347f326bb3c38a0603fadf3bac Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 30 Jun 2025 19:23:55 +0300 Subject: [PATCH 030/161] feat(scenario logic): - docstring for adapt_services - fixes for when no object for cutting are provided - black formatting --- app/common/api_handlers/json_api_handler.py | 89 +++++---- app/effects_api/modules/context_service.py | 53 ++++-- app/effects_api/modules/scenario_service.py | 84 ++++++--- app/effects_api/modules/services_service.py | 63 ++++--- app/gateways/urban_api_gateway.py | 195 +++++++++----------- app/system_router/system_controller.py | 2 +- 6 files changed, 257 insertions(+), 229 deletions(-) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index a92fe98..f034dd4 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -6,8 +6,8 @@ class JSONAPIHandler: def __init__( - self, - base_url: str, + self, + base_url: str, ) -> None: """Initialisation function @@ -21,7 +21,7 @@ def __init__( @staticmethod async def _check_response_status( - response: aiohttp.ClientResponse + response: aiohttp.ClientResponse, ) -> list | dict | None: """Function handles response @@ -58,7 +58,7 @@ async def _check_response_status( @staticmethod async def _check_request_params( - params: dict[str, str | int | float | bool] | None, + params: dict[str, str | int | float | bool] | None, ) -> dict | None: """ Function checks request parameters @@ -74,13 +74,12 @@ async def _check_request_params( params[key] = str(param).lower() return params - async def get( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to get data from api Args: @@ -102,11 +101,7 @@ async def get( ) url = self.base_url + endpoint_url params = await self._check_request_params(params) - async with session.get( - url=url, - headers=headers, - params=params - ) as response: + async with session.get(url=url, headers=headers, params=params) as response: result = await self._check_response_status(response) if isinstance(result, list): return result @@ -126,12 +121,12 @@ async def get( return result async def post( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -156,10 +151,10 @@ async def post( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.post( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: @@ -172,12 +167,12 @@ async def post( return result async def put( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -202,14 +197,14 @@ async def put( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.put( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: - return await self.put( + return await self.put( endpoint_url=endpoint_url, headers=headers, params=params, @@ -218,12 +213,12 @@ async def put( return result async def delete( - self, - endpoint_url: str, - headers: dict | None = None, - params: dict | None = None, - data: dict | None = None, - session: aiohttp.ClientSession | None = None, + self, + endpoint_url: str, + headers: dict | None = None, + params: dict | None = None, + data: dict | None = None, + session: aiohttp.ClientSession | None = None, ) -> dict | list: """Function to post data from api Args: @@ -248,14 +243,14 @@ async def delete( url = self.base_url + endpoint_url params = await self._check_request_params(params) async with session.delete( - url=url, - headers=headers, - params=params, - data=data, + url=url, + headers=headers, + params=params, + data=data, ) as response: result = await self._check_response_status(response) if not result: - return await self.delete( + return await self.delete( endpoint_url=endpoint_url, headers=headers, params=params, diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 64d4219..fc947fa 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,37 +1,50 @@ import geopandas as gpd import pandas as pd -from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks +from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services -from app.dependencies import urban_api_gateway +from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import adapt_functional_zones -from app.effects_api.modules.scenario_service import _get_best_functional_zones_source, close_gaps +from app.effects_api.modules.functional_sources_service import \ + adapt_functional_zones +from app.effects_api.modules.scenario_service import ( + _get_best_functional_zones_source, close_gaps) from app.effects_api.modules.services_service import adapt_services async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) + return gpd.GeoDataFrame( + geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + ) async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: project = await urban_api_gateway.get_project(project_id) - context_ids = project['properties']['context'] - geometries = [await urban_api_gateway.get_territory_geometry(territory_id) for territory_id in context_ids] + context_ids = project["properties"]["context"] + geometries = [ + await urban_api_gateway.get_territory_geometry(territory_id) + for territory_id in context_ids + ] return gpd.GeoDataFrame(geometry=geometries, crs=4326) async def _get_context_roads(project_id: int): - gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=26) - return gdf[['geometry']].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects( + project_id, physical_object_function_id=26 + ) + return gdf[["geometry"]].reset_index(drop=True) async def _get_context_water(project_id: int): - gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_function_id=4) - return gdf[['geometry']].reset_index(drop=True) + gdf = await urban_api_gateway.get_physical_objects( + project_id, physical_object_function_id=4 + ) + return gdf[["geometry"]].reset_index(drop=True) -async def _get_context_blocks(project_id: int, boundaries: gpd.GeoDataFrame) -> gpd.GeoDataFrame: +async def _get_context_blocks( + project_id: int, boundaries: gpd.GeoDataFrame +) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) @@ -54,15 +67,21 @@ async def get_context_blocks(project_id: int): context_boundaries = context_boundaries.to_crs(crs) project_boundaries = project_boundaries.to_crs(crs) - context_boundaries = context_boundaries.overlay(project_boundaries, how='difference') + context_boundaries = context_boundaries.overlay( + project_boundaries, how="difference" + ) return await _get_context_blocks(project_id, context_boundaries) -async def get_context_functional_zones(project_id: int, source: str, year: int, token: str) -> gpd.GeoDataFrame: +async def get_context_functional_zones( + project_id: int, source: str, year: int, token: str +) -> gpd.GeoDataFrame: sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) year, source = await _get_best_functional_zones_source(sources_df, source, year) # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) - functional_zones = await urban_api_gateway.get_functional_zones(project_id, year, source) + functional_zones = await urban_api_gateway.get_functional_zones( + project_id, year, source + ) functional_zones = functional_zones.loc[ functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) ].reset_index(drop=True) @@ -70,7 +89,9 @@ async def get_context_functional_zones(project_id: int, source: str, year: int, async def get_context_buildings(project_id: int): - gdf = await urban_api_gateway.get_physical_objects(project_id, physical_object_type_id=4, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects( + project_id, physical_object_type_id=4, centers_only=True + ) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index bfd53dc..884f694 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,21 +1,21 @@ from typing import Optional -import shapely -import numpy as np -from blocksnet.preprocessing.imputing import impute_services -from blocksnet.preprocessing.imputing import impute_buildings -from blocksnet.blocks.cutting import preprocess_urban_objects, cut_urban_blocks import geopandas as gpd +import numpy as np import pandas as pd +import shapely +from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects +from blocksnet.preprocessing.imputing import impute_buildings, impute_services from loguru import logger from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import adapt_functional_zones +from app.effects_api.modules.functional_sources_service import \ + adapt_functional_zones from app.effects_api.modules.services_service import adapt_services -SOURCES_PRIORITY = ['PZZ', 'OSM', "User"] +SOURCES_PRIORITY = ["PZZ", "OSM", "User"] def close_gaps(gdf, tolerance): # taken from momepy @@ -50,25 +50,32 @@ def close_gaps(gdf, tolerance): # taken from momepy async def _get_project_boundaries(project_id: int): - return gpd.GeoDataFrame(geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326) + return gpd.GeoDataFrame( + geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + ) async def _get_scenario_roads(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=26) + gdf = await urban_api_gateway.get_physical_objects_scenario( + scenario_id, token, physical_object_function_id=26 + ) if gdf is None: return None - return gdf[['geometry']].reset_index(drop=True) + return gdf[["geometry"]].reset_index(drop=True) async def _get_scenario_water(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_function_id=4) + gdf = await urban_api_gateway.get_physical_objects_scenario( + scenario_id, token, physical_object_function_id=4 + ) if gdf is None: return None - return gdf[['geometry']].reset_index(drop=True) + return gdf[["geometry"]].reset_index(drop=True) -async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, - boundaries: gpd.GeoDataFrame, token) -> gpd.GeoDataFrame: +async def _get_scenario_blocks( + user_scenario_id: int, base_scenario_id: int, boundaries: gpd.GeoDataFrame, token +) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) @@ -80,14 +87,19 @@ async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, if user_roads is not None and not user_roads.empty: user_roads = user_roads.to_crs(crs) user_roads = user_roads.explode() - base_roads =await _get_scenario_roads(base_scenario_id, token) + base_roads = await _get_scenario_roads(base_scenario_id, token) if base_roads is not None and not base_roads.empty: base_roads = base_roads.to_crs(crs) base_roads = base_roads.explode() - if base_roads is not None and not base_roads.empty and user_roads is not None and not user_roads.empty: + if ( + base_roads is not None + and not base_roads.empty + and user_roads is not None + and not user_roads.empty + ): roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) roads.geometry = close_gaps(roads, 1) - roads = roads.explode(column='geometry') + roads = roads.explode(column="geometry") else: raise http_exception(404, "No objects found for polygons cutting") @@ -98,16 +110,16 @@ async def _get_scenario_blocks(user_scenario_id: int, base_scenario_id: int, async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: scenario = await urban_api_gateway.get_scenario(scenario_id, token) - project_id = scenario['project']['project_id'] + project_id = scenario["project"]["project_id"] project = await urban_api_gateway.get_project(project_id) - base_scenario_id = project['base_scenario']['id'] + base_scenario_id = project["base_scenario"]["id"] return project_id, base_scenario_id async def _get_best_functional_zones_source( - sources_df: pd.DataFrame, - source: str | None = None, - year: int | None = None, + sources_df: pd.DataFrame, + source: str | None = None, + year: int | None = None, ) -> tuple[int | None, str | None]: """ Pick the (year, source) pair that should be fetched. @@ -151,11 +163,17 @@ async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataF crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) - return await _get_scenario_blocks(user_scenario_id, base_scenario_id, project_boundaries, token) + return await _get_scenario_blocks( + user_scenario_id, base_scenario_id, project_boundaries, token + ) -async def get_scenario_functional_zones(scenario_id: int, token: str, source: str = None, year: int = None) -> gpd.GeoDataFrame: - functional_zones = await urban_api_gateway.get_functional_zones_scenario(scenario_id, token, year, source) +async def get_scenario_functional_zones( + scenario_id: int, token: str, source: str = None, year: int = None +) -> gpd.GeoDataFrame: + functional_zones = await urban_api_gateway.get_functional_zones_scenario( + scenario_id, token, year, source + ) functional_zones = functional_zones.loc[ functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) ].reset_index(drop=True) @@ -164,7 +182,9 @@ async def get_scenario_functional_zones(scenario_id: int, token: str, source: st async def get_scenario_buildings(scenario_id: int, token: str): try: - gdf = await urban_api_gateway.get_physical_objects_scenario(scenario_id, token, physical_object_type_id=4, centers_only=True) + gdf = await urban_api_gateway.get_physical_objects_scenario( + scenario_id, token, physical_object_type_id=4, centers_only=True + ) if gdf is None: return None gdf = adapt_buildings(gdf.reset_index(drop=True)) @@ -174,15 +194,19 @@ async def get_scenario_buildings(scenario_id: int, token: str): logger.exception(e) raise http_exception( 404, - f'No buildings found for scenario {scenario_id}', + f"No buildings found for scenario {scenario_id}", _input={"scenario_id": scenario_id}, - _detail={"error": repr(e)} + _detail={"error": repr(e)}, ) from e -async def get_scenario_services(scenario_id: int, service_types: pd.DataFrame, token: str): +async def get_scenario_services( + scenario_id: int, service_types: pd.DataFrame, token: str +): try: - gdf = await urban_api_gateway.get_services_scenario(scenario_id, centers_only=True, token=token) + gdf = await urban_api_gateway.get_services_scenario( + scenario_id, centers_only=True, token=token + ) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/services_service.py b/app/effects_api/modules/services_service.py index d03b8f7..5d15c6c 100644 --- a/app/effects_api/modules/services_service.py +++ b/app/effects_api/modules/services_service.py @@ -1,37 +1,46 @@ import geopandas as gpd import pandas as pd -def _adapt_service_type(data : dict, service_types : pd.DataFrame) -> int: - service_type_id = int(data['service_type_id']) + +def _adapt_service_type(data: dict, service_types: pd.DataFrame) -> int: + service_type_id = int(data["service_type_id"]) if service_type_id in service_types.index: - service_type_name = service_types.loc[service_type_id, 'name'] + service_type_name = service_types.loc[service_type_id, "name"] return service_type_name return None -def adapt_services(buildings_gdf : gpd.GeoDataFrame, service_types : pd.DataFrame) -> dict[int, gpd.GeoDataFrame]: + +def adapt_services( + buildings_gdf: gpd.GeoDataFrame, service_types: pd.DataFrame +) -> dict[int, gpd.GeoDataFrame]: """ - Convert the raw building GeoDataFrame into a dictionary where each key is a - canonical service-type ID and the value is a GeoDataFrame of buildings of - that service type. + Convert the raw building GeoDataFrame into a dictionary where each key is a + canonical service-type ID and the value is a GeoDataFrame of buildings of + that service type. - Parameters: - buildings_gdf : gpd.GeoDataFrame - Required columns: - • geometry – building footprint or centroid - • capacity – numeric design capacity - • service_type – raw service-type ID - service_types : pd.DataFrame - Lookup table used by the helper _adapt_service_type to map raw - service_type IDs onto canonical IDs. + Parameters: + buildings_gdf : gpd.GeoDataFrame + Required columns: + • geometry – building footprint or centroid + • capacity – numeric design capacity + • service_type – raw service-type ID + service_types : pd.DataFrame + Lookup table used by the helper _adapt_service_type to map raw + service_type IDs onto canonical IDs. - Returns: - dict[int, gpd.GeoDataFrame] - Keys are canonical service-type IDs (int). - Each value contains only geometry and capacity columns; the temporary - service_type column is removed. - Buildings whose service_type cannot be mapped are discarded. - """ - gdf = buildings_gdf[['geometry', 'capacity']].copy() - gdf['service_type'] = buildings_gdf['service_type'].apply(lambda st : _adapt_service_type(st, service_types)) - gdf = gdf[~gdf['service_type'].isna()].copy() - return {st:gdf[gdf['service_type']==st].drop(columns=['service_type']) for st in sorted(gdf['service_type'].unique())} \ No newline at end of file + Returns: + dict[int, gpd.GeoDataFrame] + Keys are canonical service-type IDs (int). + Each value contains only geometry and capacity columns; the temporary + service_type column is removed. + Buildings whose service_type cannot be mapped are discarded. + """ + gdf = buildings_gdf[["geometry", "capacity"]].copy() + gdf["service_type"] = buildings_gdf["service_type"].apply( + lambda st: _adapt_service_type(st, service_types) + ) + gdf = gdf[~gdf["service_type"].isna()].copy() + return { + st: gdf[gdf["service_type"] == st].drop(columns=["service_type"]) + for st in sorted(gdf["service_type"].unique()) + } diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 1755e3f..6d576b6 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -1,9 +1,9 @@ import json +from typing import Any, Dict, Literal, Optional + import geopandas as gpd import pandas as pd import shapely -from typing import Any, Dict, Literal, Optional - from loguru import logger from app.common.api_handlers.json_api_handler import JSONAPIHandler @@ -17,58 +17,43 @@ def __init__(self, base_url: str) -> None: # TODO context async def get_physical_objects( - self, - project_id: int, - **kwargs: dict + self, project_id: int, **kwargs: dict ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", params=kwargs, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("physical_object_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "physical_object_id" ) - async def get_services( - self, - project_id: int, - **kwargs: Any - ) -> gpd.GeoDataFrame: + async def get_services(self, project_id: int, **kwargs: Any) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/services_with_geometry", - params=kwargs + params=kwargs, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("service_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "service_id" ) - async def get_functional_zones_sources( - self, - project_id: int - ) -> pd.DataFrame: + async def get_functional_zones_sources(self, project_id: int) -> pd.DataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zone_sources" ) return pd.DataFrame(res) async def get_functional_zones( - self, - project_id: int, - year: int, - source: int + self, project_id: int, year: int, source: int ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zones", - params={"year": year, "source": source} + params={"year": year, "source": source}, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("functional_zone_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "functional_zone_id" ) async def get_project(self, project_id: int) -> Dict[str, Any]: @@ -76,9 +61,7 @@ async def get_project(self, project_id: int) -> Dict[str, Any]: return res async def get_project_geometry(self, project_id: int): - res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/territory" - ) + res = await self.json_handler.get(f"/api/v1/projects/{project_id}/territory") geometry_json = json.dumps(res["geometry"]) return shapely.from_geojson(geometry_json) @@ -93,21 +76,23 @@ async def get_scenario_info(self, target_scenario_id: int, token: str) -> dict: except Exception as e: logger.exception(e) raise http_exception( - 404, - f"Scenario info for ID {target_scenario_id} is missing", - _input={"target_scenario_id": target_scenario_id}, - _detail={"error": repr(e)} + 404, + f"Scenario info for ID {target_scenario_id} is missing", + _input={"target_scenario_id": target_scenario_id}, + _detail={"error": repr(e)}, ) from e async def get_scenario(self, scenario_id: int, token: str) -> Dict[str, Any]: headers = {"Authorization": f"Bearer {token}"} - res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}", headers=headers) + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}", headers=headers + ) return res async def get_functional_zones_sources_scenario( - self, - scenario_id: int, - token: str, + self, + scenario_id: int, + token: str, ) -> pd.DataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( @@ -117,65 +102,52 @@ async def get_functional_zones_sources_scenario( return pd.DataFrame(res) async def get_functional_zones_scenario( - self, - scenario_id: int, - token: str, - year: int, - source: str + self, scenario_id: int, token: str, year: int, source: str ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/functional_zones", headers={"Authorization": f"Bearer {token}"}, - params={"year": year, "source": source} + params={"year": year, "source": source}, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("functional_zone_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "functional_zone_id" ) async def get_physical_objects_scenario( - self, - scenario_id: int, - token: str, - **kwargs: Any + self, scenario_id: int, token: str, **kwargs: Any ) -> gpd.GeoDataFrame | None: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/physical_objects_with_geometry", headers={"Authorization": f"Bearer {token}"}, - params=kwargs + params=kwargs, ) if res["features"]: - return ( - gpd.GeoDataFrame.from_features(res, crs=4326) - .set_index("physical_object_id") + return gpd.GeoDataFrame.from_features(res, crs=4326).set_index( + "physical_object_id" ) return None async def get_services_scenario( - self, - scenario_id: int, - token: str, - **kwargs: Any + self, scenario_id: int, token: str, **kwargs: Any ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/services_with_geometry", headers={"Authorization": f"Bearer {token}"}, - params=kwargs + params=kwargs, ) features = res["features"] - return ( - gpd.GeoDataFrame.from_features(features, crs=4326) - .set_index("service_id") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "service_id" ) async def get_optimal_func_zone_request_data( - self, - token: str, - data_id: int, - source: Literal["PZZ", "OSM", "User"] | None, - year: int | None, - project: bool = True + self, + token: str, + data_id: int, + source: Literal["PZZ", "OSM", "User"] | None, + year: int | None, + project: bool = True, ) -> tuple[str, int]: """ Function retrieves best matching zone sources based on given source and year. @@ -193,9 +165,9 @@ async def get_optimal_func_zone_request_data( """ async def _get_optimal_source( - sources_data: pd.DataFrame, - target_year: int | None, - is_project: bool, + sources_data: pd.DataFrame, + target_year: int | None, + is_project: bool, ) -> tuple[str, int]: """ Function estimates the best source and year @@ -219,7 +191,9 @@ async def _get_optimal_source( sources_data = sources_data[sources_data["source"] == i] source_name = sources_data["source"].iloc[0] if year: - source_year = sources_data[sources_data["year"] == target_year].iloc[0] + source_year = sources_data[ + sources_data["year"] == target_year + ].iloc[0] else: source_year = sources_data["year"].max() logger.info(f"{source_name}, {int(source_year)}") @@ -231,7 +205,7 @@ async def _get_optimal_source( "source": source, "year": year, "is_project": is_project, - } + }, ) if not project and source == "User": @@ -245,18 +219,17 @@ async def _get_optimal_source( }, _detail={ "available_sources": ["PZZ", "OSM"], - } + }, ) headers = {"Authorization": f"Bearer {token}"} if project: available_sources = await self.json_handler.get( - f"/api/v1/scenarios/{data_id}/functional_zone_sources", - headers=headers + f"/api/v1/scenarios/{data_id}/functional_zone_sources", headers=headers ) else: available_sources = await self.json_handler.get( f"/api/v1/projects/{data_id}/context/functional_zone_sources", - headers=headers + headers=headers, ) sources_df = pd.DataFrame.from_records(available_sources) if not source: @@ -266,12 +239,14 @@ async def _get_optimal_source( return await _get_optimal_source(source_df, year, project) async def get_project_id( - self, - scenario_id: int, - token: str | None = None, + self, + scenario_id: int, + token: str | None = None, ) -> int: endpoint = f"/api/v1/scenarios/{scenario_id}" - response = await self.json_handler.get(endpoint, headers={"Authorization": f"Bearer {token}"}) + response = await self.json_handler.get( + endpoint, headers={"Authorization": f"Bearer {token}"} + ) project_id = response.get("project", {}).get("project_id") if project_id is None: raise http_exception( @@ -282,18 +257,22 @@ async def get_project_id( return project_id - async def get_all_project_info(self, project_id: int, token: Optional[str] = None) -> dict: + async def get_all_project_info( + self, project_id: int, token: Optional[str] = None + ) -> dict: url = f"/api/v1/projects/{project_id}" try: - response = await self.json_handler.get(url, headers={"Authorization": f"Bearer {token}"}) + response = await self.json_handler.get( + url, headers={"Authorization": f"Bearer {token}"} + ) return response except Exception as e: logger.exception(e) raise http_exception( - 404, - f"Project info for ID {project_id} is missing", - _input={"project_id": project_id}, - _detail={"error": repr(e)} + 404, + f"Project info for ID {project_id} is missing", + _input={"project_id": project_id}, + _detail={"error": repr(e)}, ) from e async def get_service_types(self, **kwargs): @@ -315,33 +294,34 @@ async def get_service_types(self, **kwargs): for it in items ] - return ( - pd.DataFrame(rows) - .set_index("service_type_id") - ) + return pd.DataFrame(rows).set_index("service_type_id") async def get_social_values(self, **kwargs): - res = await self.json_handler.get('/api/v1/social_values', params=kwargs) - return pd.DataFrame(res).set_index('soc_value_id') + res = await self.json_handler.get("/api/v1/social_values", params=kwargs) + return pd.DataFrame(res).set_index("soc_value_id") async def get_social_value_service_types(self, soc_value_id: int, **kwargs): - data = await self.json_handler.get(f'/api/v1/social_values/{soc_value_id}/service_types', params=kwargs) + data = await self.json_handler.get( + f"/api/v1/social_values/{soc_value_id}/service_types", params=kwargs + ) if not data: return None if isinstance(data, list): items = data elif isinstance(data, dict): - items = data.get('service_types') or data.get('data') or [] + items = data.get("service_types") or data.get("data") or [] else: items = [] rows = [] for it in items: - rows.append({ - 'soc_value_id': it.get('soc_value_id'), - }) - df = pd.DataFrame(rows).set_index('soc_value_id') + rows.append( + { + "soc_value_id": it.get("soc_value_id"), + } + ) + df = pd.DataFrame(rows).set_index("soc_value_id") return df async def get_service_type_social_values(self, service_type_id: int, **kwargs): @@ -362,14 +342,13 @@ async def get_service_type_social_values(self, service_type_id: int, **kwargs): return df async def get_indicators(self, parent_id: int | None = None, **kwargs): - res = await self.json_handler.get('/api/v1/indicators_by_parent', params={ - 'parent_id': parent_id, - **kwargs - }) - return pd.DataFrame(res).set_index('indicator_id') + res = await self.json_handler.get( + "/api/v1/indicators_by_parent", params={"parent_id": parent_id, **kwargs} + ) + return pd.DataFrame(res).set_index("indicator_id") async def get_territory_geometry(self, territory_id: int): - res = await self.json_handler.get(f'/api/v1/territory/{territory_id}') + res = await self.json_handler.get(f"/api/v1/territory/{territory_id}") geom = res["geometry"] if isinstance(geom, dict): geom = json.dumps(geom) diff --git a/app/system_router/system_controller.py b/app/system_router/system_controller.py index 6e1bd8d..0177892 100644 --- a/app/system_router/system_controller.py +++ b/app/system_router/system_controller.py @@ -8,7 +8,7 @@ system_router = APIRouter(prefix="/system", tags=["System"]) -#TODO use structlog instead of loguru +# TODO use structlog instead of loguru @system_router.get("/logs") async def get_logs(): """ From c7fb50947998c634d1af2e193ee0227471dd1025 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 30 Jun 2025 19:28:33 +0300 Subject: [PATCH 031/161] feat(constants): - added all ids required by UrbanAPI to const.py --- app/effects_api/constants/const.py | 4 ++++ app/effects_api/modules/context_service.py | 7 ++++--- app/effects_api/modules/scenario_service.py | 7 ++++--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index ce85cac..cde75e1 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -109,3 +109,7 @@ ], "population": [["properties", "population_balanced"]], } + +LIVING_BUILDINGS_ID = 4 +ROADS_ID = 26 +WATER_ID = 4 \ No newline at end of file diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index fc947fa..159150d 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -4,6 +4,7 @@ from blocksnet.preprocessing.imputing import impute_buildings, impute_services from app.dependencies import urban_api_gateway +from app.effects_api.constants.const import ROADS_ID, LIVING_BUILDINGS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import \ adapt_functional_zones @@ -30,14 +31,14 @@ async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: async def _get_context_roads(project_id: int): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=26 + project_id, physical_object_function_id=ROADS_ID ) return gdf[["geometry"]].reset_index(drop=True) async def _get_context_water(project_id: int): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=4 + project_id, physical_object_function_id=WATER_ID ) return gdf[["geometry"]].reset_index(drop=True) @@ -90,7 +91,7 @@ async def get_context_functional_zones( async def get_context_buildings(project_id: int): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_type_id=4, centers_only=True + project_id, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True ) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 884f694..21b8826 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -10,6 +10,7 @@ from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway +from app.effects_api.constants.const import ROADS_ID, WATER_ID, LIVING_BUILDINGS_ID from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import \ adapt_functional_zones @@ -57,7 +58,7 @@ async def _get_project_boundaries(project_id: int): async def _get_scenario_roads(scenario_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_function_id=26 + scenario_id, token, physical_object_function_id=ROADS_ID ) if gdf is None: return None @@ -66,7 +67,7 @@ async def _get_scenario_roads(scenario_id: int, token: str): async def _get_scenario_water(scenario_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_function_id=4 + scenario_id, token, physical_object_function_id=WATER_ID ) if gdf is None: return None @@ -183,7 +184,7 @@ async def get_scenario_functional_zones( async def get_scenario_buildings(scenario_id: int, token: str): try: gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_type_id=4, centers_only=True + scenario_id, token, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True ) if gdf is None: return None From 157298c5c3f1ce0b3b400d7c5fe0510f1a7c517c Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 20:00:31 +0300 Subject: [PATCH 032/161] refactor(dockerfile): - added optional env APP_WORKERS to docker env - added TODO for app version --- Dockerfile | 3 ++- app/main.py | 1 + docker-compose.yml | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d498f45..b1f9107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ ENV PYTHONUNBUFFERED=1 # Enables env file ENV APP_ENV=production +ENV APP_WORKERS=1 #add pypi mirror to config COPY pip.conf /etc/xdg/pip/pip.conf @@ -26,4 +27,4 @@ WORKDIR /app COPY . /app # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug -CMD ["gunicorn", "--bind", "0.0.0.0:80", "-k", "uvicorn.workers.UvicornWorker", "--workers", "1", "app.main:app"] +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --workers $APP_WORKERS app.main:app"] \ No newline at end of file diff --git a/app/main.py b/app/main.py index f354554..3335b0f 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from app.effects_api.effects_controller import development_router from app.system_router.system_controller import system_router +#TODO add app version app = FastAPI( title="Effects API", description="API for calculating effects of territory transformation with BlocksNet library", diff --git a/docker-compose.yml b/docker-compose.yml index bfeefdb..ca5f3af 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ version: '3.4' services: EffectsAPI: - image: effectsapi:dev + image: effectsapi build: context: . dockerfile: ./Dockerfile From 7ea0c5474e62d2c3835e11427c946eafaaf93e92 Mon Sep 17 00:00:00 2001 From: Leon Date: Mon, 30 Jun 2025 20:01:43 +0300 Subject: [PATCH 033/161] style: - ran pre-commit with isort and black --- app/effects_api/constants/const.py | 2 +- app/effects_api/modules/context_service.py | 3 ++- app/effects_api/modules/scenario_service.py | 8 ++++++-- app/main.py | 2 +- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index cde75e1..cee5cf0 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -112,4 +112,4 @@ LIVING_BUILDINGS_ID = 4 ROADS_ID = 26 -WATER_ID = 4 \ No newline at end of file +WATER_ID = 4 diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 159150d..956f879 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -4,7 +4,8 @@ from blocksnet.preprocessing.imputing import impute_buildings, impute_services from app.dependencies import urban_api_gateway -from app.effects_api.constants.const import ROADS_ID, LIVING_BUILDINGS_ID, WATER_ID +from app.effects_api.constants.const import (LIVING_BUILDINGS_ID, ROADS_ID, + WATER_ID) from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import \ adapt_functional_zones diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 21b8826..564ac80 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -10,7 +10,8 @@ from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway -from app.effects_api.constants.const import ROADS_ID, WATER_ID, LIVING_BUILDINGS_ID +from app.effects_api.constants.const import (LIVING_BUILDINGS_ID, ROADS_ID, + WATER_ID) from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import \ adapt_functional_zones @@ -184,7 +185,10 @@ async def get_scenario_functional_zones( async def get_scenario_buildings(scenario_id: int, token: str): try: gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True + scenario_id, + token, + physical_object_type_id=LIVING_BUILDINGS_ID, + centers_only=True, ) if gdf is None: return None diff --git a/app/main.py b/app/main.py index 3335b0f..8c7ca74 100644 --- a/app/main.py +++ b/app/main.py @@ -8,7 +8,7 @@ from app.effects_api.effects_controller import development_router from app.system_router.system_controller import system_router -#TODO add app version +# TODO add app version app = FastAPI( title="Effects API", description="API for calculating effects of territory transformation with BlocksNet library", From 1a11fabcfab2ae22e3a513e5aa80d196418c3f0f Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 1 Jul 2025 10:29:49 +0300 Subject: [PATCH 034/161] fix(dependencies): - logs fixed --- app/dependencies.py | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index 6679a49..71ea9be 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -6,22 +6,13 @@ from app.gateways.urban_api_gateway import UrbanAPIGateway +absolute_app_path = Path().absolute() +config = Config() + logger.remove() log_level = "INFO" log_format = "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {message}" logger.add(sys.stderr, format=log_format, level=log_level, colorize=True) -logger.add( - ".log", - level=log_level, - format=log_format, - colorize=False, - backtrace=True, - diagnose=True, -) - -absolute_app_path = Path().absolute() -config = Config() - logger.add( absolute_app_path / f"{config.get('LOG_NAME')}", format=log_format, From 0fe5009e3b773e26da3ee001eb4e630c77de024b Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 1 Jul 2025 18:28:17 +0300 Subject: [PATCH 035/161] feat(maps): - added names maps --- .../output_maps/pred_columns_names_map.json | 6 ++++ .../soc_economy_pred_name_map.json | 30 +++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 app/effects_api/schemas/output_maps/pred_columns_names_map.json create mode 100644 app/effects_api/schemas/output_maps/soc_economy_pred_name_map.json diff --git a/app/effects_api/schemas/output_maps/pred_columns_names_map.json b/app/effects_api/schemas/output_maps/pred_columns_names_map.json new file mode 100644 index 0000000..df7059a --- /dev/null +++ b/app/effects_api/schemas/output_maps/pred_columns_names_map.json @@ -0,0 +1,6 @@ +{ + "pred": "Предсказанное количество сервисов", + "lower": "Нижняя граница доверительного интервала", + "upper": "Верхняя граница доверительного интервала", + "is_interval": "Попадание предсказания в доверительный интервал" +} \ No newline at end of file diff --git a/app/effects_api/schemas/output_maps/soc_economy_pred_name_map.json b/app/effects_api/schemas/output_maps/soc_economy_pred_name_map.json new file mode 100644 index 0000000..6485df2 --- /dev/null +++ b/app/effects_api/schemas/output_maps/soc_economy_pred_name_map.json @@ -0,0 +1,30 @@ +{ + "nursing_home_count": "Количество домов престарелых", + "hotel_count": "Количество гостиниц", + "theatre_count": "Количество театров", + "cinema_count": "Количество кинотеатров", + "secondary_vocational_education_institutions_count": "Количество образовательных учреждений СПО", + "university_count": "Количество высших учебных заведений", + "stadium_count": "Количество стадионов", + "emergency_medical_service_stations_count": "Количество станций скорой медицинской помощи", + "kindergarten_count": "Количество дошкольных образовательных учреждений", + "hostel_count": "Количество хостелов", + "park_count": "Количество парков", + "multifunctional_center_count": "Количество центров предоставления государственных и муниципальных услуг", + "pharmacy_count": "Количество аптек", + "sports_halls_count": "Количество спортивных залов", + "hospital_count": "Количество больничных учреждений (стационаров)", + "school_count": "Количество общеобразовательных учреждений", + "mall_count": "Количество торгово-развлекательных центров", + "polyclinic_count": "Количество амбулаторно-поликлинических учреждений", + "post_count": "Количество почтовых отделений", + "swimming_pool_count": "Количество плавательных бассейнов", + "library_count": "Количество библиотек", + "guest_house_count": "Количество туристических баз", + "fire_safety_facilities_count": "Количество объектов обеспечения пожарной безопасности", + "restaurant_count": "Количество объектов общественного питания", + "police_count": "Количество полицейских участков", + "museum_count": "Количество музеев", + "bank_count": "Количество отделений банков", + "pitch_count": "Количество плоскостных спортивных сооружений" +} From 22a2c6280694fe8d9591f886060664de6611004e Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 1 Jul 2025 18:36:16 +0300 Subject: [PATCH 036/161] refactor(effects+controller): - added optional list of context territories in response - added function for prediction from bn - added attribute name mapping in output schema for socio_economic_prediction endpoint --- .pre-commit-config.yaml | 4 +- app/effects_api/dto/development_dto.py | 9 ++ app/effects_api/effects_controller.py | 13 +- app/effects_api/effects_service.py | 127 +++++++++++++----- .../schemas/output_maps/__init__.py | 13 ++ .../schemas/socio_economic_response_schema.py | 68 +++++++++- 6 files changed, 191 insertions(+), 43 deletions(-) create mode 100644 app/effects_api/schemas/output_maps/__init__.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 03a34e6..9703de4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,4 +8,6 @@ repos: - repo: https://github.com/pycqa/isort rev: 6.0.1 hooks: - - id: isort \ No newline at end of file + - id: isort + name: isort (python) + args: ["--profile", "black"] \ No newline at end of file diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index 4ede62d..77af766 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -41,3 +41,12 @@ class ContextDevelopmentDTO(DevelopmentDTO): examples=[2023, 2024], description="Year of the chosen context functional-zone source.", ) + + +class SocioEconomicPredictionDTO(ContextDevelopmentDTO): + + split: bool = Field( + default=False, + examples=[False, True], + description="If split will return additional evaluation for each context mo", + ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index d6e5bb6..15db0ef 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -4,10 +4,15 @@ from fastapi.params import Depends from app.common.auth.auth import verify_token -from app.common.exceptions.controller_exception_handler import \ - handle_controller_exception +from app.common.exceptions.controller_exception_handler import ( + handle_controller_exception, +) -from .dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO +from .dto.development_dto import ( + ContextDevelopmentDTO, + DevelopmentDTO, + SocioEconomicPredictionDTO, +) from .effects_service import effects_service from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema @@ -45,7 +50,7 @@ async def get_context_development( "/socio_economic_prediction", response_model=SocioEconomicResponseSchema ) async def get_socio_economic_prediction( - params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], + params: Annotated[SocioEconomicPredictionDTO, Depends(SocioEconomicPredictionDTO)], token: str = Depends(verify_token), ) -> SocioEconomicResponseSchema: diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 63c9cc5..6ecc378 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -4,33 +4,50 @@ from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import (DensityRegressor, - SocialRegressor) +from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor from blocksnet.relations import generate_adjacency_graph from loguru import logger from app.dependencies import urban_api_gateway from app.effects_api.modules.scenario_service import ( - get_scenario_blocks, get_scenario_buildings, get_scenario_functional_zones, - get_scenario_services) + get_scenario_blocks, + get_scenario_buildings, + get_scenario_functional_zones, + get_scenario_services, +) from app.effects_api.modules.service_type_service import adapt_service_types from .constants.const import LAND_USE_RULES -from .dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO -from .modules.context_service import (get_context_blocks, - get_context_buildings, - get_context_functional_zones, - get_context_services) +from .dto.development_dto import ( + ContextDevelopmentDTO, + DevelopmentDTO, + SocioEconomicPredictionDTO, +) +from .modules.context_service import ( + get_context_blocks, + get_context_buildings, + get_context_functional_zones, + get_context_services, +) from .schemas.development_response_schema import DevelopmentResponseSchema -from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema +from .schemas.socio_economic_response_schema import ( + SocioEconomicResponseSchema, + SocioEconomicSchema, +) # TODO add caching service class EffectsService: + def __init__( + self, + ): + self.bn_social_regressor: SocialRegressor = SocialRegressor() + @staticmethod async def get_optimal_func_zone_data( - params: DevelopmentDTO | ContextDevelopmentDTO, token: str + params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO, + token: str, ) -> DevelopmentDTO: """ Get optimal functional zone source and year for the project scenario. @@ -375,9 +392,40 @@ async def run_development_parameters( return development_df + async def run_social_reg_prediction( + self, + blocks: gpd.GeoDataFrame, + data_input: pd.DataFrame, + ): + """ + Function runs social regression from blocksnet + Args: + blocks (gpd.GeoDataFrame): Block layer already containing per-land-use **shares** + data_input (pd.DataFrame): Data to run regression on + Returns: + SocioEconomicSchema: SocioEconomicSchema from schemas to return result generation + """ + + data_input["latitude"] = blocks.geometry.union_all().centroid.x + data_input["longitude"] = blocks.geometry.union_all().centroid.y + data_input["buildings_count"] = data_input["count_buildings"] + y_pred, pi_lower, pi_upper = self.bn_social_regressor.evaluate(data_input) + iloc = 0 + result_data = { + "pred": y_pred.apply(round).astype(int).iloc[iloc].to_dict(), + "lower": pi_lower.iloc[iloc].to_dict(), + "upper": pi_upper.iloc[iloc].to_dict(), + } + result_df = pd.DataFrame.from_dict(result_data) + result_df["is_interval"] = (result_df["pred"] <= result_df["upper"]) & ( + result_df["pred"] >= result_df["lower"] + ) + res = result_df.to_dict(orient="index") + return SocioEconomicSchema(**{"socio_economic_prediction": res}) + async def evaluate_master_plan( - self, params: ContextDevelopmentDTO, token: str = None - ) -> SocioEconomicResponseSchema: + self, params: SocioEconomicPredictionDTO, token: str = None + ) -> SocioEconomicSchema: """ End-to-end pipeline that fuses *project* and *context* blocks, enriches them with development parameters and produces socio-economic forecasts @@ -409,6 +457,8 @@ async def evaluate_master_plan( logger.info("Evaluating master plan effects") params = await self.get_optimal_func_zone_data(params, token) + project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) + project_info = await urban_api_gateway.get_all_project_info(project_id, token) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( params.scenario_id, params.context_func_zone_source, @@ -454,27 +504,38 @@ async def evaluate_master_plan( blocks[cols] = development_df[cols].values for lu in LandUse: blocks[lu.value] = blocks[lu.value] * blocks["site_area"] - data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] - input = pd.DataFrame(data) - - input["latitude"] = blocks.geometry.union_all().centroid.x - input["longitude"] = blocks.geometry.union_all().centroid.y - input["buildings_count"] = input["count_buildings"] - sr = SocialRegressor() - y_pred, pi_lower, pi_upper = sr.evaluate(input) - iloc = 0 - result_data = { - "pred": y_pred.apply(round).astype(int).iloc[iloc].to_dict(), - "lower": pi_lower.iloc[iloc].to_dict(), - "upper": pi_upper.iloc[iloc].to_dict(), - } - result_df = pd.DataFrame.from_dict(result_data) - result_df["is_interval"] = (result_df["pred"] <= result_df["upper"]) & ( - result_df["pred"] >= result_df["lower"] + main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] + main_input = pd.DataFrame(main_data) + main_res = await self.run_social_reg_prediction(blocks, main_input) + context_results = {} + if params.split: + for context_ter_id in project_info["properties"]["context"]: + territory = gpd.GeoDataFrame( + geometry=[ + await urban_api_gateway.get_territory_geometry(context_ter_id) + ], + crs=4326, + ) + ter_blocks = ( + blocks.sjoin( + territory.to_crs(territory.estimate_utm_crs()), how="left" + ) + .dropna(subset="index_right") + .drop(columns="index_right") + ) + ter_data = [ + ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict() + ] + ter_input = pd.DataFrame(ter_data) + context_results[context_ter_id] = await self.run_social_reg_prediction( + ter_blocks, ter_input + ) + + return SocioEconomicResponseSchema( + socio_economic_prediction=main_res.socio_economic_prediction, + split_prediction=context_results if context_results else None, + params_data=params, ) - res = result_df.to_dict(orient="index") - res = {"socio_economic_prediction": res, "params_data": params.as_dict()} - return SocioEconomicResponseSchema(**res) async def calc_project_development( self, token: str, params: DevelopmentDTO diff --git a/app/effects_api/schemas/output_maps/__init__.py b/app/effects_api/schemas/output_maps/__init__.py new file mode 100644 index 0000000..608e1a2 --- /dev/null +++ b/app/effects_api/schemas/output_maps/__init__.py @@ -0,0 +1,13 @@ +import json +from pathlib import Path + +abs_path = Path(__file__).parent + + +with open( + abs_path / "soc_economy_pred_name_map.json", "r", encoding="utf-8" +) as sepnm_file: + soc_economy_pred_name_map = json.load(sepnm_file) + +with open(abs_path / "pred_columns_names_map.json", "r", encoding="utf-8") as pcnp_file: + pred_columns_names_map = json.load(pcnp_file) diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index c0e25d9..a2b38eb 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -1,11 +1,14 @@ -from pydantic import BaseModel, field_validator +from typing import Optional -from app.effects_api.dto.development_dto import (ContextDevelopmentDTO, - DevelopmentDTO) +from pydantic import BaseModel, Field, field_validator, model_serializer +from app.common.exceptions.http_exception_wrapper import http_exception +from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO + +from .output_maps import pred_columns_names_map, soc_economy_pred_name_map -class SocioEconomicParams(BaseModel): +class SocioEconomicParams(BaseModel): pred: int lower: int upper: float @@ -16,8 +19,63 @@ class SocioEconomicParams(BaseModel): def validate_upper(cls, v: float) -> float: return round(v, 2) + @model_serializer + def serialize_model(self): + return { + pred_columns_names_map[str(field)]: getattr(self, field) + for field in self.model_fields + } -class SocioEconomicResponseSchema(BaseModel): +class SocioEconomicSchema(BaseModel): socio_economic_prediction: dict[str, SocioEconomicParams] + + @field_validator("socio_economic_prediction", mode="before") + @classmethod + def rename_attributes(cls, value: dict[str, SocioEconomicParams]): + + try: + return { + ( + k + if k not in pred_columns_names_map.keys() + else soc_economy_pred_name_map[k] + ): v + for k, v in value.items() + } + except KeyError as key_e: + raise http_exception( + 500, + "Could not rename socio economic prediction attributes", + _input=list(value.keys()), + _detail={"error": repr(key_e)}, + ) from key_e + except Exception as e: + raise http_exception( + 500, + "Error during output validation", + ) + + +class SocioEconomicResponseSchema(SocioEconomicSchema): + """ + DTO Class for socio-economic response + Attributes: + socio_economic_prediction (dict[str, SocioEconomicParams]): where SocioEconomicParams is class containing fields + (pred: int, lower: int, upper: float, is_interval: bool) + split_prediction (Optional[list[SocioEconomicSchema]]): optional list of context predictions as list + of SocioEconomicSchema + params_data (DevelopmentDTO | ContextDevelopmentDTO): + """ + + split_prediction: Optional[dict[int, SocioEconomicSchema]] params_data: DevelopmentDTO | ContextDevelopmentDTO + + def __post_init__(self): + for k, v in self.split_prediction.items(): + for k1, v1 in v.items(): + for k2 in v1.__dict__.keys(): + setattr( + v1, + k2, + ) From 165d88f2840b6441a973792853353e2df50604d6 Mon Sep 17 00:00:00 2001 From: Leon Date: Tue, 1 Jul 2025 20:10:31 +0300 Subject: [PATCH 037/161] refactor(exception_handler): - rewrote exception handling to decorator - pr comments resolved --- app/common/api_handlers/json_api_handler.py | 6 +++ .../controller_exception_handler.py | 34 ---------------- app/common/exceptions/exception_handler.py | 39 +++++++++++++++++++ app/effects_api/dto/development_dto.py | 3 -- app/effects_api/effects_controller.py | 22 ++++------- app/effects_api/effects_service.py | 12 ++++-- .../schemas/socio_economic_response_schema.py | 23 ++++------- app/gateways/urban_api_gateway.py | 6 +++ 8 files changed, 75 insertions(+), 70 deletions(-) delete mode 100644 app/common/exceptions/controller_exception_handler.py create mode 100644 app/common/exceptions/exception_handler.py diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index f034dd4..70ce284 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -17,8 +17,14 @@ def __init__( None """ + self.__name__ = "UrbanAPIGateway" self.base_url = base_url + def model_dump( + self, + ): + return {k: str(v) for k, v in self.__dict__.items()} + @staticmethod async def _check_response_status( response: aiohttp.ClientResponse, diff --git a/app/common/exceptions/controller_exception_handler.py b/app/common/exceptions/controller_exception_handler.py deleted file mode 100644 index 05246a0..0000000 --- a/app/common/exceptions/controller_exception_handler.py +++ /dev/null @@ -1,34 +0,0 @@ -from fastapi import HTTPException -from fastapi.encoders import jsonable_encoder -from loguru import logger - -from .http_exception_wrapper import http_exception - -base_types = (int, float, str, bool, None) - - -async def handle_controller_exception(func, is_async: bool = True, **kwargs): - try: - if is_async: - return await func(**kwargs) - else: - return func(**kwargs) - except HTTPException: - raise - except Exception as e: - logger.exception(e) - raise http_exception( - 500, - msg="Internal server error", - _input={ - "func": str(func), - "is_async": is_async, - "kwargs": jsonable_encoder( - { - i: (v if type(v) in base_types else v.as_dict()) - for i, v in kwargs.items() - } - ), - }, - _detail={"error": repr(e)}, - ) from e diff --git a/app/common/exceptions/exception_handler.py b/app/common/exceptions/exception_handler.py new file mode 100644 index 0000000..a2de711 --- /dev/null +++ b/app/common/exceptions/exception_handler.py @@ -0,0 +1,39 @@ +import functools + +from fastapi import HTTPException +from fastapi.encoders import jsonable_encoder +from loguru import logger + +from .http_exception_wrapper import http_exception + +base_types = (int, float, str, bool, list, tuple, set, dict, None) + + +def async_ultimate_exception_decorator(func): + @functools.wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except HTTPException: + raise + except Exception as e: + logger.exception(e) + raise http_exception( + 500, + msg="Internal server error", + _input={ + "func": func.__name__, + "args": jsonable_encoder( + [i if type(i) in base_types else i.model_dump() for i in args] + ), + "kwargs": jsonable_encoder( + { + i: (v if type(v) in base_types else v.model_dump()) + for i, v in kwargs.items() + } + ), + }, + _detail={"error": repr(e)}, + ) from e + + return wrapper diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index 77af766..89b41f2 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -23,9 +23,6 @@ class DevelopmentDTO(BaseModel): description="Year of the chosen project functional-zone source.", ) - def as_dict(self): - return self.model_dump(by_alias=False) - class ContextDevelopmentDTO(DevelopmentDTO): context_func_zone_source: Optional[Literal["PZZ", "OSM", "User"]] = Field( diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 15db0ef..009bc80 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -4,10 +4,8 @@ from fastapi.params import Depends from app.common.auth.auth import verify_token -from app.common.exceptions.controller_exception_handler import ( - handle_controller_exception, -) +from ..common.exceptions.exception_handler import async_ultimate_exception_decorator from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, @@ -23,37 +21,31 @@ @development_router.get( "/project_development", response_model=DevelopmentResponseSchema ) +@async_ultimate_exception_decorator async def get_project_development( params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], token: str = Depends(verify_token), ) -> DevelopmentResponseSchema: - - return await handle_controller_exception( - effects_service.calc_project_development, token=token, params=params - ) + return await effects_service.calc_project_development(token, params) @development_router.get( "/context_development", response_model=DevelopmentResponseSchema ) +@async_ultimate_exception_decorator async def get_context_development( params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), ) -> DevelopmentResponseSchema: - - return await handle_controller_exception( - effects_service.calc_context_development, token=token, params=params - ) + return await effects_service.calc_context_development(token, params) @development_router.get( "/socio_economic_prediction", response_model=SocioEconomicResponseSchema ) +@async_ultimate_exception_decorator async def get_socio_economic_prediction( params: Annotated[SocioEconomicPredictionDTO, Depends(SocioEconomicPredictionDTO)], token: str = Depends(verify_token), ) -> SocioEconomicResponseSchema: - - return await handle_controller_exception( - effects_service.evaluate_master_plan, token=token, params=params - ) + return await effects_service.evaluate_master_plan(params, token) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 6ecc378..f954fc4 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -42,8 +42,14 @@ class EffectsService: def __init__( self, ): + self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() + def model_dump( + self, + ): + return {k: str(v) for k, v in self.__dict__.items()} + @staticmethod async def get_optimal_func_zone_data( params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO, @@ -421,7 +427,7 @@ async def run_social_reg_prediction( result_df["pred"] >= result_df["lower"] ) res = result_df.to_dict(orient="index") - return SocioEconomicSchema(**{"socio_economic_prediction": res}) + return SocioEconomicSchema(socio_economic_prediction=res) async def evaluate_master_plan( self, params: SocioEconomicPredictionDTO, token: str = None @@ -558,7 +564,7 @@ async def calc_project_development( ) res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") - res.update({"params_data": params.as_dict()}) + res.update({"params_data": params.model_dump()}) return DevelopmentResponseSchema(**res) async def calc_context_development( @@ -591,7 +597,7 @@ async def calc_context_development( blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") - res.update({"params_data": params.as_dict()}) + res.update({"params_data": params.model_dump()}) return DevelopmentResponseSchema(**res) diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index a2b38eb..432902e 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -9,10 +9,10 @@ class SocioEconomicParams(BaseModel): - pred: int - lower: int - upper: float - is_interval: bool + pred: int = Field(..., description="Prediction column name") + lower: int = Field(..., description="Lower prediction column name") + upper: float = Field(..., description="Upper prediction column name") + is_interval: bool = Field(..., description="Is interval prediction column name") @field_validator("upper", mode="after") @classmethod @@ -30,7 +30,7 @@ def serialize_model(self): class SocioEconomicSchema(BaseModel): socio_economic_prediction: dict[str, SocioEconomicParams] - @field_validator("socio_economic_prediction", mode="before") + @field_validator("socio_economic_prediction", mode="after") @classmethod def rename_attributes(cls, value: dict[str, SocioEconomicParams]): @@ -54,7 +54,9 @@ def rename_attributes(cls, value: dict[str, SocioEconomicParams]): raise http_exception( 500, "Error during output validation", - ) + _input=list(value.keys()), + _detail={"error": repr(e)}, + ) from e class SocioEconomicResponseSchema(SocioEconomicSchema): @@ -70,12 +72,3 @@ class SocioEconomicResponseSchema(SocioEconomicSchema): split_prediction: Optional[dict[int, SocioEconomicSchema]] params_data: DevelopmentDTO | ContextDevelopmentDTO - - def __post_init__(self): - for k, v in self.split_prediction.items(): - for k1, v1 in v.items(): - for k2 in v1.__dict__.keys(): - setattr( - v1, - k2, - ) diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 6d576b6..fa5488e 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -14,6 +14,12 @@ class UrbanAPIGateway: def __init__(self, base_url: str) -> None: self.json_handler = JSONAPIHandler(base_url) + self.__name__ = "UrbanAPIGateway" + + def model_dump( + self, + ): + return {k: str(v) for k, v in self.__dict__.items()} # TODO context async def get_physical_objects( From a1dd63c770f86b23a84314cd9efad3f30cd21a4d Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 00:14:17 +0300 Subject: [PATCH 038/161] feat(exception_handler): - added middleware for exceptions --- app/common/exceptions/exception_handler.py | 94 +++++++++++++++------- app/effects_api/effects_controller.py | 4 - app/main.py | 2 + 3 files changed, 67 insertions(+), 33 deletions(-) diff --git a/app/common/exceptions/exception_handler.py b/app/common/exceptions/exception_handler.py index a2de711..6504600 100644 --- a/app/common/exceptions/exception_handler.py +++ b/app/common/exceptions/exception_handler.py @@ -1,39 +1,75 @@ -import functools +"""Exception handling middleware is defined here.""" -from fastapi import HTTPException -from fastapi.encoders import jsonable_encoder +import itertools +import json +import traceback + +from fastapi import FastAPI, HTTPException, Request from loguru import logger +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse from .http_exception_wrapper import http_exception -base_types = (int, float, str, bool, list, tuple, set, dict, None) +class ExceptionHandlerMiddleware( + BaseHTTPMiddleware +): # pylint: disable=too-few-public-methods + """Handle exceptions, so they become http response code 500 - Internal Server Error. + + If debug is activated in app configuration, then stack trace is returned, otherwise only a generic error message. + Message is sent to logger error stream anyway. + """ -def async_ultimate_exception_decorator(func): - @functools.wraps(func) - async def wrapper(*args, **kwargs): + def __init__(self, app: FastAPI): + """Passing debug as a list with single element is a hack to be able to change the value + on the application startup. + """ + super().__init__(app) + + async def dispatch(self, request: Request, call_next): try: - return await func(*args, **kwargs) - except HTTPException: - raise + return await call_next(request) except Exception as e: - logger.exception(e) - raise http_exception( - 500, - msg="Internal server error", - _input={ - "func": func.__name__, - "args": jsonable_encoder( - [i if type(i) in base_types else i.model_dump() for i in args] - ), - "kwargs": jsonable_encoder( - { - i: (v if type(v) in base_types else v.model_dump()) - for i, v in kwargs.items() - } - ), + request_info = { + "method": request.method, + "url": str(request.url), + "path_params": dict(request.path_params), + "query_params": dict(request.query_params), + "headers": dict(request.headers), + } + try: + request_info["body"] = await request.json() + except: + try: + request_info["body"] = str(await request.body()) + except: + request_info["body"] = "Could not read request body" + if isinstance(e, HTTPException): + return JSONResponse( + status_code=e.status_code, + content={ + "message": ( + e.detail.get("msg") + if isinstance(e.detail, dict) + else str(e.detail) + ), + "error_type": e.__class__.__name__, + "request": request_info, + "detail": ( + e.detail.get("detail") + if isinstance(e.detail, dict) + else None + ), + }, + ) + return JSONResponse( + status_code=500, + content={ + "message": "Internal server error", + "error_type": e.__class__.__name__, + "request": request_info, + "detail": str(e), + "traceback": traceback.format_exc().splitlines(), }, - _detail={"error": repr(e)}, - ) from e - - return wrapper + ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 009bc80..3f2b4a7 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -5,7 +5,6 @@ from app.common.auth.auth import verify_token -from ..common.exceptions.exception_handler import async_ultimate_exception_decorator from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, @@ -21,7 +20,6 @@ @development_router.get( "/project_development", response_model=DevelopmentResponseSchema ) -@async_ultimate_exception_decorator async def get_project_development( params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], token: str = Depends(verify_token), @@ -32,7 +30,6 @@ async def get_project_development( @development_router.get( "/context_development", response_model=DevelopmentResponseSchema ) -@async_ultimate_exception_decorator async def get_context_development( params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), @@ -43,7 +40,6 @@ async def get_context_development( @development_router.get( "/socio_economic_prediction", response_model=SocioEconomicResponseSchema ) -@async_ultimate_exception_decorator async def get_socio_economic_prediction( params: Annotated[SocioEconomicPredictionDTO, Depends(SocioEconomicPredictionDTO)], token: str = Depends(verify_token), diff --git a/app/main.py b/app/main.py index 8c7ca74..3c23e6f 100644 --- a/app/main.py +++ b/app/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse +from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.effects_controller import development_router from app.system_router.system_controller import system_router @@ -25,6 +26,7 @@ allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) +app.add_middleware(ExceptionHandlerMiddleware) @app.get("/", include_in_schema=False) From 6b6abd788f6a3edb445a00ff7c1edaa7656376e6 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 00:15:12 +0300 Subject: [PATCH 039/161] style: - ran black and isort for all files --- app/effects_api/modules/context_service.py | 10 +++++----- app/effects_api/modules/scenario_service.py | 6 ++---- app/effects_api/schemas/development_response_schema.py | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 956f879..c3f1501 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -4,13 +4,13 @@ from blocksnet.preprocessing.imputing import impute_buildings, impute_services from app.dependencies import urban_api_gateway -from app.effects_api.constants.const import (LIVING_BUILDINGS_ID, ROADS_ID, - WATER_ID) +from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import \ - adapt_functional_zones +from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.scenario_service import ( - _get_best_functional_zones_source, close_gaps) + _get_best_functional_zones_source, + close_gaps, +) from app.effects_api.modules.services_service import adapt_services diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 564ac80..d7bb65b 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -10,11 +10,9 @@ from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import urban_api_gateway -from app.effects_api.constants.const import (LIVING_BUILDINGS_ID, ROADS_ID, - WATER_ID) +from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings -from app.effects_api.modules.functional_sources_service import \ - adapt_functional_zones +from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.services_service import adapt_services SOURCES_PRIORITY = ["PZZ", "OSM", "User"] diff --git a/app/effects_api/schemas/development_response_schema.py b/app/effects_api/schemas/development_response_schema.py index 3674c6a..00b6663 100644 --- a/app/effects_api/schemas/development_response_schema.py +++ b/app/effects_api/schemas/development_response_schema.py @@ -1,8 +1,7 @@ from pydantic import BaseModel, Field, model_validator from typing_extensions import Self -from app.effects_api.dto.development_dto import (ContextDevelopmentDTO, - DevelopmentDTO) +from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO class DevelopmentResponseSchema(BaseModel): From f055c3570c6fc7d0000fc2e128df122bdb78eaaf Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 00:19:05 +0300 Subject: [PATCH 040/161] chore(context): - removed commented code --- app/effects_api/modules/context_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index c3f1501..1c6669b 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -80,7 +80,6 @@ async def get_context_functional_zones( ) -> gpd.GeoDataFrame: sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) year, source = await _get_best_functional_zones_source(sources_df, source, year) - # year, source = await urban_api_gateway.get_optimal_func_zone_request_data(token, scenario_id, year, source) functional_zones = await urban_api_gateway.get_functional_zones( project_id, year, source ) From 5ed40e01f2c1fbe8b263f35011afaa2ec58d9e71 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 00:43:59 +0300 Subject: [PATCH 041/161] refactor(effects_service): - removed model_dump custom methods --- app/common/api_handlers/json_api_handler.py | 5 ----- app/effects_api/effects_service.py | 5 ----- app/gateways/urban_api_gateway.py | 5 ----- 3 files changed, 15 deletions(-) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index 70ce284..db91c37 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -20,11 +20,6 @@ def __init__( self.__name__ = "UrbanAPIGateway" self.base_url = base_url - def model_dump( - self, - ): - return {k: str(v) for k, v in self.__dict__.items()} - @staticmethod async def _check_response_status( response: aiohttp.ClientResponse, diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index f954fc4..6b11d72 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -45,11 +45,6 @@ def __init__( self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() - def model_dump( - self, - ): - return {k: str(v) for k, v in self.__dict__.items()} - @staticmethod async def get_optimal_func_zone_data( params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO, diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index fa5488e..1148486 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -16,11 +16,6 @@ def __init__(self, base_url: str) -> None: self.json_handler = JSONAPIHandler(base_url) self.__name__ = "UrbanAPIGateway" - def model_dump( - self, - ): - return {k: str(v) for k, v in self.__dict__.items()} - # TODO context async def get_physical_objects( self, project_id: int, **kwargs: dict From bf9646677902418b3e0a60fd8690f3a11463fd20 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 13:33:55 +0300 Subject: [PATCH 042/161] refactor(exception_handler): - updated docstring --- app/common/exceptions/exception_handler.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/common/exceptions/exception_handler.py b/app/common/exceptions/exception_handler.py index 6504600..b42acfc 100644 --- a/app/common/exceptions/exception_handler.py +++ b/app/common/exceptions/exception_handler.py @@ -15,19 +15,29 @@ class ExceptionHandlerMiddleware( BaseHTTPMiddleware ): # pylint: disable=too-few-public-methods - """Handle exceptions, so they become http response code 500 - Internal Server Error. - - If debug is activated in app configuration, then stack trace is returned, otherwise only a generic error message. - Message is sent to logger error stream anyway. + """Handle exceptions, so they become http response code 500 - Internal Server Error if not handled as HTTPException + previously. + Attributes: + app (FastAPI): The FastAPI application instance. """ def __init__(self, app: FastAPI): - """Passing debug as a list with single element is a hack to be able to change the value - on the application startup. """ + Universal exception handler middleware init function. + Args: + app (FastAPI): The FastAPI application instance. + """ + super().__init__(app) async def dispatch(self, request: Request, call_next): + """ + Dispatch function for sending errors to user from API + Args: + request (Request): The incoming request object. + call_next: function to extract. + """ + try: return await call_next(request) except Exception as e: From fe747021849b89ccf8218f886817e51b1b88a9bf Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 15:48:18 +0300 Subject: [PATCH 043/161] fix(socio_economic_response_schema): - fixed keys renaming on validation --- app/effects_api/schemas/socio_economic_response_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index 432902e..e978e27 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -38,7 +38,7 @@ def rename_attributes(cls, value: dict[str, SocioEconomicParams]): return { ( k - if k not in pred_columns_names_map.keys() + if k not in soc_economy_pred_name_map.keys() else soc_economy_pred_name_map[k] ): v for k, v in value.items() From 3c1236f9388043bd24317832ef9c08f800c1fe75 Mon Sep 17 00:00:00 2001 From: Leon Date: Wed, 2 Jul 2025 20:33:00 +0300 Subject: [PATCH 044/161] fix(Dockerfile): - added worker timeout - added start-end logs to endpoints functions --- Dockerfile | 2 +- app/effects_api/effects_service.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b1f9107..3926b3e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ WORKDIR /app COPY . /app # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug -CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --workers $APP_WORKERS app.main:app"] \ No newline at end of file +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --timeout 1000 --workers $APP_WORKERS app.main:app"] \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 6b11d72..f5d426b 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -456,7 +456,7 @@ async def evaluate_master_plan( 4. Feed summarised indicators into SocialRegressor. """ - logger.info("Evaluating master plan effects") + logger.info(f"Evaluating master plan effects with {params.model_dump()}") params = await self.get_optimal_func_zone_data(params, token) project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) project_info = await urban_api_gateway.get_all_project_info(project_id, token) @@ -532,6 +532,9 @@ async def evaluate_master_plan( ter_blocks, ter_input ) + logger.info( + f"Finished evaluating master plan effects with {params.model_dump()}" + ) return SocioEconomicResponseSchema( socio_economic_prediction=main_res.socio_economic_prediction, split_prediction=context_results if context_results else None, @@ -550,6 +553,7 @@ async def calc_project_development( DevelopmentResponseSchema: Response schema with development indicators """ + logger.info(f"Calculating development for project {params.model_dump()}") params = await self.get_optimal_func_zone_data(params, token) blocks, buildings = await self.aggregate_blocks_layer_scenario( params.scenario_id, @@ -560,6 +564,9 @@ async def calc_project_development( res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") res.update({"params_data": params.model_dump()}) + logger.info( + f"Finished calculating development for project {params.model_dump()}" + ) return DevelopmentResponseSchema(**res) async def calc_context_development( @@ -574,6 +581,7 @@ async def calc_context_development( DevelopmentResponseSchema: Response schema with development indicators """ + logger.info(f"Calculating development for context {params.model_dump()}") params = await self.get_optimal_func_zone_data(params, token) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( params.scenario_id, @@ -593,6 +601,9 @@ async def calc_context_development( res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") res.update({"params_data": params.model_dump()}) + logger.info( + f"Finished calculating development for context {params.model_dump()}" + ) return DevelopmentResponseSchema(**res) From 464c45db861788fa97472be7b4e625cbcb619811 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Wed, 2 Jul 2025 23:11:33 +0300 Subject: [PATCH 045/161] refactor(tmp_commit): testing for F35 --- app/effects_api/effects_controller.py | 8 +++ app/effects_api/effects_service.py | 81 ++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 3f2b4a7..b2e9002 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -45,3 +45,11 @@ async def get_socio_economic_prediction( token: str = Depends(verify_token), ) -> SocioEconomicResponseSchema: return await effects_service.evaluate_master_plan(params, token) + + +@development_router.get("/F_35") +async def territory_transformation( + params: Annotated[SocioEconomicPredictionDTO, Depends(SocioEconomicPredictionDTO)], + token: str = Depends(verify_token), +): + return await effects_service.territory_transformation_scenario(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 6b11d72..c1df730 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,11 +1,18 @@ import geopandas as gpd import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators +from blocksnet.analysis.provision import competitive_provision, provision_strong_total from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use +from blocksnet.config import service_types_config from blocksnet.enums import LandUse from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor -from blocksnet.relations import generate_adjacency_graph +from blocksnet.relations import ( + calculate_accessibility_matrix, + generate_adjacency_graph, + get_accessibility_context, + get_accessibility_graph, +) from loguru import logger from app.dependencies import urban_api_gateway @@ -595,5 +602,77 @@ async def calc_context_development( res.update({"params_data": params.model_dump()}) return DevelopmentResponseSchema(**res) + def _get_accessibility_context( + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float + ) -> list[int]: + blocks["population"] = blocks["population"].fillna(0) + project_blocks = blocks[blocks["is_project"]].copy() + context_blocks = get_accessibility_context( + acc_mx, project_blocks, accessibility, out=False, keep=True + ) + return list(context_blocks.index) + + def _assess_provision( + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str + ) -> gpd.GeoDataFrame: + _, demand, accessibility = service_types_config[service_type].values() + context_ids = self._get_accessibility_context(blocks, acc_mx, accessibility) + capacity_column = f"capacity_{service_type}" + if capacity_column not in blocks.columns: + blocks_df = blocks[["geometry", "population"]].fillna(0) + blocks_df["capacity"] = 0 + else: + blocks_df = blocks.rename(columns={capacity_column: "capacity"})[ + ["geometry", "population", "capacity"] + ].fillna(0) + prov_df, _ = competitive_provision(blocks_df, acc_mx, accessibility, demand) + prov_df = prov_df.loc[context_ids].copy() + return blocks[["geometry"]].join(prov_df, how="right") + + async def territory_transformation_scenario( + self, token: str, params: ContextDevelopmentDTO + ): + service_types = await urban_api_gateway.get_service_types() + service_types = await adapt_service_types(service_types) + service_types = service_types[ + ~service_types["infrastructure_type"].isna() + ].copy() + + params = await self.get_optimal_func_zone_data(params, token) + context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, + ) + scenario_blocks, scenario_buildings = ( + await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) + ) + blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) + graph = get_accessibility_graph(scenario_blocks, "intermodal") + acc_mx = calculate_accessibility_matrix(scenario_blocks, graph) + prov_gdfs = {} + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + column = f"capacity_{st_name}" + _, demand, accessibility = service_types_config[st_name].values() + prov_gdf = self._assess_provision(blocks, acc_mx, st_name) + prov_gdfs[st_name] = prov_gdf + + prov_totals = {} + for st_name, prov_gdf in prov_gdfs.items(): + if prov_gdf.demand.sum() == 0: + total = None + else: + total = float(provision_strong_total(prov_gdf)) + prov_totals[st_name] = total + + return prov_totals + effects_service = EffectsService() From 56423275f219d7f18fe8166674dcb1e47ddf5042 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 3 Jul 2025 11:45:48 +0300 Subject: [PATCH 046/161] fix(main): - disabled allow_credentials --- app/main.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/main.py b/app/main.py index 3c23e6f..3a07e29 100644 --- a/app/main.py +++ b/app/main.py @@ -1,5 +1,3 @@ -from contextlib import asynccontextmanager - from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware @@ -21,7 +19,7 @@ app.add_middleware( CORSMiddleware, allow_origins=origins, - allow_credentials=True, + allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) From 34183b2d336e404f5930959fd2b033c13c2c80a7 Mon Sep 17 00:00:00 2001 From: Leon Date: Thu, 3 Jul 2025 13:35:42 +0300 Subject: [PATCH 047/161] fix(auth): - added tokens to all requests to urban api --- app/effects_api/effects_service.py | 8 ++-- app/effects_api/modules/context_service.py | 52 ++++++++++++--------- app/effects_api/modules/scenario_service.py | 9 ++-- app/gateways/urban_api_gateway.py | 32 +++++++++---- 4 files changed, 64 insertions(+), 37 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index f5d426b..5361416 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -227,7 +227,7 @@ async def aggregate_blocks_layer_context( logger.info("Starting generating context blocks layer") project_id = await urban_api_gateway.get_project_id(scenario_id, token) - context_blocks_gdf = await get_context_blocks(project_id) + context_blocks_gdf = await get_context_blocks(project_id, token) context_blocks_crs = context_blocks_gdf.crs context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) context_blocks_gdf["site_area"] = context_blocks_gdf.area @@ -247,7 +247,7 @@ async def aggregate_blocks_layer_context( ) logger.success(f"Land use for context blocks have been assigned {scenario_id}") - context_buildings_gdf = await get_context_buildings(project_id) + context_buildings_gdf = await get_context_buildings(project_id, token) if context_buildings_gdf is not None: context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) @@ -273,7 +273,9 @@ async def aggregate_blocks_layer_context( service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) - context_services_dict = await get_context_services(project_id, service_types) + context_services_dict = await get_context_services( + project_id, service_types, token + ) for service_type, services in context_services_dict.items(): services = services.to_crs(context_blocks_gdf.crs) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 1c6669b..25991b4 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -14,14 +14,17 @@ from app.effects_api.modules.services_service import adapt_services -async def _get_project_boundaries(project_id: int): +async def _get_project_boundaries(project_id: int, token: str): return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + geometry=[ + await urban_api_gateway.get_project_geometry(project_id, token=token) + ], + crs=4326, ) -async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: - project = await urban_api_gateway.get_project(project_id) +async def _get_context_boundaries(project_id: int, token: str) -> gpd.GeoDataFrame: + project = await urban_api_gateway.get_project(project_id, token) context_ids = project["properties"]["context"] geometries = [ await urban_api_gateway.get_territory_geometry(territory_id) @@ -30,29 +33,29 @@ async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: return gpd.GeoDataFrame(geometry=geometries, crs=4326) -async def _get_context_roads(project_id: int): +async def _get_context_roads(project_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=ROADS_ID + project_id, token, physical_object_function_id=ROADS_ID ) return gdf[["geometry"]].reset_index(drop=True) -async def _get_context_water(project_id: int): +async def _get_context_water(project_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=WATER_ID + project_id, token, physical_object_function_id=WATER_ID ) return gdf[["geometry"]].reset_index(drop=True) async def _get_context_blocks( - project_id: int, boundaries: gpd.GeoDataFrame + project_id: int, boundaries: gpd.GeoDataFrame, token: str ) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await _get_context_water(project_id) + water = await _get_context_water(project_id, token) water = water.to_crs(crs) - roads = await _get_context_roads(project_id) + roads = await _get_context_roads(project_id, token) roads = roads.to_crs(crs) roads.geometry = close_gaps(roads, 1) @@ -61,9 +64,9 @@ async def _get_context_blocks( return blocks -async def get_context_blocks(project_id: int): - project_boundaries = await _get_project_boundaries(project_id) - context_boundaries = await _get_context_boundaries(project_id) +async def get_context_blocks(project_id: int, token: str): + project_boundaries = await _get_project_boundaries(project_id, token) + context_boundaries = await _get_context_boundaries(project_id, token) crs = context_boundaries.estimate_utm_crs() context_boundaries = context_boundaries.to_crs(crs) @@ -72,16 +75,16 @@ async def get_context_blocks(project_id: int): context_boundaries = context_boundaries.overlay( project_boundaries, how="difference" ) - return await _get_context_blocks(project_id, context_boundaries) + return await _get_context_blocks(project_id, context_boundaries, token) async def get_context_functional_zones( project_id: int, source: str, year: int, token: str ) -> gpd.GeoDataFrame: - sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) + sources_df = await urban_api_gateway.get_functional_zones_sources(project_id, token) year, source = await _get_best_functional_zones_source(sources_df, source, year) functional_zones = await urban_api_gateway.get_functional_zones( - project_id, year, source + token, project_id, year, source ) functional_zones = functional_zones.loc[ functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) @@ -89,17 +92,24 @@ async def get_context_functional_zones( return adapt_functional_zones(functional_zones) -async def get_context_buildings(project_id: int): +async def get_context_buildings(project_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True + project_id, + token, + physical_object_type_id=LIVING_BUILDINGS_ID, + centers_only=True, ) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) -async def get_context_services(project_id: int, service_types: pd.DataFrame): - gdf = await urban_api_gateway.get_services(project_id, centers_only=True) +async def get_context_services( + project_id: int, service_types: pd.DataFrame, token: str +): + gdf = await urban_api_gateway.get_services( + project_id, centers_only=True, token=token + ) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index d7bb65b..2bc3790 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -49,9 +49,10 @@ def close_gaps(gdf, tolerance): # taken from momepy return gpd.GeoSeries(snapped, crs=gdf.crs) -async def _get_project_boundaries(project_id: int): +async def _get_project_boundaries(project_id: int, token: str): return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + geometry=[await urban_api_gateway.get_project_geometry(project_id, token)], + crs=4326, ) @@ -111,7 +112,7 @@ async def _get_scenario_blocks( async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: scenario = await urban_api_gateway.get_scenario(scenario_id, token) project_id = scenario["project"]["project_id"] - project = await urban_api_gateway.get_project(project_id) + project = await urban_api_gateway.get_project(project_id, token) base_scenario_id = project["base_scenario"]["id"] return project_id, base_scenario_id @@ -158,7 +159,7 @@ async def _get_best_functional_zones_source( async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataFrame: project_id, base_scenario_id = await _get_scenario_info(user_scenario_id, token) - project_boundaries = await _get_project_boundaries(project_id) + project_boundaries = await _get_project_boundaries(project_id, token) crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 1148486..038518b 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -18,51 +18,65 @@ def __init__(self, base_url: str) -> None: # TODO context async def get_physical_objects( - self, project_id: int, **kwargs: dict + self, project_id: int, token: str, **kwargs: dict ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", params=kwargs, + headers={"Authorization": f"Bearer {token}"}, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( "physical_object_id" ) - async def get_services(self, project_id: int, **kwargs: Any) -> gpd.GeoDataFrame: + async def get_services( + self, project_id: int, token: str, **kwargs: Any + ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/services_with_geometry", params=kwargs, + headers={"Authorization": f"Bearer {token}"}, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( "service_id" ) - async def get_functional_zones_sources(self, project_id: int) -> pd.DataFrame: + async def get_functional_zones_sources( + self, project_id: int, token: str + ) -> pd.DataFrame: res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/context/functional_zone_sources" + f"/api/v1/projects/{project_id}/context/functional_zone_sources", + headers={"Authorization": f"Bearer {token}"}, ) return pd.DataFrame(res) async def get_functional_zones( - self, project_id: int, year: int, source: int + self, token: str, project_id: int, year: int, source: int ) -> gpd.GeoDataFrame: res = await self.json_handler.get( f"/api/v1/projects/{project_id}/context/functional_zones", params={"year": year, "source": source}, + headers={"Authorization": f"Bearer {token}"}, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( "functional_zone_id" ) - async def get_project(self, project_id: int) -> Dict[str, Any]: - res = await self.json_handler.get(f"/api/v1/projects/{project_id}") + async def get_project(self, project_id: int, token: str) -> Dict[str, Any]: + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}", + headers={"Authorization": f"Bearer {token}"}, + ) return res - async def get_project_geometry(self, project_id: int): - res = await self.json_handler.get(f"/api/v1/projects/{project_id}/territory") + async def get_project_geometry(self, project_id: int, token: str): + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/territory", + headers={"Authorization": f"Bearer {token}"}, + ) geometry_json = json.dumps(res["geometry"]) return shapely.from_geojson(geometry_json) From badd1cbcf38c6facbe31a2b498d2239e9f169156 Mon Sep 17 00:00:00 2001 From: Leon Date: Fri, 4 Jul 2025 15:02:21 +0300 Subject: [PATCH 048/161] fix(effects_service): - crs mismatch fixed --- app/effects_api/effects_service.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 5361416..05d40c4 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -171,7 +171,8 @@ async def aggregate_blocks_layer_scenario( scenario_services_dict = await get_scenario_services( scenario_id, service_types, token ) - + if not scenario_services_dict: + scenario_services_dict = {} for service_type, services in scenario_services_dict.items(): services = services.to_crs(scenario_blocks_gdf.crs) scenario_blocks_services, _ = aggregate_objects( @@ -191,7 +192,6 @@ async def aggregate_blocks_layer_scenario( } ) ) - scenario_blocks_gdf["is_project"] = True logger.success( f"Services for scenario blocks have been aggregated {scenario_id}" @@ -276,7 +276,8 @@ async def aggregate_blocks_layer_context( context_services_dict = await get_context_services( project_id, service_types, token ) - + if not context_services_dict: + context_services_dict = {} for service_type, services in context_services_dict.items(): services = services.to_crs(context_blocks_gdf.crs) context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) @@ -520,9 +521,7 @@ async def evaluate_master_plan( crs=4326, ) ter_blocks = ( - blocks.sjoin( - territory.to_crs(territory.estimate_utm_crs()), how="left" - ) + blocks.sjoin(territory.to_crs(blocks.crs), how="left") .dropna(subset="index_right") .drop(columns="index_right") ) From 39ae4a7bfd1d71160c947739f3a4952edbb54bdb Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:41:30 +0300 Subject: [PATCH 049/161] feat(tmp_commit_2): - testing for full F35 logic - test version of tasks - test version of cache --- app/common/caching/caching_service.py | 65 +++++ app/effects_api/constants/const.py | 3 + .../dto/transformation_effects_dto.py | 11 + app/effects_api/effects_controller.py | 19 +- app/effects_api/effects_service.py | 227 +++++++++++++++++- app/effects_api/modules/context_service.py | 37 +++ app/effects_api/modules/task_service.py | 114 +++++++++ app/effects_api/tasks_controller.py | 69 ++++++ app/main.py | 5 + 9 files changed, 538 insertions(+), 12 deletions(-) create mode 100644 app/common/caching/caching_service.py create mode 100644 app/effects_api/dto/transformation_effects_dto.py create mode 100644 app/effects_api/modules/task_service.py create mode 100644 app/effects_api/tasks_controller.py diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py new file mode 100644 index 0000000..3c0e804 --- /dev/null +++ b/app/common/caching/caching_service.py @@ -0,0 +1,65 @@ +import json +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Literal + +_CACHE_DIR = Path("__effects_cache__") +_CACHE_DIR.mkdir(exist_ok=True) + +_FILENAME_RE = re.compile(r"[^A-Za-z0-9_-]+") + + +def _safe(s: str) -> str: + return _FILENAME_RE.sub("", s) + + +def _file_name(method: str, scenario_id: int, ts: datetime) -> Path: + stamp = ts.strftime("%Y%m%d_%H%M%S") + name = f"scenario_{scenario_id}__{_safe(method)}__{stamp}.json" + return _CACHE_DIR / name + + +class FileCache: + """Service for caching files.""" + + def save( + self, + method: str, + scenario_id: int, + params: dict[str, Any], + data: dict[str, Any], + ) -> Path: + ts = datetime.now() + path = _file_name(method, scenario_id, ts) + to_save = { + "meta": {"timestamp": ts.isoformat(), "params": params}, + "data": data, + } + path.write_text(json.dumps(to_save, ensure_ascii=False)) + return path + + def _latest_path(self, method: str, scenario_id: int) -> Path | None: + pattern = f"scenario_{scenario_id}__{_safe(method)}__*.json" + files = sorted(_CACHE_DIR.glob(pattern), reverse=True) + return files[0] if files else None + + def load( + self, method: str, scenario_id: int, max_age: timedelta | None = None + ) -> dict[str, Any] | None: + path = self._latest_path(method, scenario_id) + if not path: + return None + if max_age: + mtime = datetime.fromtimestamp(path.stat().st_mtime) + if datetime.now() - mtime > max_age: + return None + return json.loads(path.read_text()) + + def has( + self, method: str, scenario_id: int, max_age: timedelta | None = None + ) -> bool: + return self.load(method, scenario_id, max_age) is not None + + +cache = FileCache() diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index cee5cf0..85bea83 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -110,6 +110,9 @@ "population": [["properties", "population_balanced"]], } + +INFRASTRUCTURES_WEIGHTS = {"basic": 0.5714, "additional": 0.2857, "comfort": 0.1429} + LIVING_BUILDINGS_ID = 4 ROADS_ID = 26 WATER_ID = 4 diff --git a/app/effects_api/dto/transformation_effects_dto.py b/app/effects_api/dto/transformation_effects_dto.py new file mode 100644 index 0000000..0afa936 --- /dev/null +++ b/app/effects_api/dto/transformation_effects_dto.py @@ -0,0 +1,11 @@ +from pydantic import Field + +from app.effects_api.dto.development_dto import ContextDevelopmentDTO + + +class TerritoryTransformationDTO(ContextDevelopmentDTO): + required_service: str = Field( + ..., + examples=["school"], + description="Service type to get response on", + ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index b2e9002..86e6223 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -1,7 +1,9 @@ +import json from typing import Annotated from fastapi import APIRouter from fastapi.params import Depends +from starlette.responses import JSONResponse from app.common.auth.auth import verify_token @@ -10,6 +12,7 @@ DevelopmentDTO, SocioEconomicPredictionDTO, ) +from .dto.transformation_effects_dto import TerritoryTransformationDTO from .effects_service import effects_service from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema @@ -47,9 +50,21 @@ async def get_socio_economic_prediction( return await effects_service.evaluate_master_plan(params, token) +# @development_router.get("/F_35") +# async def territory_transformation( +# params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], +# token: str = Depends(verify_token), +# ): +# return await effects_service.territory_transformation_scenario(token, params) + + @development_router.get("/F_35") async def territory_transformation( - params: Annotated[SocioEconomicPredictionDTO, Depends(SocioEconomicPredictionDTO)], + params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], token: str = Depends(verify_token), ): - return await effects_service.territory_transformation_scenario(token, params) + gdf = await effects_service.territory_transformation_scenario(token, params) + gdf = gdf.to_crs(4326) + + geojson_dict = json.loads(gdf.to_json(drop_id=True)) + return JSONResponse(content=geojson_dict) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c1df730..eb28e2f 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,3 +1,5 @@ +import json + import geopandas as gpd import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators @@ -7,6 +9,16 @@ from blocksnet.config import service_types_config from blocksnet.enums import LandUse from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor +from blocksnet.optimization.services import ( + AreaSolution, + BlockSolution, + Facade, + GradientChooser, + SimpleChooser, + TPEOptimizer, + WeightedConstraints, + WeightedObjective, +) from blocksnet.relations import ( calculate_accessibility_matrix, generate_adjacency_graph, @@ -24,12 +36,14 @@ ) from app.effects_api.modules.service_type_service import adapt_service_types -from .constants.const import LAND_USE_RULES +from ..common.caching.caching_service import cache +from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, SocioEconomicPredictionDTO, ) +from .dto.transformation_effects_dto import TerritoryTransformationDTO from .modules.context_service import ( get_context_blocks, get_context_buildings, @@ -602,7 +616,7 @@ async def calc_context_development( res.update({"params_data": params.model_dump()}) return DevelopmentResponseSchema(**res) - def _get_accessibility_context( + async def _get_accessibility_context( self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float ) -> list[int]: blocks["population"] = blocks["population"].fillna(0) @@ -612,11 +626,14 @@ def _get_accessibility_context( ) return list(context_blocks.index) - def _assess_provision( + async def _assess_provision( self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str ) -> gpd.GeoDataFrame: _, demand, accessibility = service_types_config[service_type].values() - context_ids = self._get_accessibility_context(blocks, acc_mx, accessibility) + blocks["is_project"] = blocks["is_project"].fillna(False).astype(bool) + context_ids = await self._get_accessibility_context( + blocks, acc_mx, accessibility + ) capacity_column = f"capacity_{service_type}" if capacity_column not in blocks.columns: blocks_df = blocks[["geometry", "population"]].fillna(0) @@ -630,7 +647,7 @@ def _assess_provision( return blocks[["geometry"]].join(prov_df, how="right") async def territory_transformation_scenario( - self, token: str, params: ContextDevelopmentDTO + self, token: str, params: TerritoryTransformationDTO ): service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) @@ -654,14 +671,13 @@ async def territory_transformation_scenario( ) ) blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) - graph = get_accessibility_graph(scenario_blocks, "intermodal") - acc_mx = calculate_accessibility_matrix(scenario_blocks, graph) + graph = get_accessibility_graph(blocks, "intermodal") + acc_mx = calculate_accessibility_matrix(blocks, graph) prov_gdfs = {} for st_id in service_types.index: st_name = service_types.loc[st_id, "name"] - column = f"capacity_{st_name}" _, demand, accessibility = service_types_config[st_name].values() - prov_gdf = self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) prov_gdfs[st_name] = prov_gdf prov_totals = {} @@ -672,7 +688,198 @@ async def territory_transformation_scenario( total = float(provision_strong_total(prov_gdf)) prov_totals[st_name] = total - return prov_totals + # provision after + service_types["infrastructure_weight"] = ( + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] + ) + blocks_lus = blocks.loc[blocks["is_project"], "land_use"] + blocks_lus = blocks_lus[~blocks_lus.isna()] + blocks_lus = blocks_lus.to_dict() + + var_adapter = AreaSolution(blocks_lus) + + facade = Facade( + blocks_lu=blocks_lus, + blocks_df=blocks, + accessibility_matrix=acc_mx, + var_adapter=var_adapter, + ) + + for st_id, row in service_types.iterrows(): + st_name = row["name"] + st_weight = row["infrastructure_weight"] + st_column = f"capacity_{st_name}" + if st_column in blocks.columns: + df = blocks.rename(columns={st_column: "capacity"})[ + ["capacity"] + ].fillna(0) + else: + logger.info( + f"#{st_id}:{st_name} нет на территории контекста проекта. Добавляем нулевой датафрейм" + ) + df = blocks[[]].copy() + df["capacity"] = 0 + facade.add_service_type(st_name, st_weight, df) + + services_weights = service_types.set_index("name")[ + "infrastructure_weight" + ].to_dict() + + objective = WeightedObjective( + num_params=facade.num_params, + facade=facade, + weights=services_weights, + max_evals=50, + ) + + constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) + + tpe_optimizer = TPEOptimizer( + objective=objective, + constraints=constraints, + vars_chooser=SimpleChooser(facade), + ) + + best_x, best_val, perc, func_evals = tpe_optimizer.run( + max_runs=50, timeout=60000, initial_runs_num=1 + ) + + prov_gdfs = {} + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + if st_name in facade._chosen_service_types: + prov_df = facade._provision_adapter.get_last_provision_df(st_name) + prov_gdf = blocks[["geometry"]].join(prov_df, how="right") + prov_gdfs[st_name] = prov_gdf + + prov_totals = {} + for st_name, prov_gdf in prov_gdfs.items(): + if prov_gdf.demand.sum() == 0: + total = None + else: + total = float(provision_strong_total(prov_gdf)) + prov_totals[st_name] = total + + response = prov_gdfs[params.required_service] + logger.info("123") + return response + + # async def territory_transformation_scenario( + # self, token: str, params: TerritoryTransformationDTO + # ): + # method_name = "territory_transformation" + # scen_id = params.scenario_id + # required_srv = params.required_service + # + # cached = cache.load(method_name, scen_id) + # if cached: + # prov_gdfs = { + # name: gpd.GeoDataFrame.from_features(fcoll["features"]) + # for name, fcoll in cached["data"].items() + # } + # return prov_gdfs[required_srv] + # + # service_types = await urban_api_gateway.get_service_types() + # service_types = await adapt_service_types(service_types) + # service_types = service_types[ + # ~service_types["infrastructure_type"].isna() + # ].copy() + # + # params = await self.get_optimal_func_zone_data(params, token) + # context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + # params.scenario_id, + # params.context_func_zone_source, + # params.context_func_source_year, + # token, + # ) + # scenario_blocks, scenario_buildings = ( + # await self.aggregate_blocks_layer_scenario( + # params.scenario_id, + # params.proj_func_zone_source, + # params.proj_func_source_year, + # token, + # ) + # ) + # blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) + # graph = get_accessibility_graph(blocks, "intermodal") + # acc_mx = calculate_accessibility_matrix(blocks, graph) + # prov_gdfs = {} + # for st_id in service_types.index: + # st_name = service_types.loc[st_id, "name"] + # _, demand, accessibility = service_types_config[st_name].values() + # prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + # prov_gdfs[st_name] = prov_gdf + # + # prov_totals = {} + # for st_name, prov_gdf in prov_gdfs.items(): + # if prov_gdf.demand.sum() == 0: + # total = None + # else: + # total = float(provision_strong_total(prov_gdf)) + # prov_totals[st_name] = total + # + # #provision after + # service_types['infrastructure_weight'] = service_types['infrastructure_type'].map(INFRASTRUCTURES_WEIGHTS) * \ + # service_types['infrastructure_weight'] + # blocks_lus = blocks.loc[blocks['is_project'], 'land_use'] + # blocks_lus = blocks_lus[~blocks_lus.isna()] + # blocks_lus = blocks_lus.to_dict() + # + # var_adapter = AreaSolution(blocks_lus) + # + # facade = Facade( + # blocks_lu=blocks_lus, + # blocks_df=blocks, + # accessibility_matrix=acc_mx, + # var_adapter=var_adapter, + # ) + # + # for st_id, row in service_types.iterrows(): + # st_name = row['name'] + # st_weight = row['infrastructure_weight'] + # st_column = f'capacity_{st_name}' + # if st_column in blocks.columns: + # df = blocks.rename(columns={st_column: 'capacity'})[['capacity']].fillna(0) + # else: + # logger.info(f'#{st_id}:{st_name} нет на территории контекста проекта. Добавляем нулевой датафрейм') + # df = blocks[[]].copy() + # df['capacity'] = 0 + # facade.add_service_type(st_name, st_weight, df) + # + # services_weights = service_types.set_index('name')['infrastructure_weight'].to_dict() + # + # objective = WeightedObjective(num_params=facade.num_params, facade=facade, weights=services_weights, max_evals=50) + # constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) + # tpe_optimizer = TPEOptimizer(objective=objective, constraints=constraints, vars_chooser=SimpleChooser(facade)) + # + # best_x, best_val, perc, func_evals = tpe_optimizer.run(max_runs=50, timeout=60000, initial_runs_num=1) + # + # prov_gdfs = {} + # for st_id in service_types.index: + # st_name = service_types.loc[st_id, 'name'] + # if st_name in facade._chosen_service_types: + # prov_df = facade._provision_adapter.get_last_provision_df(st_name) + # prov_gdf = blocks[['geometry']].join(prov_df, how='right') + # prov_gdf = prov_gdf.to_crs(4326) + # prov_gdfs[st_name] = prov_gdf + # + # # prov_totals = {} + # # for st_name, prov_gdf in prov_gdfs.items(): + # # if prov_gdf.demand.sum() == 0: + # # total = None + # # else: + # # total = float(provision_strong_total(prov_gdf)) + # # prov_totals[st_name] = total + # + # prov_json = { + # name: json.loads(gdf.to_json(drop_id=True)) + # for name, gdf in prov_gdfs.items() + # } + # cache.save(method_name, scen_id, params.model_dump(), prov_json) + # + # response = prov_gdfs[params.required_service] + # return response effects_service = EffectsService() diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 1c6669b..d2c514e 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -13,6 +13,10 @@ ) from app.effects_api.modules.services_service import adapt_services +# from blocksnet.relations import get_accessibility_context +# from blocksnet.config import service_types_config +# from blocksnet.analysis.provision import competitive_provision + async def _get_project_boundaries(project_id: int): return gpd.GeoDataFrame( @@ -103,3 +107,36 @@ async def get_context_services(project_id: int, service_types: pd.DataFrame): gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + + +# async def _assess_provision( +# blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str +# ) -> gpd.GeoDataFrame: +# _, demand, accessibility = service_types_config[service_type].values() +# blocks["is_project"] = ( +# blocks["is_project"] +# .fillna(False) +# .astype(bool) +# ) +# context_ids = await _get_accessibility_context(blocks, acc_mx, accessibility) +# capacity_column = f"capacity_{service_type}" +# if capacity_column not in blocks.columns: +# blocks_df = blocks[["geometry", "population"]].fillna(0) +# blocks_df["capacity"] = 0 +# else: +# blocks_df = blocks.rename(columns={capacity_column: "capacity"})[ +# ["geometry", "population", "capacity"] +# ].fillna(0) +# prov_df, _ = competitive_provision(blocks_df, acc_mx, accessibility, demand) +# prov_df = prov_df.loc[context_ids].copy() +# return blocks[["geometry"]].join(prov_df, how="right") +# +# async def _get_accessibility_context( +# blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float +# ) -> list[int]: +# blocks["population"] = blocks["population"].fillna(0) +# project_blocks = blocks[blocks["is_project"]].copy() +# context_blocks = get_accessibility_context( +# acc_mx, project_blocks, accessibility, out=False, keep=True +# ) +# return list(context_blocks.index) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py new file mode 100644 index 0000000..a98ab63 --- /dev/null +++ b/app/effects_api/modules/task_service.py @@ -0,0 +1,114 @@ +import asyncio +import contextlib +import json +import uuid +from contextlib import asynccontextmanager +from datetime import timedelta +from typing import Any, Callable, Literal + +import geopandas as gpd +from fastapi import FastAPI +from loguru import logger + +from app.common.caching.caching_service import cache +from app.effects_api.effects_service import effects_service + +MethodFunc = Callable[[str, Any], "dict[str, Any]"] + +TASK_METHODS: dict[str, MethodFunc] = { + "territory_transformation": effects_service.territory_transformation_scenario, +} + +_task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() +_task_map: dict[str, "AnyTask"] = {} + + +class AnyTask: + def __init__(self, method: str, scenario_id: int, token: str, params: Any): + self.method = method + self.scenario_id = scenario_id + self.token = token + self.params = params + + self.task_id = f"{method}_{scenario_id}" + self.status: Literal["queued", "running", "done", "failed"] = "queued" + self.result: dict | None = None + self.error: str | None = None + + async def to_response(self) -> dict: + if self.status in {"queued", "running"}: + return {"status": self.status} + if self.status == "done": + return {"status": "done", "result": self.result} + return {"status": "failed", "error": self.error} + + def run_sync(self) -> None: + """ + Starts run from certain methods, + converts response to JSON-serializable type and puts into cache + """ + try: + logger.info(f"[{self.task_id}] started") + self.status = "running" + + cached = cache.load(self.method, self.scenario_id) + if cached: + logger.info(f"[{self.task_id}] loaded from cache") + self.result = cached["data"] + self.status = "done" + return + + func = TASK_METHODS[self.method] + raw_data = asyncio.run(func(self.token, self.params)) + + def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: + return json.loads(gdf.to_json(drop_id=True)) + + if isinstance(raw_data, gpd.GeoDataFrame): + data_to_cache = gdf_to_dict(raw_data) + + elif isinstance(raw_data, dict): + data_to_cache = { + k: gdf_to_dict(v) if isinstance(v, gpd.GeoDataFrame) else v + for k, v in raw_data.items() + } + else: + data_to_cache = raw_data + + cache.save( + self.method, + self.scenario_id, + self.params.model_dump(), + data_to_cache, + ) + self.result = data_to_cache + self.status = "done" + + except Exception as exc: + logger.exception(exc) + self.status = "failed" + self.error = str(exc) + + +async def _worker(): + while True: + task: AnyTask = await _task_queue.get() + await asyncio.to_thread(task.run_sync) + _task_queue.task_done() + + +worker_task: asyncio.Task | None = None + + +def init_worker(app: FastAPI): + global worker_task + worker_task = asyncio.create_task(_worker(), name="any_task_worker") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + yield + if worker_task: + worker_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await worker_task diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py new file mode 100644 index 0000000..306f60c --- /dev/null +++ b/app/effects_api/tasks_controller.py @@ -0,0 +1,69 @@ +import uuid +from typing import Annotated + +from fastapi import APIRouter +from fastapi.params import Depends +from starlette.responses import JSONResponse + +from app.common.auth.auth import verify_token +from app.effects_api.modules.task_service import ( + TASK_METHODS, + AnyTask, + _task_map, + _task_queue, +) + +from ..common.caching.caching_service import cache +from ..common.exceptions.http_exception_wrapper import http_exception +from .dto.transformation_effects_dto import TerritoryTransformationDTO + +router = APIRouter(prefix="/tasks", tags=["tasks"]) + + +@router.post("/{method}", status_code=202) +async def create_task( + method: str, + params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], + token: str = Depends(verify_token), # token получаем здесь +): + if method not in TASK_METHODS: + raise http_exception(404, f"method '{method}' is not registered", method) + + task_id = f"{method}__{params.scenario_id}" + + # ────────── не дублируем задачу ────────── + existing = _task_map.get(task_id) + if existing and existing.status in {"queued", "running"}: + # Уже в очереди или считается ➜ отдаём старый id/статус + return {"task_id": task_id, "status": existing.status} + + # ────────── создаём новую ────────── + task = AnyTask(method, params.scenario_id, token, params) # ← token передаём! + _task_map[task_id] = task + await _task_queue.put(task) + + return {"task_id": task_id, "status": "queued"} + + +@router.get("/{task_id}") +async def task_status(task_id: str): + task = _task_map.get(task_id) + if not task: + raise http_exception(404, "task not found", task_id) + return await task.to_response() + + +@router.get("/territory_transformation/{scenario_id}/{service_name}") +async def get_tt_layer(scenario_id: int, service_name: str): + """ + Вернуть уже посчитанный layer (или 404, если нет). + """ + cached = cache.load("territory_transformation", scenario_id) + if not cached: + raise http_exception(404, "no cached result for this scenario", scenario_id) + + fcoll = cached["data"].get(service_name) + if not fcoll: + raise http_exception(404, f"service '{service_name}' not found") + + return JSONResponse(content=fcoll) diff --git a/app/main.py b/app/main.py index 3c23e6f..b7c4ed0 100644 --- a/app/main.py +++ b/app/main.py @@ -7,13 +7,17 @@ from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.effects_controller import development_router +from app.effects_api.modules.task_service import init_worker, lifespan +from app.effects_api.tasks_controller import router as tasks_router from app.system_router.system_controller import system_router # TODO add app version app = FastAPI( title="Effects API", description="API for calculating effects of territory transformation with BlocksNet library", + lifespan=lifespan, ) +init_worker(app) origins = ["*"] @@ -34,5 +38,6 @@ async def read_root(): return RedirectResponse("/docs") +app.include_router(tasks_router) app.include_router(system_router) app.include_router(development_router) From e6d33ce7b71b9deaa68ac295ffe5b6484986b7dc Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Wed, 9 Jul 2025 01:50:14 +0300 Subject: [PATCH 050/161] feat(tmp_commit_3): - test version of tasks - test version of cache, removed seconds from cached files timestamps - updated gitignore --- .gitignore | 1 + app/common/caching/caching_service.py | 12 +- app/effects_api/effects_service.py | 263 ++++++++++++++------------ app/effects_api/tasks_controller.py | 12 +- 4 files changed, 154 insertions(+), 134 deletions(-) diff --git a/.gitignore b/.gitignore index c7c0891..0f0d464 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ gdf_with_obj.geojson boundaries.parquet roads.parquet water.parquet +__effects_cache__ diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 3c0e804..01428b5 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -4,8 +4,8 @@ from pathlib import Path from typing import Any, Literal -_CACHE_DIR = Path("__effects_cache__") -_CACHE_DIR.mkdir(exist_ok=True) +_CACHE_DIR = Path().absolute() / "__effects_cache__" +_CACHE_DIR.mkdir(parents=True, exist_ok=True) _FILENAME_RE = re.compile(r"[^A-Za-z0-9_-]+") @@ -15,8 +15,8 @@ def _safe(s: str) -> str: def _file_name(method: str, scenario_id: int, ts: datetime) -> Path: - stamp = ts.strftime("%Y%m%d_%H%M%S") - name = f"scenario_{scenario_id}__{_safe(method)}__{stamp}.json" + stamp = ts.strftime("%Y%m%d_%H%M") + name = f"scenario_{scenario_id}_{_safe(method)}_{stamp}.json" return _CACHE_DIR / name @@ -32,6 +32,8 @@ def save( ) -> Path: ts = datetime.now() path = _file_name(method, scenario_id, ts) + if path.exists(): + return path to_save = { "meta": {"timestamp": ts.isoformat(), "params": params}, "data": data, @@ -40,7 +42,7 @@ def save( return path def _latest_path(self, method: str, scenario_id: int) -> Path | None: - pattern = f"scenario_{scenario_id}__{_safe(method)}__*.json" + pattern = f"scenario_{scenario_id}_{_safe(method)}_*.json" files = sorted(_CACHE_DIR.glob(pattern), reverse=True) return files[0] if files else None diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index eb28e2f..b8b43ec 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -646,9 +646,140 @@ async def _assess_provision( prov_df = prov_df.loc[context_ids].copy() return blocks[["geometry"]].join(prov_df, how="right") + # async def territory_transformation_scenario( + # self, token: str, params: TerritoryTransformationDTO + # ): + # service_types = await urban_api_gateway.get_service_types() + # service_types = await adapt_service_types(service_types) + # service_types = service_types[ + # ~service_types["infrastructure_type"].isna() + # ].copy() + # + # params = await self.get_optimal_func_zone_data(params, token) + # context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + # params.scenario_id, + # params.context_func_zone_source, + # params.context_func_source_year, + # token, + # ) + # scenario_blocks, scenario_buildings = ( + # await self.aggregate_blocks_layer_scenario( + # params.scenario_id, + # params.proj_func_zone_source, + # params.proj_func_source_year, + # token, + # ) + # ) + # blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) + # graph = get_accessibility_graph(blocks, "intermodal") + # acc_mx = calculate_accessibility_matrix(blocks, graph) + # prov_gdfs = {} + # for st_id in service_types.index: + # st_name = service_types.loc[st_id, "name"] + # _, demand, accessibility = service_types_config[st_name].values() + # prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + # prov_gdfs[st_name] = prov_gdf + # + # prov_totals = {} + # for st_name, prov_gdf in prov_gdfs.items(): + # if prov_gdf.demand.sum() == 0: + # total = None + # else: + # total = float(provision_strong_total(prov_gdf)) + # prov_totals[st_name] = total + # + # # provision after + # service_types["infrastructure_weight"] = ( + # service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + # * service_types["infrastructure_weight"] + # ) + # blocks_lus = blocks.loc[blocks["is_project"], "land_use"] + # blocks_lus = blocks_lus[~blocks_lus.isna()] + # blocks_lus = blocks_lus.to_dict() + # + # var_adapter = AreaSolution(blocks_lus) + # + # facade = Facade( + # blocks_lu=blocks_lus, + # blocks_df=blocks, + # accessibility_matrix=acc_mx, + # var_adapter=var_adapter, + # ) + # + # for st_id, row in service_types.iterrows(): + # st_name = row["name"] + # st_weight = row["infrastructure_weight"] + # st_column = f"capacity_{st_name}" + # if st_column in blocks.columns: + # df = blocks.rename(columns={st_column: "capacity"})[ + # ["capacity"] + # ].fillna(0) + # else: + # logger.info( + # f"#{st_id}:{st_name} нет на территории контекста проекта. Добавляем нулевой датафрейм" + # ) + # df = blocks[[]].copy() + # df["capacity"] = 0 + # facade.add_service_type(st_name, st_weight, df) + # + # services_weights = service_types.set_index("name")[ + # "infrastructure_weight" + # ].to_dict() + # + # objective = WeightedObjective( + # num_params=facade.num_params, + # facade=facade, + # weights=services_weights, + # max_evals=50, + # ) + # + # constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) + # + # tpe_optimizer = TPEOptimizer( + # objective=objective, + # constraints=constraints, + # vars_chooser=SimpleChooser(facade), + # ) + # + # best_x, best_val, perc, func_evals = tpe_optimizer.run( + # max_runs=50, timeout=60000, initial_runs_num=1 + # ) + # + # prov_gdfs = {} + # for st_id in service_types.index: + # st_name = service_types.loc[st_id, "name"] + # if st_name in facade._chosen_service_types: + # prov_df = facade._provision_adapter.get_last_provision_df(st_name) + # prov_gdf = blocks[["geometry"]].join(prov_df, how="right") + # prov_gdfs[st_name] = prov_gdf + # + # prov_totals = {} + # for st_name, prov_gdf in prov_gdfs.items(): + # if prov_gdf.demand.sum() == 0: + # total = None + # else: + # total = float(provision_strong_total(prov_gdf)) + # prov_totals[st_name] = total + # + # response = prov_gdfs[params.required_service] + # logger.info("123") + # return response + async def territory_transformation_scenario( - self, token: str, params: TerritoryTransformationDTO + self, token: str, params: ContextDevelopmentDTO ): + method_name = "territory_transformation" + scen_id = params.scenario_id + # required_srv = params.required_service + + cached = cache.load(method_name, scen_id) + if cached: + prov_gdfs = { + name: gpd.GeoDataFrame.from_features(fcoll["features"]) + for name, fcoll in cached["data"].items() + } + return prov_gdfs + service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) service_types = service_types[ @@ -716,7 +847,7 @@ async def territory_transformation_scenario( ].fillna(0) else: logger.info( - f"#{st_id}:{st_name} нет на территории контекста проекта. Добавляем нулевой датафрейм" + f"#{st_id}:{st_name} does not exist on territory. Adding empty GeoDataFrame" ) df = blocks[[]].copy() df["capacity"] = 0 @@ -732,9 +863,7 @@ async def territory_transformation_scenario( weights=services_weights, max_evals=50, ) - constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) - tpe_optimizer = TPEOptimizer( objective=objective, constraints=constraints, @@ -751,6 +880,7 @@ async def territory_transformation_scenario( if st_name in facade._chosen_service_types: prov_df = facade._provision_adapter.get_last_provision_df(st_name) prov_gdf = blocks[["geometry"]].join(prov_df, how="right") + prov_gdf = prov_gdf.to_crs(4326) prov_gdfs[st_name] = prov_gdf prov_totals = {} @@ -761,125 +891,14 @@ async def territory_transformation_scenario( total = float(provision_strong_total(prov_gdf)) prov_totals[st_name] = total - response = prov_gdfs[params.required_service] - logger.info("123") - return response + prov_json = { + name: json.loads(gdf.to_json(drop_id=True)) + for name, gdf in prov_gdfs.items() + } + cache.save(method_name, scen_id, params.model_dump(), prov_json) - # async def territory_transformation_scenario( - # self, token: str, params: TerritoryTransformationDTO - # ): - # method_name = "territory_transformation" - # scen_id = params.scenario_id - # required_srv = params.required_service - # - # cached = cache.load(method_name, scen_id) - # if cached: - # prov_gdfs = { - # name: gpd.GeoDataFrame.from_features(fcoll["features"]) - # for name, fcoll in cached["data"].items() - # } - # return prov_gdfs[required_srv] - # - # service_types = await urban_api_gateway.get_service_types() - # service_types = await adapt_service_types(service_types) - # service_types = service_types[ - # ~service_types["infrastructure_type"].isna() - # ].copy() - # - # params = await self.get_optimal_func_zone_data(params, token) - # context_blocks, context_buildings = await self.aggregate_blocks_layer_context( - # params.scenario_id, - # params.context_func_zone_source, - # params.context_func_source_year, - # token, - # ) - # scenario_blocks, scenario_buildings = ( - # await self.aggregate_blocks_layer_scenario( - # params.scenario_id, - # params.proj_func_zone_source, - # params.proj_func_source_year, - # token, - # ) - # ) - # blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) - # graph = get_accessibility_graph(blocks, "intermodal") - # acc_mx = calculate_accessibility_matrix(blocks, graph) - # prov_gdfs = {} - # for st_id in service_types.index: - # st_name = service_types.loc[st_id, "name"] - # _, demand, accessibility = service_types_config[st_name].values() - # prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - # prov_gdfs[st_name] = prov_gdf - # - # prov_totals = {} - # for st_name, prov_gdf in prov_gdfs.items(): - # if prov_gdf.demand.sum() == 0: - # total = None - # else: - # total = float(provision_strong_total(prov_gdf)) - # prov_totals[st_name] = total - # - # #provision after - # service_types['infrastructure_weight'] = service_types['infrastructure_type'].map(INFRASTRUCTURES_WEIGHTS) * \ - # service_types['infrastructure_weight'] - # blocks_lus = blocks.loc[blocks['is_project'], 'land_use'] - # blocks_lus = blocks_lus[~blocks_lus.isna()] - # blocks_lus = blocks_lus.to_dict() - # - # var_adapter = AreaSolution(blocks_lus) - # - # facade = Facade( - # blocks_lu=blocks_lus, - # blocks_df=blocks, - # accessibility_matrix=acc_mx, - # var_adapter=var_adapter, - # ) - # - # for st_id, row in service_types.iterrows(): - # st_name = row['name'] - # st_weight = row['infrastructure_weight'] - # st_column = f'capacity_{st_name}' - # if st_column in blocks.columns: - # df = blocks.rename(columns={st_column: 'capacity'})[['capacity']].fillna(0) - # else: - # logger.info(f'#{st_id}:{st_name} нет на территории контекста проекта. Добавляем нулевой датафрейм') - # df = blocks[[]].copy() - # df['capacity'] = 0 - # facade.add_service_type(st_name, st_weight, df) - # - # services_weights = service_types.set_index('name')['infrastructure_weight'].to_dict() - # - # objective = WeightedObjective(num_params=facade.num_params, facade=facade, weights=services_weights, max_evals=50) - # constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) - # tpe_optimizer = TPEOptimizer(objective=objective, constraints=constraints, vars_chooser=SimpleChooser(facade)) - # - # best_x, best_val, perc, func_evals = tpe_optimizer.run(max_runs=50, timeout=60000, initial_runs_num=1) - # - # prov_gdfs = {} - # for st_id in service_types.index: - # st_name = service_types.loc[st_id, 'name'] - # if st_name in facade._chosen_service_types: - # prov_df = facade._provision_adapter.get_last_provision_df(st_name) - # prov_gdf = blocks[['geometry']].join(prov_df, how='right') - # prov_gdf = prov_gdf.to_crs(4326) - # prov_gdfs[st_name] = prov_gdf - # - # # prov_totals = {} - # # for st_name, prov_gdf in prov_gdfs.items(): - # # if prov_gdf.demand.sum() == 0: - # # total = None - # # else: - # # total = float(provision_strong_total(prov_gdf)) - # # prov_totals[st_name] = total - # - # prov_json = { - # name: json.loads(gdf.to_json(drop_id=True)) - # for name, gdf in prov_gdfs.items() - # } - # cache.save(method_name, scen_id, params.model_dump(), prov_json) - # - # response = prov_gdfs[params.required_service] - # return response + # response = prov_gdfs[params.required_service] + return prov_gdfs effects_service = EffectsService() diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 306f60c..311885b 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -15,6 +15,7 @@ from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception +from .dto.development_dto import ContextDevelopmentDTO from .dto.transformation_effects_dto import TerritoryTransformationDTO router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -23,22 +24,19 @@ @router.post("/{method}", status_code=202) async def create_task( method: str, - params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], - token: str = Depends(verify_token), # token получаем здесь + params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], + token: str = Depends(verify_token), ): if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) - task_id = f"{method}__{params.scenario_id}" + task_id = f"{method}_{params.scenario_id}" - # ────────── не дублируем задачу ────────── existing = _task_map.get(task_id) if existing and existing.status in {"queued", "running"}: - # Уже в очереди или считается ➜ отдаём старый id/статус return {"task_id": task_id, "status": existing.status} - # ────────── создаём новую ────────── - task = AnyTask(method, params.scenario_id, token, params) # ← token передаём! + task = AnyTask(method, params.scenario_id, token, params) _task_map[task_id] = task await _task_queue.put(task) From c88b99a213d53ecbf458d65072eac78b813549c4 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Wed, 9 Jul 2025 12:59:52 +0300 Subject: [PATCH 051/161] feat(tmp_commit_4): - test version of tasks - working cache for tt method in format "timestamp__scen_id__method_name__hash" --- app/common/caching/caching_service.py | 60 ++++++++++++++++++++----- app/effects_api/effects_service.py | 58 +++++++++++++++--------- app/effects_api/modules/task_service.py | 12 +++-- app/effects_api/tasks_controller.py | 40 ++++++++++++----- 4 files changed, 122 insertions(+), 48 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 01428b5..aa99380 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -1,3 +1,4 @@ +import hashlib import json import re from datetime import datetime, timedelta @@ -14,48 +15,85 @@ def _safe(s: str) -> str: return _FILENAME_RE.sub("", s) -def _file_name(method: str, scenario_id: int, ts: datetime) -> Path: - stamp = ts.strftime("%Y%m%d_%H%M") - name = f"scenario_{scenario_id}_{_safe(method)}_{stamp}.json" +def _file_name(method: str, scenario_id: int, phash: str, day: str) -> Path: + name = f"{day}__scenario_{scenario_id}__{_safe(method)}__{phash}.json" return _CACHE_DIR / name +def _to_dt(dt_str: str) -> datetime: + # Urban API часто возвращает с 'Z' на конце → заменим на +00:00 + if dt_str.endswith("Z"): + dt_str = dt_str[:-1] + "+00:00" + return datetime.fromisoformat(dt_str) + + class FileCache: """Service for caching files.""" + def params_hash(self, params: dict[str, Any]) -> str: + """ + 8-symbol md5-hash from params dict. + """ + raw = json.dumps(params, sort_keys=True, separators=(",", ":")) + return hashlib.md5(raw.encode()).hexdigest()[:8] + def save( self, method: str, scenario_id: int, params: dict[str, Any], data: dict[str, Any], + scenario_updated_at: str | None = None, ) -> Path: - ts = datetime.now() - path = _file_name(method, scenario_id, ts) - if path.exists(): + + phash = self.params_hash(params) + day = datetime.now().strftime("%Y%m%d") + + path = _file_name(method, scenario_id, phash, day) + if path.exists(): # уже сохраняли сегодня return path + to_save = { - "meta": {"timestamp": ts.isoformat(), "params": params}, + "meta": { + "timestamp": datetime.now().isoformat(), + "scenario_updated_at": scenario_updated_at, + "params_hash": phash, + }, "data": data, } path.write_text(json.dumps(to_save, ensure_ascii=False)) return path def _latest_path(self, method: str, scenario_id: int) -> Path | None: - pattern = f"scenario_{scenario_id}_{_safe(method)}_*.json" + pattern = f"*__scenario_{scenario_id}__{_safe(method)}__*.json" files = sorted(_CACHE_DIR.glob(pattern), reverse=True) return files[0] if files else None def load( - self, method: str, scenario_id: int, max_age: timedelta | None = None + self, + method: str, + scenario_id: int, + params_hash: str, # ← требуем хэш + max_age: timedelta | None = None, ) -> dict[str, Any] | None: - path = self._latest_path(method, scenario_id) - if not path: + + pattern = f"*__scenario_{scenario_id}__{_safe(method)}__{params_hash}.json" + files = sorted(_CACHE_DIR.glob(pattern), reverse=True) + if not files: return None + + path = files[0] # самый свежий день if max_age: mtime = datetime.fromtimestamp(path.stat().st_mtime) if datetime.now() - mtime > max_age: return None + + return json.loads(path.read_text()) + + def load_latest(self, method: str, scenario_id: int) -> dict[str, Any] | None: + path = self._latest_path(method, scenario_id) + if not path: + return None return json.loads(path.read_text()) def has( diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index b8b43ec..ee1f472 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -36,7 +36,7 @@ ) from app.effects_api.modules.service_type_service import adapt_service_types -from ..common.caching.caching_service import cache +from ..common.caching.caching_service import _to_dt, cache from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, @@ -769,16 +769,26 @@ async def territory_transformation_scenario( self, token: str, params: ContextDevelopmentDTO ): method_name = "territory_transformation" - scen_id = params.scenario_id - # required_srv = params.required_service - cached = cache.load(method_name, scen_id) + # ───── получаем info, вытаскиваем updated_at и is_based ───── + info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + updated_at = info["updated_at"] # "2025-05-29T12:38:30Z" + is_based = info["is_based"] + + phash = cache.params_hash(params.model_dump()) + cached = cache.load(method_name, params.scenario_id, phash) if cached: - prov_gdfs = { - name: gpd.GeoDataFrame.from_features(fcoll["features"]) - for name, fcoll in cached["data"].items() - } - return prov_gdfs + cache_ts = _to_dt(cached["meta"]["timestamp"]) + scen_ts = _to_dt(updated_at) + + if cache_ts > scen_ts: + logger.info("cache hit — scenario older than cache") + return { + n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + for n, fc in cached["data"].items() + } + + logger.info("Cache stale: scenario updated; recalculating") service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) @@ -804,15 +814,17 @@ async def territory_transformation_scenario( blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) graph = get_accessibility_graph(blocks, "intermodal") acc_mx = calculate_accessibility_matrix(blocks, graph) - prov_gdfs = {} + + # provision before + prov_gdfs_before = {} for st_id in service_types.index: st_name = service_types.loc[st_id, "name"] _, demand, accessibility = service_types_config[st_name].values() prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - prov_gdfs[st_name] = prov_gdf + prov_gdfs_before[st_name] = prov_gdf prov_totals = {} - for st_name, prov_gdf in prov_gdfs.items(): + for st_name, prov_gdf in prov_gdfs_before.items(): if prov_gdf.demand.sum() == 0: total = None else: @@ -874,17 +886,17 @@ async def territory_transformation_scenario( max_runs=50, timeout=60000, initial_runs_num=1 ) - prov_gdfs = {} + prov_gdfs_after = {} for st_id in service_types.index: st_name = service_types.loc[st_id, "name"] if st_name in facade._chosen_service_types: prov_df = facade._provision_adapter.get_last_provision_df(st_name) prov_gdf = blocks[["geometry"]].join(prov_df, how="right") prov_gdf = prov_gdf.to_crs(4326) - prov_gdfs[st_name] = prov_gdf + prov_gdfs_after[st_name] = prov_gdf prov_totals = {} - for st_name, prov_gdf in prov_gdfs.items(): + for st_name, prov_gdf in prov_gdfs_after.items(): if prov_gdf.demand.sum() == 0: total = None else: @@ -892,13 +904,19 @@ async def territory_transformation_scenario( prov_totals[st_name] = total prov_json = { - name: json.loads(gdf.to_json(drop_id=True)) - for name, gdf in prov_gdfs.items() + n: json.loads(gdf.to_json(drop_id=True)) + for n, gdf in prov_gdfs_after.items() } - cache.save(method_name, scen_id, params.model_dump(), prov_json) - # response = prov_gdfs[params.required_service] - return prov_gdfs + cache.save( + method_name, + params.scenario_id, + params.model_dump(), + prov_json, + scenario_updated_at=updated_at, + ) + + return prov_gdfs_after effects_service = EffectsService() diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index a98ab63..b30e3ad 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -30,7 +30,8 @@ def __init__(self, method: str, scenario_id: int, token: str, params: Any): self.token = token self.params = params - self.task_id = f"{method}_{scenario_id}" + self.param_hash = cache.params_hash(params.model_dump()) + self.task_id = f"{method}_{scenario_id}_{self.param_hash}" self.status: Literal["queued", "running", "done", "failed"] = "queued" self.result: dict | None = None self.error: str | None = None @@ -43,15 +44,12 @@ async def to_response(self) -> dict: return {"status": "failed", "error": self.error} def run_sync(self) -> None: - """ - Starts run from certain methods, - converts response to JSON-serializable type and puts into cache - """ try: logger.info(f"[{self.task_id}] started") self.status = "running" - cached = cache.load(self.method, self.scenario_id) + # 1. Пытаемся взять кэш по (method, id, hash) + cached = cache.load(self.method, self.scenario_id, self.param_hash) if cached: logger.info(f"[{self.task_id}] loaded from cache") self.result = cached["data"] @@ -66,7 +64,6 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: if isinstance(raw_data, gpd.GeoDataFrame): data_to_cache = gdf_to_dict(raw_data) - elif isinstance(raw_data, dict): data_to_cache = { k: gdf_to_dict(v) if isinstance(v, gpd.GeoDataFrame) else v @@ -81,6 +78,7 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: self.params.model_dump(), data_to_cache, ) + self.result = data_to_cache self.status = "done" diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 311885b..a209c57 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -17,29 +17,42 @@ from ..common.exceptions.http_exception_wrapper import http_exception from .dto.development_dto import ContextDevelopmentDTO from .dto.transformation_effects_dto import TerritoryTransformationDTO +from .effects_service import effects_service router = APIRouter(prefix="/tasks", tags=["tasks"]) +async def _with_defaults( + dto: ContextDevelopmentDTO, token: str +) -> ContextDevelopmentDTO: + """Вернуть DTO, в котором source/year уже выбраны функцией–помощником""" + return await effects_service.get_optimal_func_zone_data(dto, token) + + @router.post("/{method}", status_code=202) async def create_task( method: str, - params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], + params: Annotated[ContextDevelopmentDTO, Depends()], token: str = Depends(verify_token), ): + if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) - task_id = f"{method}_{params.scenario_id}" + # 1) подставляем дефолты → получаем «финальные» параметры + params_filled = await effects_service.get_optimal_func_zone_data(params, token) + phash = cache.params_hash(params_filled.model_dump()) - existing = _task_map.get(task_id) - if existing and existing.status in {"queued", "running"}: - return {"task_id": task_id, "status": existing.status} + task_id = f"{method}_{params_filled.scenario_id}_{phash}" - task = AnyTask(method, params.scenario_id, token, params) + cached = cache.load(method, params_filled.scenario_id, phash) + if cached: + return {"task_id": task_id, "status": "done"} + + # 3) создаём задачу с уже заполненным DTO + task = AnyTask(method, params_filled.scenario_id, token, params_filled) _task_map[task_id] = task await _task_queue.put(task) - return {"task_id": task_id, "status": "queued"} @@ -54,14 +67,21 @@ async def task_status(task_id: str): @router.get("/territory_transformation/{scenario_id}/{service_name}") async def get_tt_layer(scenario_id: int, service_name: str): """ - Вернуть уже посчитанный layer (или 404, если нет). + Вернуть сохранённый FeatureCollection по сценарию и точному + названию сервиса (без какой-либо нормализации). """ - cached = cache.load("territory_transformation", scenario_id) + # 1. самый свежий файл для данного сценария + cached = cache.load_latest("territory_transformation", scenario_id) if not cached: raise http_exception(404, "no cached result for this scenario", scenario_id) fcoll = cached["data"].get(service_name) if not fcoll: - raise http_exception(404, f"service '{service_name}' not found") + raise http_exception( + 404, + f"service '{service_name}' not found", + _detail={"available": list(cached["data"])}, + ) + # 3. отдаём FeatureCollection-словарь return JSONResponse(content=fcoll) From 3c01d7bf0cee8339e06a8cf3dcf1352987e95409 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Wed, 9 Jul 2025 22:51:21 +0300 Subject: [PATCH 052/161] feat(tmp_commit_5): pre-fix commit - working tt method - returning geolayers "before" "after" --- app/common/caching/__init__.py | 0 app/common/caching/caching_service.py | 13 +++---- app/common/utils/__init__.py | 0 app/common/utils/geodata.py | 14 +++++++ app/effects_api/constants/const.py | 12 ++++++ app/effects_api/effects_service.py | 54 +++++++++++++++++++-------- app/effects_api/tasks_controller.py | 46 +++++++++++++---------- 7 files changed, 96 insertions(+), 43 deletions(-) create mode 100644 app/common/caching/__init__.py create mode 100644 app/common/utils/__init__.py create mode 100644 app/common/utils/geodata.py diff --git a/app/common/caching/__init__.py b/app/common/caching/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index aa99380..ae0dfdf 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -21,7 +21,6 @@ def _file_name(method: str, scenario_id: int, phash: str, day: str) -> Path: def _to_dt(dt_str: str) -> datetime: - # Urban API часто возвращает с 'Z' на конце → заменим на +00:00 if dt_str.endswith("Z"): dt_str = dt_str[:-1] + "+00:00" return datetime.fromisoformat(dt_str) @@ -50,7 +49,7 @@ def save( day = datetime.now().strftime("%Y%m%d") path = _file_name(method, scenario_id, phash, day) - if path.exists(): # уже сохраняли сегодня + if path.exists(): return path to_save = { @@ -61,7 +60,7 @@ def save( }, "data": data, } - path.write_text(json.dumps(to_save, ensure_ascii=False)) + path.write_text(json.dumps(to_save, ensure_ascii=False), encoding="utf-8") return path def _latest_path(self, method: str, scenario_id: int) -> Path | None: @@ -73,7 +72,7 @@ def load( self, method: str, scenario_id: int, - params_hash: str, # ← требуем хэш + params_hash: str, max_age: timedelta | None = None, ) -> dict[str, Any] | None: @@ -82,19 +81,19 @@ def load( if not files: return None - path = files[0] # самый свежий день + path = files[0] if max_age: mtime = datetime.fromtimestamp(path.stat().st_mtime) if datetime.now() - mtime > max_age: return None - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) def load_latest(self, method: str, scenario_id: int) -> dict[str, Any] | None: path = self._latest_path(method, scenario_id) if not path: return None - return json.loads(path.read_text()) + return json.loads(path.read_text(encoding="utf-8")) def has( self, method: str, scenario_id: int, max_age: timedelta | None = None diff --git a/app/common/utils/__init__.py b/app/common/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py new file mode 100644 index 0000000..2b8b066 --- /dev/null +++ b/app/common/utils/geodata.py @@ -0,0 +1,14 @@ +import json + +import geopandas as gpd + +from app.effects_api.constants.const import COL_RU + + +def _gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: + if "provision_weak" in gdf.columns: + gdf = gdf.drop(columns="provision_weak") + gdf = gdf.rename( + columns={k: v for k, v in COL_RU.items() if k in gdf.columns}, errors="raise" + ) + return json.loads(gdf.to_crs(4326).to_json(drop_id=True)) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index 85bea83..f09479c 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -113,6 +113,18 @@ INFRASTRUCTURES_WEIGHTS = {"basic": 0.5714, "additional": 0.2857, "comfort": 0.1429} +COL_RU = { + "demand": "Спрос", + "capacity": "Емкость сервисов", + "demand_left": "Неудовлетворенный спрос", + "demand_within": "Спрос в пределах нормативной доступности", + "demand_without": "Спрос за пределами нормативной доступности", + "capacity_left": "Оставшаяся емкость сервисов", + "capacity_within": "Емкость сервисов в пределах нормативной доступности", + "capacity_without": "Емкость сервисов за пределами нормативной доступности", + "provision_strong": "Обеспеченность сервисами", +} + LIVING_BUILDINGS_ID = 4 ROADS_ID = 26 WATER_ID = 4 diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index ee1f472..407dcd1 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -37,6 +37,7 @@ from app.effects_api.modules.service_type_service import adapt_service_types from ..common.caching.caching_service import _to_dt, cache +from ..common.utils.geodata import _gdf_to_ru_fc from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, @@ -770,23 +771,21 @@ async def territory_transformation_scenario( ): method_name = "territory_transformation" - # ───── получаем info, вытаскиваем updated_at и is_based ───── info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) - updated_at = info["updated_at"] # "2025-05-29T12:38:30Z" + updated_at = info["updated_at"] is_based = info["is_based"] + project_id = info["project"]["project_id"] phash = cache.params_hash(params.model_dump()) cached = cache.load(method_name, params.scenario_id, phash) - if cached: - cache_ts = _to_dt(cached["meta"]["timestamp"]) - scen_ts = _to_dt(updated_at) - - if cache_ts > scen_ts: - logger.info("cache hit — scenario older than cache") - return { - n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") - for n, fc in cached["data"].items() - } + if cached and cached["meta"]["scenario_updated_at"] == updated_at: + layer_set = cached["data"] + + target = layer_set["before"] if is_based else layer_set["after"] + return { + n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + for n, fc in target.items() + } logger.info("Cache stale: scenario updated; recalculating") @@ -821,6 +820,8 @@ async def territory_transformation_scenario( st_name = service_types.loc[st_id, "name"] _, demand, accessibility = service_types_config[st_name].values() prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdf.to_crs(4326) + prov_gdf = prov_gdf.drop(axis="columns", columns="provision_weak") prov_gdfs_before[st_name] = prov_gdf prov_totals = {} @@ -831,7 +832,23 @@ async def territory_transformation_scenario( total = float(provision_strong_total(prov_gdf)) prov_totals[st_name] = total - # provision after + if is_based: + prov_json = { + "before": { + name: _gdf_to_ru_fc(gdf) for name, gdf in prov_gdfs_before.items() + } + } + cache.save( + "territory_transformation", + params.scenario_id, + params.model_dump(), + prov_json, + scenario_updated_at=updated_at, + ) + + return prov_gdfs_before + + # provision after service_types["infrastructure_weight"] = ( service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) * service_types["infrastructure_weight"] @@ -903,13 +920,18 @@ async def territory_transformation_scenario( total = float(provision_strong_total(prov_gdf)) prov_totals[st_name] = total + # prov_json = { + # n: json.loads(gdf.to_json(drop_id=True)) + # for n, gdf in prov_gdfs_after.items() + # } + prov_json = { - n: json.loads(gdf.to_json(drop_id=True)) - for n, gdf in prov_gdfs_after.items() + "before": {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items()}, + "after": {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()}, } cache.save( - method_name, + "territory_transformation", params.scenario_id, params.model_dump(), prov_json, diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index a209c57..44949b5 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -25,7 +25,6 @@ async def _with_defaults( dto: ContextDevelopmentDTO, token: str ) -> ContextDevelopmentDTO: - """Вернуть DTO, в котором source/year уже выбраны функцией–помощником""" return await effects_service.get_optimal_func_zone_data(dto, token) @@ -35,24 +34,24 @@ async def create_task( params: Annotated[ContextDevelopmentDTO, Depends()], token: str = Depends(verify_token), ): - if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) - # 1) подставляем дефолты → получаем «финальные» параметры params_filled = await effects_service.get_optimal_func_zone_data(params, token) phash = cache.params_hash(params_filled.model_dump()) - task_id = f"{method}_{params_filled.scenario_id}_{phash}" - cached = cache.load(method, params_filled.scenario_id, phash) - if cached: + if cache.load(method, params_filled.scenario_id, phash): return {"task_id": task_id, "status": "done"} - # 3) создаём задачу с уже заполненным DTO + existing = _task_map.get(task_id) + if existing and existing.status in {"queued", "running"}: + return {"task_id": task_id, "status": existing.status} + task = AnyTask(method, params_filled.scenario_id, token, params_filled) _task_map[task_id] = task await _task_queue.put(task) + return {"task_id": task_id, "status": "queued"} @@ -65,23 +64,30 @@ async def task_status(task_id: str): @router.get("/territory_transformation/{scenario_id}/{service_name}") -async def get_tt_layer(scenario_id: int, service_name: str): +async def get_tt_layer(scenario_id: int, service_name: str, is_based: bool = False): """ - Вернуть сохранённый FeatureCollection по сценарию и точному - названию сервиса (без какой-либо нормализации). + Returns FeatureCollections for requested service. + + If is_based = true (only «before») — return single FC. + If is_based = false — returns FC from cached `{"before": …, "after": …}`. """ - # 1. самый свежий файл для данного сценария cached = cache.load_latest("territory_transformation", scenario_id) if not cached: raise http_exception(404, "no cached result for this scenario", scenario_id) - fcoll = cached["data"].get(service_name) - if not fcoll: - raise http_exception( - 404, - f"service '{service_name}' not found", - _detail={"available": list(cached["data"])}, - ) + data: dict = cached["data"] + has_after = "after" in data + + if is_based or not has_after: + fcoll = data["before"].get(service_name) + if not fcoll: + raise http_exception(404, f"service '{service_name}' not found") + return JSONResponse(content=fcoll) + + fc_before = data["before"].get(service_name) + fc_after = data["after"].get(service_name) + + if not (fc_before and fc_after): + raise http_exception(404, f"service '{service_name}' not found") - # 3. отдаём FeatureCollection-словарь - return JSONResponse(content=fcoll) + return JSONResponse(content={"before": fc_before, "after": fc_after}) From 624b885667bb26b054020ecf1980a5497efb498d Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Thu, 10 Jul 2025 20:53:24 +0300 Subject: [PATCH 053/161] fix(tmp_commit_6): main logic fixes - provision before is now evaluating for base scenario, returning blocks from context + base scenario - need to be fixed: cache usage, provision after returning with no project territory, only context --- app/common/utils/geodata.py | 4 + app/effects_api/effects_controller.py | 2 +- app/effects_api/effects_service.py | 894 +++++++++++++++--------- app/effects_api/modules/task_service.py | 2 +- app/effects_api/tasks_controller.py | 20 +- app/gateways/urban_api_gateway.py | 11 + 6 files changed, 573 insertions(+), 360 deletions(-) diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 2b8b066..1758a0a 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -12,3 +12,7 @@ def _gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: columns={k: v for k, v in COL_RU.items() if k in gdf.columns}, errors="raise" ) return json.loads(gdf.to_crs(4326).to_json(drop_id=True)) + + +def _fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: + return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 86e6223..0e5e670 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -63,7 +63,7 @@ async def territory_transformation( params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], token: str = Depends(verify_token), ): - gdf = await effects_service.territory_transformation_scenario(token, params) + gdf = await effects_service.territory_transformation_scenario_before(token, params) gdf = gdf.to_crs(4326) geojson_dict = json.loads(gdf.to_json(drop_id=True)) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 407dcd1..e6dbd58 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,4 +1,5 @@ import json +from typing import Any, Literal import geopandas as gpd import pandas as pd @@ -37,7 +38,8 @@ from app.effects_api.modules.service_type_service import adapt_service_types from ..common.caching.caching_service import _to_dt, cache -from ..common.utils.geodata import _gdf_to_ru_fc +from ..common.exceptions.http_exception_wrapper import http_exception +from ..common.utils.geodata import _fc_to_gdf, _gdf_to_ru_fc from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, @@ -67,6 +69,22 @@ def __init__( self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() + async def _make_params_for_hash( + self, + params: ContextDevelopmentDTO | DevelopmentDTO, + base_scen_id: int, + token: str, + ) -> dict: + base_src, base_year = ( + await urban_api_gateway.get_optimal_func_zone_request_data( + token, base_scen_id, None, None + ) + ) + return params.model_dump() | { + "base_func_zone_source": base_src, + "base_func_zone_year": base_year, + } + @staticmethod async def get_optimal_func_zone_data( params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO, @@ -112,213 +130,407 @@ async def get_optimal_func_zone_data( return params return params + # @staticmethod + # async def aggregate_blocks_layer_scenario( + # scenario_id: int, + # functional_zone_source: str = None, + # functional_zone_year: int = None, + # token: str = None, + # ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + # """ + # Params: + # scenario_id : int + # ID of the scenario whose blocks are processed. + # functional_zone_source : str | None, default None + # Preferred source of functional-zone polygons + # (e.g. "PZZ", "OSM", "User"). + # If None, the helper picks the best available source (PZZ). + # functional_zone_year : int | None, default None + # Year of the functional-zone dataset. `None` → latest available. + # token : str | None, default None + # Optional bearer token passed to Urban API. + # + # Returns: + # tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] + # blocks_gdf– scenario blocks enriched with: + # site_area, land-use shares, building counts and per-service + # capacities/ counts. + # buildings_gdf – original (optionally projected) buildings + # used for aggregation.""" + # + # logger.info(f"Aggregating unified blocks layer for scenario {scenario_id}") + # + # logger.info("Starting generating scenario blocks layer") + # scenario_blocks_gdf = await get_scenario_blocks(scenario_id, token) + # scenario_blocks_crs = scenario_blocks_gdf.crs + # scenario_blocks_gdf["site_area"] = scenario_blocks_gdf.area + # + # scenario_functional_zones = await get_scenario_functional_zones( + # scenario_id, token, functional_zone_source, functional_zone_year + # ) + # scenario_functional_zones = scenario_functional_zones.to_crs( + # scenario_blocks_crs + # ) + # scenario_blocks_lu = assign_land_use( + # scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES + # ) + # scenario_blocks_gdf = scenario_blocks_gdf.join( + # scenario_blocks_lu.drop(columns=["geometry"]) + # ) + # logger.success(f"Land use for scenario blocks have been assigned {scenario_id}") + # + # scenario_buildings_gdf = await get_scenario_buildings(scenario_id, token) + # if scenario_buildings_gdf is not None: + # scenario_buildings_gdf = scenario_buildings_gdf.to_crs( + # scenario_blocks_gdf.crs + # ) + # blocks_buildings, _ = aggregate_objects( + # scenario_blocks_gdf, scenario_buildings_gdf + # ) + # scenario_blocks_gdf = scenario_blocks_gdf.join( + # blocks_buildings.drop(columns=["geometry"]).rename( + # columns={"count": "count_buildings"} + # ) + # ) + # scenario_blocks_gdf["count_buildings"] = ( + # scenario_blocks_gdf["count_buildings"].fillna(0).astype(int) + # ) + # if "is_living" not in scenario_blocks_gdf.columns: + # ( + # scenario_blocks_gdf["count_buildings"], + # scenario_blocks_gdf["is_living"], + # ) = (0, None) + # + # logger.success( + # f"Buildings for scenario blocks have been aggregated {scenario_id}" + # ) + # + # service_types = await urban_api_gateway.get_service_types() + # service_types = await adapt_service_types(service_types) + # + # scenario_services_dict = await get_scenario_services( + # scenario_id, service_types, token + # ) + # + # for service_type, services in scenario_services_dict.items(): + # services = services.to_crs(scenario_blocks_gdf.crs) + # scenario_blocks_services, _ = aggregate_objects( + # scenario_blocks_gdf, services + # ) + # scenario_blocks_services["capacity"] = ( + # scenario_blocks_services["capacity"].fillna(0).astype(int) + # ) + # scenario_blocks_services["count"] = ( + # scenario_blocks_services["count"].fillna(0).astype(int) + # ) + # scenario_blocks_gdf = scenario_blocks_gdf.join( + # scenario_blocks_services.drop(columns=["geometry"]).rename( + # columns={ + # "capacity": f"capacity_{service_type}", + # "count": f"count_{service_type}", + # } + # ) + # ) + # + # scenario_blocks_gdf["is_project"] = True + # logger.success( + # f"Services for scenario blocks have been aggregated {scenario_id}" + # ) + # + # return scenario_blocks_gdf, scenario_buildings_gdf @staticmethod - async def aggregate_blocks_layer_scenario( + async def load_blocks_scenario(scenario_id: int, token: str) -> gpd.GeoDataFrame: + gdf = await get_scenario_blocks(scenario_id, token) + gdf["site_area"] = gdf.area + return gdf + + @staticmethod + async def assign_land_use_to_blocks_scenario( + blocks: gpd.GeoDataFrame, scenario_id: int, - functional_zone_source: str = None, - functional_zone_year: int = None, - token: str = None, - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: - """ - Params: - scenario_id : int - ID of the scenario whose blocks are processed. - functional_zone_source : str | None, default None - Preferred source of functional-zone polygons - (e.g. "PZZ", "OSM", "User"). - If None, the helper picks the best available source (PZZ). - functional_zone_year : int | None, default None - Year of the functional-zone dataset. `None` → latest available. - token : str | None, default None - Optional bearer token passed to Urban API. + source: str | None, + year: int | None, + token: str, + ) -> gpd.GeoDataFrame: - Returns: - tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] - blocks_gdf– scenario blocks enriched with: - site_area, land-use shares, building counts and per-service - capacities/ counts. - buildings_gdf – original (optionally projected) buildings - used for aggregation.""" + fzones = await get_scenario_functional_zones(scenario_id, token, source, year) + fzones = fzones.to_crs(blocks.crs) - logger.info(f"Aggregating unified blocks layer for scenario {scenario_id}") + lu = assign_land_use(blocks, fzones, LAND_USE_RULES) + return blocks.join(lu.drop(columns=["geometry"])) - logger.info("Starting generating scenario blocks layer") - scenario_blocks_gdf = await get_scenario_blocks(scenario_id, token) - scenario_blocks_crs = scenario_blocks_gdf.crs - scenario_blocks_gdf["site_area"] = scenario_blocks_gdf.area + @staticmethod + async def enrich_with_buildings_scenario( + blocks: gpd.GeoDataFrame, scenario_id: int, token: str + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - scenario_functional_zones = await get_scenario_functional_zones( - scenario_id, token, functional_zone_source, functional_zone_year - ) - scenario_functional_zones = scenario_functional_zones.to_crs( - scenario_blocks_crs - ) - scenario_blocks_lu = assign_land_use( - scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES - ) - scenario_blocks_gdf = scenario_blocks_gdf.join( - scenario_blocks_lu.drop(columns=["geometry"]) - ) - logger.success(f"Land use for scenario blocks have been assigned {scenario_id}") + buildings = await get_scenario_buildings(scenario_id, token) + if buildings is None: + blocks["count_buildings"] = 0 + return blocks, None - scenario_buildings_gdf = await get_scenario_buildings(scenario_id, token) - if scenario_buildings_gdf is not None: - scenario_buildings_gdf = scenario_buildings_gdf.to_crs( - scenario_blocks_gdf.crs - ) - blocks_buildings, _ = aggregate_objects( - scenario_blocks_gdf, scenario_buildings_gdf - ) - scenario_blocks_gdf = scenario_blocks_gdf.join( - blocks_buildings.drop(columns=["geometry"]).rename( - columns={"count": "count_buildings"} - ) - ) - scenario_blocks_gdf["count_buildings"] = ( - scenario_blocks_gdf["count_buildings"].fillna(0).astype(int) - ) - if "is_living" not in scenario_blocks_gdf.columns: - ( - scenario_blocks_gdf["count_buildings"], - scenario_blocks_gdf["is_living"], - ) = (0, None) + buildings = buildings.to_crs(blocks.crs) + blocks_bld, _ = aggregate_objects(blocks, buildings) - logger.success( - f"Buildings for scenario blocks have been aggregated {scenario_id}" + blocks = blocks.join( + blocks_bld.drop(columns=["geometry"]).rename( + columns={"count": "count_buildings"} + ) ) + blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) + if "is_living" not in blocks.columns: + blocks["is_living"] = None - service_types = await urban_api_gateway.get_service_types() - service_types = await adapt_service_types(service_types) + return blocks, buildings - scenario_services_dict = await get_scenario_services( - scenario_id, service_types, token - ) + @staticmethod + async def enrich_with_services_scenario( + blocks: gpd.GeoDataFrame, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: - for service_type, services in scenario_services_dict.items(): - services = services.to_crs(scenario_blocks_gdf.crs) - scenario_blocks_services, _ = aggregate_objects( - scenario_blocks_gdf, services - ) - scenario_blocks_services["capacity"] = ( - scenario_blocks_services["capacity"].fillna(0).astype(int) - ) - scenario_blocks_services["count"] = ( - scenario_blocks_services["count"].fillna(0).astype(int) + stypes = await urban_api_gateway.get_service_types() + stypes = await adapt_service_types(stypes) + sdict = await get_scenario_services(scenario_id, stypes, token) + + for stype, services in sdict.items(): + services = services.to_crs(blocks.crs) + b_srv, _ = aggregate_objects(blocks, services) + b_srv[["capacity", "count"]] = ( + b_srv[["capacity", "count"]].fillna(0).astype(int) ) - scenario_blocks_gdf = scenario_blocks_gdf.join( - scenario_blocks_services.drop(columns=["geometry"]).rename( + blocks = blocks.join( + b_srv.drop(columns=["geometry"]).rename( columns={ - "capacity": f"capacity_{service_type}", - "count": f"count_{service_type}", + "capacity": f"capacity_{stype}", + "count": f"count_{stype}", } ) ) + return blocks - scenario_blocks_gdf["is_project"] = True - logger.success( - f"Services for scenario blocks have been aggregated {scenario_id}" + async def aggregate_blocks_layer_scenario( + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: + + logger.info(f"[scenario {scenario_id}] ▶ load blocks") + blocks = await self.load_blocks_scenario(scenario_id, token) + + logger.info("land-use") + blocks = await self.assign_land_use_to_blocks_scenario( + blocks, scenario_id, source, year, token ) - return scenario_blocks_gdf, scenario_buildings_gdf + logger.info("buildings") + blocks, buildings = await self.enrich_with_buildings_scenario( + blocks, scenario_id, token + ) - @staticmethod - async def aggregate_blocks_layer_context( - scenario_id: int, - context_functional_zone_source: str = None, - context_functional_zone_year: int = None, - token: str = None, - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: - """Build a GeoDataFrame for context blocks (territories neighbouring the - project) and enrich it with land-use, building and service attributes. + logger.info("services") + blocks = await self.enrich_with_services_scenario(blocks, scenario_id, token) - Params: - scenario_id : int - Parent scenario (used only to fetch project ID). - context_functional_zone_source : str | None, default None - Functional-zone source for context territories. - context_functional_zone_year : int | None, default None - Year of the functional-zone dataset for context territories. - token : str | None, default None - Optional bearer token for Urban API. + blocks["is_project"] = True + logger.success(f"[scenario {scenario_id}] blocks layer ready") - Returns: - tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] - context_blocks_gdf – enriched context blocks. - context_buildings_gdf – buildings aggregated into the blocks - """ + return blocks, buildings - logger.info("Starting generating context blocks layer") + @staticmethod + async def load_context_blocks( + scenario_id: int, token: str + ) -> tuple[gpd.GeoDataFrame, int]: project_id = await urban_api_gateway.get_project_id(scenario_id, token) - context_blocks_gdf = await get_context_blocks(project_id) - context_blocks_crs = context_blocks_gdf.crs - context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) - context_blocks_gdf["site_area"] = context_blocks_gdf.area - - context_functional_zones = await get_context_functional_zones( - project_id, - context_functional_zone_source, - context_functional_zone_year, - token, - ) - context_functional_zones = context_functional_zones.to_crs(context_blocks_crs) - context_blocks_lu = assign_land_use( - context_blocks_gdf, context_functional_zones, LAND_USE_RULES - ) - context_blocks_gdf = context_blocks_gdf.join( - context_blocks_lu.drop(columns=["geometry"]) - ) - logger.success(f"Land use for context blocks have been assigned {scenario_id}") + blocks = await get_context_blocks(project_id) + blocks["site_area"] = blocks.area + return blocks, project_id + + @staticmethod + async def assign_land_use_context( + blocks: gpd.GeoDataFrame, + project_id: int, + source: str | None, + year: int | None, + token: str, + ) -> gpd.GeoDataFrame: + fzones = await get_context_functional_zones(project_id, source, year, token) + fzones = fzones.to_crs(blocks.crs) + lu = assign_land_use(blocks, fzones, LAND_USE_RULES) + return blocks.join(lu.drop(columns=["geometry"])) - context_buildings_gdf = await get_context_buildings(project_id) + @staticmethod + async def enrich_with_context_buildings( + blocks: gpd.GeoDataFrame, project_id: int + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - if context_buildings_gdf is not None: - context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) - context_blocks_buildings, _ = aggregate_objects( - context_blocks_gdf, context_buildings_gdf - ) - context_blocks_gdf = context_blocks_gdf.join( - context_blocks_buildings.drop(columns=["geometry"]).rename( - columns={"count": "count_buildings"} - ) - ) - context_blocks_gdf["count_buildings"] = ( - context_blocks_gdf["count_buildings"].fillna(0).astype(int) - ) - if "is_living" not in context_blocks_gdf.columns: - ( - context_blocks_gdf["count_buildings"], - context_blocks_gdf["is_living"], - ) = (0, None) - logger.success( - f"Buildings for context blocks have been aggregated {scenario_id}" + buildings = await get_context_buildings(project_id) + if buildings is None: + blocks["count_buildings"] = 0 + blocks["is_living"] = None + return blocks, None + + buildings = buildings.to_crs(blocks.crs) + agg, _ = aggregate_objects(blocks, buildings) + + blocks = blocks.join( + agg.drop(columns=["geometry"]).rename(columns={"count": "count_buildings"}) ) + blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) + if "is_living" not in blocks.columns: + blocks["is_living"] = None - service_types = await urban_api_gateway.get_service_types() - service_types = await adapt_service_types(service_types) - context_services_dict = await get_context_services(project_id, service_types) + return blocks, buildings - for service_type, services in context_services_dict.items(): - services = services.to_crs(context_blocks_gdf.crs) - context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) - context_blocks_services["capacity"] = ( - context_blocks_services["capacity"].fillna(0).astype(int) - ) - context_blocks_services["count"] = ( - context_blocks_services["count"].fillna(0).astype(int) + @staticmethod + async def enrich_with_context_services( + blocks: gpd.GeoDataFrame, project_id: int, token: str + ) -> gpd.GeoDataFrame: + + stypes = await urban_api_gateway.get_service_types() + stypes = await adapt_service_types(stypes) + sdict = await get_context_services(project_id, stypes) + + for stype, services in sdict.items(): + services = services.to_crs(blocks.crs) + b_srv, _ = aggregate_objects(blocks, services) + b_srv[["capacity", "count"]] = ( + b_srv[["capacity", "count"]].fillna(0).astype(int) ) - context_blocks_gdf = context_blocks_gdf.join( - context_blocks_services.drop(columns=["geometry"]).rename( + + blocks = blocks.join( + b_srv.drop(columns=["geometry"]).rename( columns={ - "capacity": f"capacity_{service_type}", - "count": f"count_{service_type}", + "capacity": f"capacity_{stype}", + "count": f"count_{stype}", } ) ) - logger.success( - f"Services for context blocks have been aggregated {scenario_id}" + return blocks + + async def aggregate_blocks_layer_context( + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: + + logger.info(f"[context {scenario_id}] ▶ load blocks") + blocks, project_id = await self.load_context_blocks(scenario_id, token) + + logger.info("▶ land-use") + blocks = await self.assign_land_use_context( + blocks, project_id, source, year, token ) - return context_blocks_gdf, context_buildings_gdf + logger.info("▶ buildings") + blocks, buildings = await self.enrich_with_context_buildings(blocks, project_id) + + logger.info("▶ services") + blocks = await self.enrich_with_context_services(blocks, project_id, token) + + logger.success(f"[context {scenario_id}] blocks layer ready") + return blocks, buildings + + # @staticmethod + # async def aggregate_blocks_layer_context( + # scenario_id: int, + # context_functional_zone_source: str = None, + # context_functional_zone_year: int = None, + # token: str = None, + # ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: + # """Build a GeoDataFrame for context blocks (territories neighbouring the + # project) and enrich it with land-use, building and service attributes. + # + # Params: + # scenario_id : int + # Parent scenario (used only to fetch project ID). + # context_functional_zone_source : str | None, default None + # Functional-zone source for context territories. + # context_functional_zone_year : int | None, default None + # Year of the functional-zone dataset for context territories. + # token : str | None, default None + # Optional bearer token for Urban API. + # + # Returns: + # tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] + # context_blocks_gdf – enriched context blocks. + # context_buildings_gdf – buildings aggregated into the blocks + # """ + # + # logger.info("Starting generating context blocks layer") + # project_id = await urban_api_gateway.get_project_id(scenario_id, token) + # context_blocks_gdf = await get_context_blocks(project_id) + # context_blocks_crs = context_blocks_gdf.crs + # context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) + # context_blocks_gdf["site_area"] = context_blocks_gdf.area + # + # context_functional_zones = await get_context_functional_zones( + # project_id, + # context_functional_zone_source, + # context_functional_zone_year, + # token, + # ) + # context_functional_zones = context_functional_zones.to_crs(context_blocks_crs) + # context_blocks_lu = assign_land_use( + # context_blocks_gdf, context_functional_zones, LAND_USE_RULES + # ) + # context_blocks_gdf = context_blocks_gdf.join( + # context_blocks_lu.drop(columns=["geometry"]) + # ) + # logger.success(f"Land use for context blocks have been assigned {scenario_id}") + # + # context_buildings_gdf = await get_context_buildings(project_id) + # + # if context_buildings_gdf is not None: + # context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) + # context_blocks_buildings, _ = aggregate_objects( + # context_blocks_gdf, context_buildings_gdf + # ) + # context_blocks_gdf = context_blocks_gdf.join( + # context_blocks_buildings.drop(columns=["geometry"]).rename( + # columns={"count": "count_buildings"} + # ) + # ) + # context_blocks_gdf["count_buildings"] = ( + # context_blocks_gdf["count_buildings"].fillna(0).astype(int) + # ) + # if "is_living" not in context_blocks_gdf.columns: + # ( + # context_blocks_gdf["count_buildings"], + # context_blocks_gdf["is_living"], + # ) = (0, None) + # logger.success( + # f"Buildings for context blocks have been aggregated {scenario_id}" + # ) + # + # service_types = await urban_api_gateway.get_service_types() + # service_types = await adapt_service_types(service_types) + # context_services_dict = await get_context_services(project_id, service_types) + # + # for service_type, services in context_services_dict.items(): + # services = services.to_crs(context_blocks_gdf.crs) + # context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) + # context_blocks_services["capacity"] = ( + # context_blocks_services["capacity"].fillna(0).astype(int) + # ) + # context_blocks_services["count"] = ( + # context_blocks_services["count"].fillna(0).astype(int) + # ) + # context_blocks_gdf = context_blocks_gdf.join( + # context_blocks_services.drop(columns=["geometry"]).rename( + # columns={ + # "capacity": f"capacity_{service_type}", + # "count": f"count_{service_type}", + # } + # ) + # ) + # logger.success( + # f"Services for context blocks have been aggregated {scenario_id}" + # ) + # + # return context_blocks_gdf, context_buildings_gdf @staticmethod async def get_services_layer(scenario_id: int, token: str): @@ -621,7 +833,7 @@ async def _get_accessibility_context( self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float ) -> list[int]: blocks["population"] = blocks["population"].fillna(0) - project_blocks = blocks[blocks["is_project"]].copy() + project_blocks = blocks.copy() context_blocks = get_accessibility_context( acc_mx, project_blocks, accessibility, out=False, keep=True ) @@ -647,147 +859,39 @@ async def _assess_provision( prov_df = prov_df.loc[context_ids].copy() return blocks[["geometry"]].join(prov_df, how="right") - # async def territory_transformation_scenario( - # self, token: str, params: TerritoryTransformationDTO - # ): - # service_types = await urban_api_gateway.get_service_types() - # service_types = await adapt_service_types(service_types) - # service_types = service_types[ - # ~service_types["infrastructure_type"].isna() - # ].copy() - # - # params = await self.get_optimal_func_zone_data(params, token) - # context_blocks, context_buildings = await self.aggregate_blocks_layer_context( - # params.scenario_id, - # params.context_func_zone_source, - # params.context_func_source_year, - # token, - # ) - # scenario_blocks, scenario_buildings = ( - # await self.aggregate_blocks_layer_scenario( - # params.scenario_id, - # params.proj_func_zone_source, - # params.proj_func_source_year, - # token, - # ) - # ) - # blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) - # graph = get_accessibility_graph(blocks, "intermodal") - # acc_mx = calculate_accessibility_matrix(blocks, graph) - # prov_gdfs = {} - # for st_id in service_types.index: - # st_name = service_types.loc[st_id, "name"] - # _, demand, accessibility = service_types_config[st_name].values() - # prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - # prov_gdfs[st_name] = prov_gdf - # - # prov_totals = {} - # for st_name, prov_gdf in prov_gdfs.items(): - # if prov_gdf.demand.sum() == 0: - # total = None - # else: - # total = float(provision_strong_total(prov_gdf)) - # prov_totals[st_name] = total - # - # # provision after - # service_types["infrastructure_weight"] = ( - # service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - # * service_types["infrastructure_weight"] - # ) - # blocks_lus = blocks.loc[blocks["is_project"], "land_use"] - # blocks_lus = blocks_lus[~blocks_lus.isna()] - # blocks_lus = blocks_lus.to_dict() - # - # var_adapter = AreaSolution(blocks_lus) - # - # facade = Facade( - # blocks_lu=blocks_lus, - # blocks_df=blocks, - # accessibility_matrix=acc_mx, - # var_adapter=var_adapter, - # ) - # - # for st_id, row in service_types.iterrows(): - # st_name = row["name"] - # st_weight = row["infrastructure_weight"] - # st_column = f"capacity_{st_name}" - # if st_column in blocks.columns: - # df = blocks.rename(columns={st_column: "capacity"})[ - # ["capacity"] - # ].fillna(0) - # else: - # logger.info( - # f"#{st_id}:{st_name} нет на территории контекста проекта. Добавляем нулевой датафрейм" - # ) - # df = blocks[[]].copy() - # df["capacity"] = 0 - # facade.add_service_type(st_name, st_weight, df) - # - # services_weights = service_types.set_index("name")[ - # "infrastructure_weight" - # ].to_dict() - # - # objective = WeightedObjective( - # num_params=facade.num_params, - # facade=facade, - # weights=services_weights, - # max_evals=50, - # ) - # - # constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) - # - # tpe_optimizer = TPEOptimizer( - # objective=objective, - # constraints=constraints, - # vars_chooser=SimpleChooser(facade), - # ) - # - # best_x, best_val, perc, func_evals = tpe_optimizer.run( - # max_runs=50, timeout=60000, initial_runs_num=1 - # ) - # - # prov_gdfs = {} - # for st_id in service_types.index: - # st_name = service_types.loc[st_id, "name"] - # if st_name in facade._chosen_service_types: - # prov_df = facade._provision_adapter.get_last_provision_df(st_name) - # prov_gdf = blocks[["geometry"]].join(prov_df, how="right") - # prov_gdfs[st_name] = prov_gdf - # - # prov_totals = {} - # for st_name, prov_gdf in prov_gdfs.items(): - # if prov_gdf.demand.sum() == 0: - # total = None - # else: - # total = float(provision_strong_total(prov_gdf)) - # prov_totals[st_name] = total - # - # response = prov_gdfs[params.required_service] - # logger.info("123") - # return response - - async def territory_transformation_scenario( + async def territory_transformation_scenario_before( self, token: str, params: ContextDevelopmentDTO ): method_name = "territory_transformation" info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] - is_based = info["is_based"] project_id = info["project"]["project_id"] + base_scenario_id = await urban_api_gateway.get_base_scenario_id(project_id) - phash = cache.params_hash(params.model_dump()) - cached = cache.load(method_name, params.scenario_id, phash) - if cached and cached["meta"]["scenario_updated_at"] == updated_at: - layer_set = cached["data"] + params = await self.get_optimal_func_zone_data(params, token) - target = layer_set["before"] if is_based else layer_set["after"] - return { - n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") - for n, fc in target.items() - } + base_src, base_year = ( + await urban_api_gateway.get_optimal_func_zone_request_data( + token, base_scenario_id, None, None + ) + ) - logger.info("Cache stale: scenario updated; recalculating") + params_for_hash = params.model_dump() | { + "base_func_zone_source": base_src, + "base_func_zone_year": base_year, + } + phash = cache.params_hash(params_for_hash) + + cached = cache.load(method_name, params.scenario_id, phash) + if ( + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "before" in cached["data"] + ): + return {n: _fc_to_gdf(fc) for n, fc in cached["data"]["before"].items()} + + logger.info("Cache stale or missing: recalculating BEFORE") service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) @@ -795,31 +899,38 @@ async def territory_transformation_scenario( ~service_types["infrastructure_type"].isna() ].copy() + # вот тут проблема что ource и year функциональных зон выбираются для сценария, который был подан, а затем пытаются примениться для базового сценария params = await self.get_optimal_func_zone_data(params, token) + base_src, base_year = ( + await urban_api_gateway.get_optimal_func_zone_request_data( + token, base_scenario_id, None, None + ) + ) + context_blocks, context_buildings = await self.aggregate_blocks_layer_context( params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token, ) - scenario_blocks, scenario_buildings = ( + + base_scenario_blocks, base_scenario_buildings = ( await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, + base_scenario_id, base_src, base_year, token ) ) - blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) - graph = get_accessibility_graph(blocks, "intermodal") - acc_mx = calculate_accessibility_matrix(blocks, graph) - # provision before + before_blocks = pd.concat([context_blocks, base_scenario_blocks]).reset_index( + drop=True + ) + graph = get_accessibility_graph(before_blocks, "intermodal") + acc_mx = calculate_accessibility_matrix(before_blocks, graph) + prov_gdfs_before = {} for st_id in service_types.index: st_name = service_types.loc[st_id, "name"] _, demand, accessibility = service_types_config[st_name].values() - prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = await self._assess_provision(before_blocks, acc_mx, st_name) prov_gdf = prov_gdf.to_crs(4326) prov_gdf = prov_gdf.drop(axis="columns", columns="provision_weak") prov_gdfs_before[st_name] = prov_gdf @@ -832,28 +943,86 @@ async def territory_transformation_scenario( total = float(provision_strong_total(prov_gdf)) prov_totals[st_name] = total + existing_data = cached["data"] if cached else {} + existing_data["before"] = { + n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items() + } + + cache.save( + method_name, + params.scenario_id, + params_for_hash, + existing_data, + scenario_updated_at=updated_at, + ) + + return prov_gdfs_before + + async def territory_transformation_scenario_after( + self, token, params: ContextDevelopmentDTO + ): + # provision after + method_name = "territory_transformation" + + info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + updated_at = info["updated_at"] + is_based = info["is_based"] + if is_based: - prov_json = { - "before": { - name: _gdf_to_ru_fc(gdf) for name, gdf in prov_gdfs_before.items() - } + raise http_exception(400, "base scenario has no 'after' layer") + + params = await self.get_optimal_func_zone_data(params, token) + + phash = cache.params_hash(params.model_dump()) + + cached = cache.load(method_name, params.scenario_id, phash) + if ( + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "after" in cached["data"] + ): + return { + n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + for n, fc in cached["data"]["after"].items() } - cache.save( - "territory_transformation", - params.scenario_id, - params.model_dump(), - prov_json, - scenario_updated_at=updated_at, - ) - return prov_gdfs_before + logger.info("AFTER: cache stale or missing; recalculating") + + service_types = await urban_api_gateway.get_service_types() + service_types = await adapt_service_types(service_types) + service_types = service_types[ + ~service_types["infrastructure_type"].isna() + ].copy() + + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, + ) + + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) + + after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( + drop=True + ) + + after_blocks["is_project"] = ( + after_blocks["is_project"].fillna(False).astype(bool) + ) + graph = get_accessibility_graph(after_blocks, "intermodal") + acc_mx = calculate_accessibility_matrix(after_blocks, graph) - # provision after service_types["infrastructure_weight"] = ( service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) * service_types["infrastructure_weight"] ) - blocks_lus = blocks.loc[blocks["is_project"], "land_use"] + blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] blocks_lus = blocks_lus[~blocks_lus.isna()] blocks_lus = blocks_lus.to_dict() @@ -861,7 +1030,7 @@ async def territory_transformation_scenario( facade = Facade( blocks_lu=blocks_lus, - blocks_df=blocks, + blocks_df=after_blocks, accessibility_matrix=acc_mx, var_adapter=var_adapter, ) @@ -870,15 +1039,15 @@ async def territory_transformation_scenario( st_name = row["name"] st_weight = row["infrastructure_weight"] st_column = f"capacity_{st_name}" - if st_column in blocks.columns: - df = blocks.rename(columns={st_column: "capacity"})[ + if st_column in after_blocks.columns: + df = after_blocks.rename(columns={st_column: "capacity"})[ ["capacity"] ].fillna(0) else: logger.info( f"#{st_id}:{st_name} does not exist on territory. Adding empty GeoDataFrame" ) - df = blocks[[]].copy() + df = after_blocks[[]].copy() df["capacity"] = 0 facade.add_service_type(st_name, st_weight, df) @@ -908,8 +1077,14 @@ async def territory_transformation_scenario( st_name = service_types.loc[st_id, "name"] if st_name in facade._chosen_service_types: prov_df = facade._provision_adapter.get_last_provision_df(st_name) - prov_gdf = blocks[["geometry"]].join(prov_df, how="right") - prov_gdf = prov_gdf.to_crs(4326) + # prov_gdf = scenario_blocks[["geometry"]].join(prov_df, how="right") + # prov_gdf = prov_gdf.to_crs(4326) + prov_gdf = ( + context_blocks[["geometry"]] + .concat(prov_df, how="outer") + .to_crs(4326) + .drop(columns="provision_weak", errors="ignore") + ) prov_gdfs_after[st_name] = prov_gdf prov_totals = {} @@ -920,25 +1095,56 @@ async def territory_transformation_scenario( total = float(provision_strong_total(prov_gdf)) prov_totals[st_name] = total - # prov_json = { - # n: json.loads(gdf.to_json(drop_id=True)) - # for n, gdf in prov_gdfs_after.items() - # } - - prov_json = { - "before": {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items()}, - "after": {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()}, - } + from_cache = cached["data"] if cached else {} + from_cache["after"] = {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()} cache.save( - "territory_transformation", + method_name, params.scenario_id, params.model_dump(), - prov_json, + from_cache, scenario_updated_at=updated_at, ) return prov_gdfs_after + async def territory_transformation( + self, + token: str, + params: ContextDevelopmentDTO, + ) -> dict[str, Any] | dict[str, dict[str, Any]]: + + info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + is_based = info["is_based"] + updated_at = info["updated_at"] + project_id = info["project"]["project_id"] + base_scenario_id = await urban_api_gateway.get_base_scenario_id(project_id) + + prov_before = await self.territory_transformation_scenario_before(token, params) + + if is_based: + return prov_before + + params_for_hash = await self._make_params_for_hash( + params, base_scenario_id, token + ) + phash = cache.params_hash(params_for_hash) + cached = cache.load("territory_transformation", params.scenario_id, phash) + + if ( + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "after" in cached["data"] + ): + prov_after = { + n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + for n, fc in cached["data"]["after"].items() + } + return {"before": prov_before, "after": prov_after} + + prov_after = await self.territory_transformation_scenario_after(token, params) + + return {"before": prov_before, "after": prov_after} + effects_service = EffectsService() diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index b30e3ad..0b63520 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -16,7 +16,7 @@ MethodFunc = Callable[[str, Any], "dict[str, Any]"] TASK_METHODS: dict[str, MethodFunc] = { - "territory_transformation": effects_service.territory_transformation_scenario, + "territory_transformation": effects_service.territory_transformation, } _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 44949b5..6fb5325 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -64,29 +64,21 @@ async def task_status(task_id: str): @router.get("/territory_transformation/{scenario_id}/{service_name}") -async def get_tt_layer(scenario_id: int, service_name: str, is_based: bool = False): - """ - Returns FeatureCollections for requested service. - - If is_based = true (only «before») — return single FC. - If is_based = false — returns FC from cached `{"before": …, "after": …}`. - """ +async def get_tt_layer(scenario_id: int, service_name: str): cached = cache.load_latest("territory_transformation", scenario_id) if not cached: - raise http_exception(404, "no cached result for this scenario", scenario_id) + raise http_exception(404, "no saved result for this scenario", scenario_id) data: dict = cached["data"] - has_after = "after" in data - if is_based or not has_after: - fcoll = data["before"].get(service_name) - if not fcoll: + if "after" not in data: + fc = data["before"].get(service_name) + if not fc: raise http_exception(404, f"service '{service_name}' not found") - return JSONResponse(content=fcoll) + return JSONResponse(content=fc) fc_before = data["before"].get(service_name) fc_after = data["after"].get(service_name) - if not (fc_before and fc_after): raise http_exception(404, f"service '{service_name}' not found") diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 1148486..2e5fc95 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -354,3 +354,14 @@ async def get_territory_geometry(self, territory_id: int): if isinstance(geom, dict): geom = json.dumps(geom) return shapely.from_geojson(geom) + + async def get_base_scenario_id(self, project_id: int) -> int: + scenarios = await self.json_handler.get( + f"/api/v1/projects/{project_id}/scenarios" + ) + + base = next((s for s in scenarios if s.get("is_based")), None) + if not base: + raise http_exception(404, "base scenario not found", project_id) + + return base["scenario_id"] From 9e5f12dcacefa400ea4ebadd226683698c8ca0a3 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Thu, 10 Jul 2025 23:15:24 +0300 Subject: [PATCH 054/161] fix(tmp_commit_7): main logic fixes - fixed: provision after returning with no project territory, only context --- app/effects_api/effects_service.py | 230 +---------------------------- 1 file changed, 8 insertions(+), 222 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index e6dbd58..46dfdc3 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -130,114 +130,6 @@ async def get_optimal_func_zone_data( return params return params - # @staticmethod - # async def aggregate_blocks_layer_scenario( - # scenario_id: int, - # functional_zone_source: str = None, - # functional_zone_year: int = None, - # token: str = None, - # ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: - # """ - # Params: - # scenario_id : int - # ID of the scenario whose blocks are processed. - # functional_zone_source : str | None, default None - # Preferred source of functional-zone polygons - # (e.g. "PZZ", "OSM", "User"). - # If None, the helper picks the best available source (PZZ). - # functional_zone_year : int | None, default None - # Year of the functional-zone dataset. `None` → latest available. - # token : str | None, default None - # Optional bearer token passed to Urban API. - # - # Returns: - # tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] - # blocks_gdf– scenario blocks enriched with: - # site_area, land-use shares, building counts and per-service - # capacities/ counts. - # buildings_gdf – original (optionally projected) buildings - # used for aggregation.""" - # - # logger.info(f"Aggregating unified blocks layer for scenario {scenario_id}") - # - # logger.info("Starting generating scenario blocks layer") - # scenario_blocks_gdf = await get_scenario_blocks(scenario_id, token) - # scenario_blocks_crs = scenario_blocks_gdf.crs - # scenario_blocks_gdf["site_area"] = scenario_blocks_gdf.area - # - # scenario_functional_zones = await get_scenario_functional_zones( - # scenario_id, token, functional_zone_source, functional_zone_year - # ) - # scenario_functional_zones = scenario_functional_zones.to_crs( - # scenario_blocks_crs - # ) - # scenario_blocks_lu = assign_land_use( - # scenario_blocks_gdf, scenario_functional_zones, LAND_USE_RULES - # ) - # scenario_blocks_gdf = scenario_blocks_gdf.join( - # scenario_blocks_lu.drop(columns=["geometry"]) - # ) - # logger.success(f"Land use for scenario blocks have been assigned {scenario_id}") - # - # scenario_buildings_gdf = await get_scenario_buildings(scenario_id, token) - # if scenario_buildings_gdf is not None: - # scenario_buildings_gdf = scenario_buildings_gdf.to_crs( - # scenario_blocks_gdf.crs - # ) - # blocks_buildings, _ = aggregate_objects( - # scenario_blocks_gdf, scenario_buildings_gdf - # ) - # scenario_blocks_gdf = scenario_blocks_gdf.join( - # blocks_buildings.drop(columns=["geometry"]).rename( - # columns={"count": "count_buildings"} - # ) - # ) - # scenario_blocks_gdf["count_buildings"] = ( - # scenario_blocks_gdf["count_buildings"].fillna(0).astype(int) - # ) - # if "is_living" not in scenario_blocks_gdf.columns: - # ( - # scenario_blocks_gdf["count_buildings"], - # scenario_blocks_gdf["is_living"], - # ) = (0, None) - # - # logger.success( - # f"Buildings for scenario blocks have been aggregated {scenario_id}" - # ) - # - # service_types = await urban_api_gateway.get_service_types() - # service_types = await adapt_service_types(service_types) - # - # scenario_services_dict = await get_scenario_services( - # scenario_id, service_types, token - # ) - # - # for service_type, services in scenario_services_dict.items(): - # services = services.to_crs(scenario_blocks_gdf.crs) - # scenario_blocks_services, _ = aggregate_objects( - # scenario_blocks_gdf, services - # ) - # scenario_blocks_services["capacity"] = ( - # scenario_blocks_services["capacity"].fillna(0).astype(int) - # ) - # scenario_blocks_services["count"] = ( - # scenario_blocks_services["count"].fillna(0).astype(int) - # ) - # scenario_blocks_gdf = scenario_blocks_gdf.join( - # scenario_blocks_services.drop(columns=["geometry"]).rename( - # columns={ - # "capacity": f"capacity_{service_type}", - # "count": f"count_{service_type}", - # } - # ) - # ) - # - # scenario_blocks_gdf["is_project"] = True - # logger.success( - # f"Services for scenario blocks have been aggregated {scenario_id}" - # ) - # - # return scenario_blocks_gdf, scenario_buildings_gdf @staticmethod async def load_blocks_scenario(scenario_id: int, token: str) -> gpd.GeoDataFrame: gdf = await get_scenario_blocks(scenario_id, token) @@ -433,105 +325,6 @@ async def aggregate_blocks_layer_context( logger.success(f"[context {scenario_id}] blocks layer ready") return blocks, buildings - # @staticmethod - # async def aggregate_blocks_layer_context( - # scenario_id: int, - # context_functional_zone_source: str = None, - # context_functional_zone_year: int = None, - # token: str = None, - # ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]: - # """Build a GeoDataFrame for context blocks (territories neighbouring the - # project) and enrich it with land-use, building and service attributes. - # - # Params: - # scenario_id : int - # Parent scenario (used only to fetch project ID). - # context_functional_zone_source : str | None, default None - # Functional-zone source for context territories. - # context_functional_zone_year : int | None, default None - # Year of the functional-zone dataset for context territories. - # token : str | None, default None - # Optional bearer token for Urban API. - # - # Returns: - # tuple[gpd.GeoDataFrame, gpd.GeoDataFrame] - # context_blocks_gdf – enriched context blocks. - # context_buildings_gdf – buildings aggregated into the blocks - # """ - # - # logger.info("Starting generating context blocks layer") - # project_id = await urban_api_gateway.get_project_id(scenario_id, token) - # context_blocks_gdf = await get_context_blocks(project_id) - # context_blocks_crs = context_blocks_gdf.crs - # context_blocks_gdf = context_blocks_gdf.to_crs(context_blocks_crs) - # context_blocks_gdf["site_area"] = context_blocks_gdf.area - # - # context_functional_zones = await get_context_functional_zones( - # project_id, - # context_functional_zone_source, - # context_functional_zone_year, - # token, - # ) - # context_functional_zones = context_functional_zones.to_crs(context_blocks_crs) - # context_blocks_lu = assign_land_use( - # context_blocks_gdf, context_functional_zones, LAND_USE_RULES - # ) - # context_blocks_gdf = context_blocks_gdf.join( - # context_blocks_lu.drop(columns=["geometry"]) - # ) - # logger.success(f"Land use for context blocks have been assigned {scenario_id}") - # - # context_buildings_gdf = await get_context_buildings(project_id) - # - # if context_buildings_gdf is not None: - # context_buildings_gdf = context_buildings_gdf.to_crs(context_blocks_gdf.crs) - # context_blocks_buildings, _ = aggregate_objects( - # context_blocks_gdf, context_buildings_gdf - # ) - # context_blocks_gdf = context_blocks_gdf.join( - # context_blocks_buildings.drop(columns=["geometry"]).rename( - # columns={"count": "count_buildings"} - # ) - # ) - # context_blocks_gdf["count_buildings"] = ( - # context_blocks_gdf["count_buildings"].fillna(0).astype(int) - # ) - # if "is_living" not in context_blocks_gdf.columns: - # ( - # context_blocks_gdf["count_buildings"], - # context_blocks_gdf["is_living"], - # ) = (0, None) - # logger.success( - # f"Buildings for context blocks have been aggregated {scenario_id}" - # ) - # - # service_types = await urban_api_gateway.get_service_types() - # service_types = await adapt_service_types(service_types) - # context_services_dict = await get_context_services(project_id, service_types) - # - # for service_type, services in context_services_dict.items(): - # services = services.to_crs(context_blocks_gdf.crs) - # context_blocks_services, _ = aggregate_objects(context_blocks_gdf, services) - # context_blocks_services["capacity"] = ( - # context_blocks_services["capacity"].fillna(0).astype(int) - # ) - # context_blocks_services["count"] = ( - # context_blocks_services["count"].fillna(0).astype(int) - # ) - # context_blocks_gdf = context_blocks_gdf.join( - # context_blocks_services.drop(columns=["geometry"]).rename( - # columns={ - # "capacity": f"capacity_{service_type}", - # "count": f"count_{service_type}", - # } - # ) - # ) - # logger.success( - # f"Services for context blocks have been aggregated {scenario_id}" - # ) - # - # return context_blocks_gdf, context_buildings_gdf - @staticmethod async def get_services_layer(scenario_id: int, token: str): """ @@ -899,7 +692,6 @@ async def territory_transformation_scenario_before( ~service_types["infrastructure_type"].isna() ].copy() - # вот тут проблема что ource и year функциональных зон выбираются для сценария, который был подан, а затем пытаются примениться для базового сценария params = await self.get_optimal_func_zone_data(params, token) base_src, base_year = ( await urban_api_gateway.get_optimal_func_zone_request_data( @@ -1045,7 +837,7 @@ async def territory_transformation_scenario_after( ].fillna(0) else: logger.info( - f"#{st_id}:{st_name} does not exist on territory. Adding empty GeoDataFrame" + f"{st_id}:{st_name} does not exist on territory. Adding empty GeoDataFrame" ) df = after_blocks[[]].copy() df["capacity"] = 0 @@ -1077,23 +869,17 @@ async def territory_transformation_scenario_after( st_name = service_types.loc[st_id, "name"] if st_name in facade._chosen_service_types: prov_df = facade._provision_adapter.get_last_provision_df(st_name) - # prov_gdf = scenario_blocks[["geometry"]].join(prov_df, how="right") - # prov_gdf = prov_gdf.to_crs(4326) prov_gdf = ( - context_blocks[["geometry"]] - .concat(prov_df, how="outer") + after_blocks[["geometry"]] + .join(prov_df, how="left") .to_crs(4326) .drop(columns="provision_weak", errors="ignore") ) - prov_gdfs_after[st_name] = prov_gdf - - prov_totals = {} - for st_name, prov_gdf in prov_gdfs_after.items(): - if prov_gdf.demand.sum() == 0: - total = None - else: - total = float(provision_strong_total(prov_gdf)) - prov_totals[st_name] = total + num_cols = prov_gdf.select_dtypes(include="number").columns + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + prov_gdfs_after[st_name] = gpd.GeoDataFrame( + prov_gdf, geometry="geometry", crs="EPSG:4326" + ) from_cache = cached["data"] if cached else {} from_cache["after"] = {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()} From 5d9932c85bdb62bd83b74ddf99506650753203d9 Mon Sep 17 00:00:00 2001 From: Voronapxl <142047864+Voronapxl@users.noreply.github.com> Date: Fri, 11 Jul 2025 01:13:52 +0300 Subject: [PATCH 055/161] fix(tmp_commit_7): main logic fixes - fixed: cache --- app/common/caching/caching_service.py | 8 ++-- app/effects_api/effects_service.py | 60 ++++++++++++------------- app/effects_api/modules/task_service.py | 8 ---- 3 files changed, 33 insertions(+), 43 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index ae0dfdf..105b1b5 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -44,14 +44,14 @@ def save( data: dict[str, Any], scenario_updated_at: str | None = None, ) -> Path: - + """ + Always write (or overwrite) the cache file so that both + 'before' and 'after' can be stored in the same JSON. + """ phash = self.params_hash(params) day = datetime.now().strftime("%Y%m%d") path = _file_name(method, scenario_id, phash, day) - if path.exists(): - return path - to_save = { "meta": { "timestamp": datetime.now().isoformat(), diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 46dfdc3..3fd8601 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -69,15 +69,18 @@ def __init__( self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() - async def _make_params_for_hash( + async def build_hash_params( self, params: ContextDevelopmentDTO | DevelopmentDTO, - base_scen_id: int, token: str, ) -> dict: + project_id = ( + await urban_api_gateway.get_scenario_info(params.scenario_id, token) + )["project"]["project_id"] + base_scenario_id = await urban_api_gateway.get_base_scenario_id(project_id) base_src, base_year = ( await urban_api_gateway.get_optimal_func_zone_request_data( - token, base_scen_id, None, None + token, base_scenario_id, None, None ) ) return params.model_dump() | { @@ -208,20 +211,20 @@ async def aggregate_blocks_layer_scenario( token: str | None = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - logger.info(f"[scenario {scenario_id}] ▶ load blocks") + logger.info(f"[Scenario {scenario_id}] load blocks") blocks = await self.load_blocks_scenario(scenario_id, token) - logger.info("land-use") + logger.info("Assigning land-use for scenario") blocks = await self.assign_land_use_to_blocks_scenario( blocks, scenario_id, source, year, token ) - logger.info("buildings") + logger.info("Aggregating buildings for scenario") blocks, buildings = await self.enrich_with_buildings_scenario( blocks, scenario_id, token ) - logger.info("services") + logger.info("Aggregating services for scenario") blocks = await self.enrich_with_services_scenario(blocks, scenario_id, token) blocks["is_project"] = True @@ -308,21 +311,21 @@ async def aggregate_blocks_layer_context( token: str | None = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - logger.info(f"[context {scenario_id}] ▶ load blocks") + logger.info(f"[Context {scenario_id}] load blocks") blocks, project_id = await self.load_context_blocks(scenario_id, token) - logger.info("▶ land-use") + logger.info("Assigning land-use for context") blocks = await self.assign_land_use_context( blocks, project_id, source, year, token ) - logger.info("▶ buildings") + logger.info("Aggregating buildings for context") blocks, buildings = await self.enrich_with_context_buildings(blocks, project_id) - logger.info("▶ services") + logger.info("Aggregating services for context") blocks = await self.enrich_with_context_services(blocks, project_id, token) - logger.success(f"[context {scenario_id}] blocks layer ready") + logger.success(f"[Context {scenario_id}] blocks layer ready") return blocks, buildings @staticmethod @@ -727,13 +730,13 @@ async def territory_transformation_scenario_before( prov_gdf = prov_gdf.drop(axis="columns", columns="provision_weak") prov_gdfs_before[st_name] = prov_gdf - prov_totals = {} - for st_name, prov_gdf in prov_gdfs_before.items(): - if prov_gdf.demand.sum() == 0: - total = None - else: - total = float(provision_strong_total(prov_gdf)) - prov_totals[st_name] = total + # prov_totals = {} + # for st_name, prov_gdf in prov_gdfs_before.items(): + # if prov_gdf.demand.sum() == 0: + # total = None + # else: + # total = float(provision_strong_total(prov_gdf)) + # prov_totals[st_name] = total existing_data = cached["data"] if cached else {} existing_data["before"] = { @@ -765,7 +768,8 @@ async def territory_transformation_scenario_after( params = await self.get_optimal_func_zone_data(params, token) - phash = cache.params_hash(params.model_dump()) + params_for_hash = await self.build_hash_params(params, token) + phash = cache.params_hash(params_for_hash) cached = cache.load(method_name, params.scenario_id, phash) if ( @@ -887,7 +891,7 @@ async def territory_transformation_scenario_after( cache.save( method_name, params.scenario_id, - params.model_dump(), + params_for_hash, from_cache, scenario_updated_at=updated_at, ) @@ -903,33 +907,27 @@ async def territory_transformation( info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) is_based = info["is_based"] updated_at = info["updated_at"] - project_id = info["project"]["project_id"] - base_scenario_id = await urban_api_gateway.get_base_scenario_id(project_id) prov_before = await self.territory_transformation_scenario_before(token, params) - if is_based: return prov_before - params_for_hash = await self._make_params_for_hash( - params, base_scenario_id, token - ) + params_for_hash = await self.build_hash_params(params, token) phash = cache.params_hash(params_for_hash) - cached = cache.load("territory_transformation", params.scenario_id, phash) + cached = cache.load("territory_transformation", params.scenario_id, phash) if ( cached and cached["meta"]["scenario_updated_at"] == updated_at and "after" in cached["data"] ): prov_after = { - n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") - for n, fc in cached["data"]["after"].items() + name: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + for name, fc in cached["data"]["after"].items() } return {"before": prov_before, "after": prov_after} prov_after = await self.territory_transformation_scenario_after(token, params) - return {"before": prov_before, "after": prov_after} diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 0b63520..f83cd6b 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -48,7 +48,6 @@ def run_sync(self) -> None: logger.info(f"[{self.task_id}] started") self.status = "running" - # 1. Пытаемся взять кэш по (method, id, hash) cached = cache.load(self.method, self.scenario_id, self.param_hash) if cached: logger.info(f"[{self.task_id}] loaded from cache") @@ -72,13 +71,6 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: else: data_to_cache = raw_data - cache.save( - self.method, - self.scenario_id, - self.params.model_dump(), - data_to_cache, - ) - self.result = data_to_cache self.status = "done" From 12b563ba397be66ec35b2977093f0f66da0e986c Mon Sep 17 00:00:00 2001 From: voronapxl Date: Mon, 18 Aug 2025 14:21:04 +0300 Subject: [PATCH 056/161] fix(urban_api): redirected context requests from projects to scenarios --- app/effects_api/effects_service.py | 25 ++++++++--------- app/effects_api/modules/context_service.py | 32 +++++++++++----------- app/gateways/urban_api_gateway.py | 18 ++++++------ 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 3fd8601..495cd8c 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -117,15 +117,12 @@ async def get_optimal_func_zone_data( not params.context_func_zone_source or not params.context_func_source_year ): - project_id = await urban_api_gateway.get_project_id( - params.scenario_id, token - ) ( params.context_func_zone_source, params.context_func_source_year, ) = await urban_api_gateway.get_optimal_func_zone_request_data( token, - project_id, + params.scenario_id, params.context_func_zone_source, params.context_func_source_year, project=False, @@ -237,29 +234,29 @@ async def load_context_blocks( scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, int]: project_id = await urban_api_gateway.get_project_id(scenario_id, token) - blocks = await get_context_blocks(project_id) + blocks = await get_context_blocks(project_id, scenario_id) blocks["site_area"] = blocks.area return blocks, project_id @staticmethod async def assign_land_use_context( blocks: gpd.GeoDataFrame, - project_id: int, + scenario_id: int, source: str | None, year: int | None, token: str, ) -> gpd.GeoDataFrame: - fzones = await get_context_functional_zones(project_id, source, year, token) + fzones = await get_context_functional_zones(scenario_id, source, year, token) fzones = fzones.to_crs(blocks.crs) lu = assign_land_use(blocks, fzones, LAND_USE_RULES) return blocks.join(lu.drop(columns=["geometry"])) @staticmethod async def enrich_with_context_buildings( - blocks: gpd.GeoDataFrame, project_id: int + blocks: gpd.GeoDataFrame, scenario_id: int ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - buildings = await get_context_buildings(project_id) + buildings = await get_context_buildings(scenario_id) if buildings is None: blocks["count_buildings"] = 0 blocks["is_living"] = None @@ -279,12 +276,12 @@ async def enrich_with_context_buildings( @staticmethod async def enrich_with_context_services( - blocks: gpd.GeoDataFrame, project_id: int, token: str + blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: stypes = await urban_api_gateway.get_service_types() stypes = await adapt_service_types(stypes) - sdict = await get_context_services(project_id, stypes) + sdict = await get_context_services(scenario_id, stypes) for stype, services in sdict.items(): services = services.to_crs(blocks.crs) @@ -316,14 +313,14 @@ async def aggregate_blocks_layer_context( logger.info("Assigning land-use for context") blocks = await self.assign_land_use_context( - blocks, project_id, source, year, token + blocks, scenario_id, source, year, token ) logger.info("Aggregating buildings for context") - blocks, buildings = await self.enrich_with_context_buildings(blocks, project_id) + blocks, buildings = await self.enrich_with_context_buildings(blocks, scenario_id) logger.info("Aggregating services for context") - blocks = await self.enrich_with_context_services(blocks, project_id, token) + blocks = await self.enrich_with_context_services(blocks, scenario_id, token) logger.success(f"[Context {scenario_id}] blocks layer ready") return blocks, buildings diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index d2c514e..e4d6aaf 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -34,29 +34,29 @@ async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: return gpd.GeoDataFrame(geometry=geometries, crs=4326) -async def _get_context_roads(project_id: int): +async def _get_context_roads(scenario_id: int): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=ROADS_ID + scenario_id, physical_object_function_id=ROADS_ID ) return gdf[["geometry"]].reset_index(drop=True) -async def _get_context_water(project_id: int): +async def _get_context_water(scenario_id: int): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_function_id=WATER_ID + scenario_id, physical_object_function_id=WATER_ID ) return gdf[["geometry"]].reset_index(drop=True) async def _get_context_blocks( - project_id: int, boundaries: gpd.GeoDataFrame + scenario_id: int, boundaries: gpd.GeoDataFrame ) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await _get_context_water(project_id) + water = await _get_context_water(scenario_id) water = water.to_crs(crs) - roads = await _get_context_roads(project_id) + roads = await _get_context_roads(scenario_id) roads = roads.to_crs(crs) roads.geometry = close_gaps(roads, 1) @@ -65,7 +65,7 @@ async def _get_context_blocks( return blocks -async def get_context_blocks(project_id: int): +async def get_context_blocks(project_id: int, scenario_id: int): project_boundaries = await _get_project_boundaries(project_id) context_boundaries = await _get_context_boundaries(project_id) @@ -76,16 +76,16 @@ async def get_context_blocks(project_id: int): context_boundaries = context_boundaries.overlay( project_boundaries, how="difference" ) - return await _get_context_blocks(project_id, context_boundaries) + return await _get_context_blocks(scenario_id, context_boundaries) async def get_context_functional_zones( - project_id: int, source: str, year: int, token: str + scenario_id: int, source: str, year: int, token: str ) -> gpd.GeoDataFrame: - sources_df = await urban_api_gateway.get_functional_zones_sources(project_id) + sources_df = await urban_api_gateway.get_functional_zones_sources(scenario_id) year, source = await _get_best_functional_zones_source(sources_df, source, year) functional_zones = await urban_api_gateway.get_functional_zones( - project_id, year, source + scenario_id, year, source ) functional_zones = functional_zones.loc[ functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) @@ -93,17 +93,17 @@ async def get_context_functional_zones( return adapt_functional_zones(functional_zones) -async def get_context_buildings(project_id: int): +async def get_context_buildings(scenario_id: int): gdf = await urban_api_gateway.get_physical_objects( - project_id, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True + scenario_id, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True ) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) -async def get_context_services(project_id: int, service_types: pd.DataFrame): - gdf = await urban_api_gateway.get_services(project_id, centers_only=True) +async def get_context_services(scenario_id: int, service_types: pd.DataFrame): + gdf = await urban_api_gateway.get_services(scenario_id, centers_only=True) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 2e5fc95..921148f 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -18,10 +18,10 @@ def __init__(self, base_url: str) -> None: # TODO context async def get_physical_objects( - self, project_id: int, **kwargs: dict + self, scenario_id: int, **kwargs: dict ) -> gpd.GeoDataFrame: res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/context/physical_objects_with_geometry", + f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", params=kwargs, ) features = res["features"] @@ -29,9 +29,9 @@ async def get_physical_objects( "physical_object_id" ) - async def get_services(self, project_id: int, **kwargs: Any) -> gpd.GeoDataFrame: + async def get_services(self, scenario_id: int, **kwargs: Any) -> gpd.GeoDataFrame: res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/context/services_with_geometry", + f"/api/v1/scenarios/{scenario_id}/context/services_with_geometry", params=kwargs, ) features = res["features"] @@ -39,17 +39,17 @@ async def get_services(self, project_id: int, **kwargs: Any) -> gpd.GeoDataFrame "service_id" ) - async def get_functional_zones_sources(self, project_id: int) -> pd.DataFrame: + async def get_functional_zones_sources(self, scenario_id: int) -> pd.DataFrame: res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/context/functional_zone_sources" + f"/api/v1/scenarios/{scenario_id}/context/functional_zone_sources" ) return pd.DataFrame(res) async def get_functional_zones( - self, project_id: int, year: int, source: int + self, scenario_id: int, year: int, source: int ) -> gpd.GeoDataFrame: res = await self.json_handler.get( - f"/api/v1/projects/{project_id}/context/functional_zones", + f"/api/v1/scenarios/{scenario_id}/context/functional_zones", params={"year": year, "source": source}, ) features = res["features"] @@ -229,7 +229,7 @@ async def _get_optimal_source( ) else: available_sources = await self.json_handler.get( - f"/api/v1/projects/{data_id}/context/functional_zone_sources", + f"/api/v1/scenarios/{data_id}/context/functional_zone_sources", headers=headers, ) sources_df = pd.DataFrame.from_records(available_sources) From 21f5c9f0e11c3d6547bac7be99d7ec46e89dd7be Mon Sep 17 00:00:00 2001 From: voronapxl Date: Mon, 18 Aug 2025 14:33:28 +0300 Subject: [PATCH 057/161] fix(effects_controller): renamed router --- app/effects_api/effects_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 0e5e670..7604aa3 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -58,7 +58,7 @@ async def get_socio_economic_prediction( # return await effects_service.territory_transformation_scenario(token, params) -@development_router.get("/F_35") +@development_router.get("/territory_transformation") async def territory_transformation( params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], token: str = Depends(verify_token), From 0739c43408c82fc4c9faa4bd976146d319cc3bfb Mon Sep 17 00:00:00 2001 From: voronapxl Date: Mon, 18 Aug 2025 18:19:16 +0300 Subject: [PATCH 058/161] feat(effects_service): added logic for calculation of values development (F26) --- app/effects_api/dto/values_development_dto.py | 11 ++ app/effects_api/effects_controller.py | 8 + app/effects_api/effects_service.py | 145 +++++++++++++----- 3 files changed, 129 insertions(+), 35 deletions(-) create mode 100644 app/effects_api/dto/values_development_dto.py diff --git a/app/effects_api/dto/values_development_dto.py b/app/effects_api/dto/values_development_dto.py new file mode 100644 index 0000000..e37d9bd --- /dev/null +++ b/app/effects_api/dto/values_development_dto.py @@ -0,0 +1,11 @@ +from pydantic import Field + +from app.effects_api.dto.development_dto import ContextDevelopmentDTO + + +class ValuesDevelopmentDTO(ContextDevelopmentDTO): + required_service: str = Field( + ..., + examples=["school"], + description="Service type to get response on", + ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 7604aa3..f6791c1 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -13,6 +13,7 @@ SocioEconomicPredictionDTO, ) from .dto.transformation_effects_dto import TerritoryTransformationDTO +from .dto.values_development_dto import ValuesDevelopmentDTO from .effects_service import effects_service from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema @@ -68,3 +69,10 @@ async def territory_transformation( geojson_dict = json.loads(gdf.to_json(drop_id=True)) return JSONResponse(content=geojson_dict) + +@development_router.get("/values_development") +async def values_development( + params: Annotated[ValuesDevelopmentDTO, Depends(ValuesDevelopmentDTO)], + token: str = Depends(verify_token) +): + return await effects_service.values_transformation(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 495cd8c..c7566e9 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,10 +1,10 @@ import json -from typing import Any, Literal +from typing import Any import geopandas as gpd import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators -from blocksnet.analysis.provision import competitive_provision, provision_strong_total +from blocksnet.analysis.provision import competitive_provision from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use from blocksnet.config import service_types_config @@ -12,9 +12,7 @@ from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor from blocksnet.optimization.services import ( AreaSolution, - BlockSolution, Facade, - GradientChooser, SimpleChooser, TPEOptimizer, WeightedConstraints, @@ -36,8 +34,9 @@ get_scenario_services, ) from app.effects_api.modules.service_type_service import adapt_service_types +from .dto.values_development_dto import ValuesDevelopmentDTO -from ..common.caching.caching_service import _to_dt, cache +from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception from ..common.utils.geodata import _fc_to_gdf, _gdf_to_ru_fc from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES @@ -46,7 +45,6 @@ DevelopmentDTO, SocioEconomicPredictionDTO, ) -from .dto.transformation_effects_dto import TerritoryTransformationDTO from .modules.context_service import ( get_context_blocks, get_context_buildings, @@ -90,7 +88,7 @@ async def build_hash_params( @staticmethod async def get_optimal_func_zone_data( - params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO, + params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO | ValuesDevelopmentDTO, token: str, ) -> DevelopmentDTO: """ @@ -750,8 +748,41 @@ async def territory_transformation_scenario_before( return prov_gdfs_before + + def _build_facade( + self, + after_blocks: gpd.GeoDataFrame, + acc_mx: pd.DataFrame, + service_types: pd.DataFrame, + ) -> Facade: + blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] + blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() + + var_adapter = AreaSolution(blocks_lus) + + facade = Facade( + blocks_lu=blocks_lus, + blocks_df=after_blocks, + accessibility_matrix=acc_mx, + var_adapter=var_adapter, + ) + + for st_id, row in service_types.iterrows(): + st_name = row["name"] + st_weight = row["infrastructure_weight"] + st_column = f"capacity_{st_name}" + + if st_column in after_blocks.columns: + df = after_blocks.rename(columns={st_column: "capacity"})[["capacity"]].fillna(0) + else: + df = after_blocks[[]].copy() + df["capacity"] = 0 + facade.add_service_type(st_name, st_weight, df) + + return facade + async def territory_transformation_scenario_after( - self, token, params: ContextDevelopmentDTO + self, token, params: ContextDevelopmentDTO | DevelopmentDTO ): # provision after method_name = "territory_transformation" @@ -815,34 +846,8 @@ async def territory_transformation_scenario_after( service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) * service_types["infrastructure_weight"] ) - blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] - blocks_lus = blocks_lus[~blocks_lus.isna()] - blocks_lus = blocks_lus.to_dict() - - var_adapter = AreaSolution(blocks_lus) - - facade = Facade( - blocks_lu=blocks_lus, - blocks_df=after_blocks, - accessibility_matrix=acc_mx, - var_adapter=var_adapter, - ) - for st_id, row in service_types.iterrows(): - st_name = row["name"] - st_weight = row["infrastructure_weight"] - st_column = f"capacity_{st_name}" - if st_column in after_blocks.columns: - df = after_blocks.rename(columns={st_column: "capacity"})[ - ["capacity"] - ].fillna(0) - else: - logger.info( - f"{st_id}:{st_name} does not exist on territory. Adding empty GeoDataFrame" - ) - df = after_blocks[[]].copy() - df["capacity"] = 0 - facade.add_service_type(st_name, st_weight, df) + facade = self._build_facade(after_blocks, acc_mx, service_types) services_weights = service_types.set_index("name")[ "infrastructure_weight" @@ -884,6 +889,9 @@ async def territory_transformation_scenario_after( from_cache = cached["data"] if cached else {} from_cache["after"] = {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()} + from_cache["opt_context"] = { + "best_x": best_x, + } cache.save( method_name, @@ -927,5 +935,72 @@ async def territory_transformation( prov_after = await self.territory_transformation_scenario_after(token, params) return {"before": prov_before, "after": prov_after} + async def values_transformation( + self, + token: str, + params: ValuesDevelopmentDTO, + ) -> pd.DataFrame: + + method_name = "territory_transformation" + params = await self.get_optimal_func_zone_data(params, token) + + params_for_hash = await self.build_hash_params(params, token) + phash = cache.params_hash(params_for_hash) + + info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + updated_at = info["updated_at"] + + cached = cache.load(method_name, params.scenario_id, phash) + + need_refresh = ( + not cached + or cached["meta"]["scenario_updated_at"] != updated_at + or "opt_context" not in cached["data"] + or "best_x" not in cached["data"]["opt_context"] + ) + if need_refresh: + await self.territory_transformation_scenario_after(token, params) + cached = cache.load(method_name, params.scenario_id, phash) + + best_x = cached["data"]["opt_context"]["best_x"] + + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, + ) + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) + after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) + after_blocks["is_project"] = after_blocks["is_project"].fillna(False).astype(bool) + + graph = get_accessibility_graph(after_blocks, "intermodal") + acc_mx = calculate_accessibility_matrix(after_blocks, graph) + + service_types = await urban_api_gateway.get_service_types() + service_types = await adapt_service_types(service_types) + service_types = service_types[~service_types["infrastructure_type"].isna()].copy() + service_types["infrastructure_weight"] = ( + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] + ) + + facade = self._build_facade(after_blocks, acc_mx, service_types) + + solution_df = facade.solution_to_services_df(best_x) + + if params.required_service: + solution_df = solution_df.loc[solution_df["service_type"] == params.required_service] + + result = json.loads( + solution_df.to_json(orient="records", date_format="iso") + ) + return result + effects_service = EffectsService() From fcce1ce5fd35ca66a49fe5360a0731b548e45da8 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 21 Aug 2025 12:11:40 +0300 Subject: [PATCH 059/161] feat(f_36_logic): testing logic for f 36 --- app/effects_api/effects_controller.py | 7 +++ app/effects_api/effects_service.py | 82 +++++++++++++++++++++++++-- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index f6791c1..a8da899 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -76,3 +76,10 @@ async def values_development( token: str = Depends(verify_token) ): return await effects_service.values_transformation(token, params) + +@development_router.get("/values_oriented_requirements") +async def values_requirements( + params: Annotated[ValuesDevelopmentDTO, Depends(ValuesDevelopmentDTO)], + token: str = Depends(verify_token) +): + return await effects_service.values_oriented_requirements(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c7566e9..f1e9998 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -2,6 +2,7 @@ from typing import Any import geopandas as gpd +import numpy as np import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators from blocksnet.analysis.provision import competitive_provision @@ -639,13 +640,15 @@ async def _assess_provision( blocks, acc_mx, accessibility ) capacity_column = f"capacity_{service_type}" - if capacity_column not in blocks.columns: - blocks_df = blocks[["geometry", "population"]].fillna(0) - blocks_df["capacity"] = 0 + if capacity_column in blocks.columns: + blocks_df = ( + blocks[["geometry", "population", capacity_column]] + .rename(columns={capacity_column: "capacity"}) + .fillna(0) + ) else: - blocks_df = blocks.rename(columns={capacity_column: "capacity"})[ - ["geometry", "population", "capacity"] - ].fillna(0) + blocks_df = blocks[["geometry", "population"]].copy().fillna(0) + blocks_df["capacity"] = 0 prov_df, _ = competitive_provision(blocks_df, acc_mx, accessibility, demand) prov_df = prov_df.loc[context_ids].copy() return blocks[["geometry"]].join(prov_df, how="right") @@ -1002,5 +1005,72 @@ async def values_transformation( ) return result + async def _get_value_level(self, provisions: list[float | None]) -> float: + provisions = [p for p in provisions if p is not None] + return np.mean(provisions) + + + async def values_oriented_requirements( + self, + token: str, + params: ValuesDevelopmentDTO): + + params = await self.get_optimal_func_zone_data(params, token) + project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) + # project_info = await urban_api_gateway.get_all_project_info(project_id, token) + + context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, + ) + + scenario_blocks, scenario_buildings = ( + await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) + ) + + scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + cap_cols = [c for c in scenario_blocks.columns if c.startswith('capacity_')] + scenario_blocks.loc[scenario_blocks['is_project'], ['population'] + cap_cols] = 0 + if 'capacity' in scenario_blocks.columns: + scenario_blocks = scenario_blocks.drop(columns='capacity') + + blocks = gpd.GeoDataFrame( + pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs, + ) + + social_values_provisions = {} + provisions_gdfs = {} + + service_types = await urban_api_gateway.get_service_types() + service_types = await adapt_service_types(service_types) + service_types = service_types[~service_types['social_values'].isna()].copy() + + graph = get_accessibility_graph(blocks, "intermodal") + acc_mx = calculate_accessibility_matrix(blocks, graph) + + for st_id in service_types.index: + st_name = service_types.loc[st_id, 'name'] + social_values = service_types.loc[st_id, 'social_values'] + prov_total, prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + provisions_gdfs[st_name] = prov_gdf + for social_value in social_values: + if social_value in social_values_provisions: + social_values_provisions[social_value].append(prov_total) + else: + social_values_provisions[social_value] = [prov_total] + + index = social_values_provisions.keys() + columns = ['social_value_level'] + result_df = pd.DataFrame(data=[self._get_value_level(social_values_provisions[sv_id]) for sv_id in index], + index=index, columns=columns) + return result_df effects_service = EffectsService() From 4aab46c1412cf5a4f735ee1ac0ab4c29757c4330 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 21 Aug 2025 16:18:16 +0300 Subject: [PATCH 060/161] feat(values_oriented_requirements): values oriented requirements development, with tasks addition --- app/effects_api/effects_service.py | 76 ++++++++++++++----------- app/effects_api/modules/task_service.py | 2 + 2 files changed, 45 insertions(+), 33 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index f1e9998..db123cb 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1005,19 +1005,17 @@ async def values_transformation( ) return result - async def _get_value_level(self, provisions: list[float | None]) -> float: - provisions = [p for p in provisions if p is not None] - return np.mean(provisions) - + def _get_value_level(self, provisions: list[float | None]) -> float: + vals = [p for p in provisions if p is not None] + return float(np.mean(vals)) if vals else np.nan async def values_oriented_requirements( self, token: str, - params: ValuesDevelopmentDTO): - + params: ValuesDevelopmentDTO, + ): params = await self.get_optimal_func_zone_data(params, token) project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) - # project_info = await urban_api_gateway.get_all_project_info(project_id, token) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( params.scenario_id, @@ -1026,51 +1024,63 @@ async def values_oriented_requirements( token, ) - scenario_blocks, scenario_buildings = ( - await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, - ) + scenario_blocks, scenario_buildings = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - cap_cols = [c for c in scenario_blocks.columns if c.startswith('capacity_')] - scenario_blocks.loc[scenario_blocks['is_project'], ['population'] + cap_cols] = 0 - if 'capacity' in scenario_blocks.columns: - scenario_blocks = scenario_blocks.drop(columns='capacity') + + cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] + scenario_blocks.loc[ + scenario_blocks["is_project"], ["population"] + cap_cols + ] = 0 + + if "capacity" in scenario_blocks.columns: + scenario_blocks = scenario_blocks.drop(columns="capacity") blocks = gpd.GeoDataFrame( pd.concat([context_blocks, scenario_blocks], ignore_index=True), crs=context_blocks.crs, ) - social_values_provisions = {} - provisions_gdfs = {} + social_values_provisions: dict[str, list[float | None]] = {} + provisions_gdfs: dict[str, gpd.GeoDataFrame] = {} service_types = await urban_api_gateway.get_service_types() service_types = await adapt_service_types(service_types) - service_types = service_types[~service_types['social_values'].isna()].copy() + service_types = service_types[~service_types["social_values"].isna()].copy() graph = get_accessibility_graph(blocks, "intermodal") acc_mx = calculate_accessibility_matrix(blocks, graph) for st_id in service_types.index: - st_name = service_types.loc[st_id, 'name'] - social_values = service_types.loc[st_id, 'social_values'] - prov_total, prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + st_name = service_types.loc[st_id, "name"] + social_values = service_types.loc[st_id, "social_values"] + + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + + if "provision_strong" in prov_gdf.columns: + prov_total: float | None = float(prov_gdf["provision_strong"].sum()) + elif "provision" in prov_gdf.columns: + prov_total = float(prov_gdf["provision"].sum()) + else: + prov_total = None + provisions_gdfs[st_name] = prov_gdf + for social_value in social_values: - if social_value in social_values_provisions: - social_values_provisions[social_value].append(prov_total) - else: - social_values_provisions[social_value] = [prov_total] - - index = social_values_provisions.keys() - columns = ['social_value_level'] - result_df = pd.DataFrame(data=[self._get_value_level(social_values_provisions[sv_id]) for sv_id in index], - index=index, columns=columns) + social_values_provisions.setdefault(social_value, []).append(prov_total) + + index = list(social_values_provisions.keys()) + result_df = pd.DataFrame( + data=[self._get_value_level(social_values_provisions[sv_id]) for sv_id in index], + index=index, + columns=["social_value_level"], + ) return result_df + effects_service = EffectsService() diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index f83cd6b..2be2b2d 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -17,6 +17,8 @@ TASK_METHODS: dict[str, MethodFunc] = { "territory_transformation": effects_service.territory_transformation, + "values_transformation": effects_service.values_transformation, + "values_oriented_requirements": effects_service.values_oriented_requirements, } _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() From da7f5c3b9beba32d5855a7d46d7a22c7daf83600 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 22 Aug 2025 17:15:37 +0300 Subject: [PATCH 061/161] feat(main logic): 1. Added separate router for all other methods to get data from cache 2. Added caching for values methods 3. Added tasks for values methods --- app/effects_api/effects_controller.py | 8 ----- app/effects_api/effects_service.py | 41 +++++++++++++++++++------ app/effects_api/modules/task_service.py | 15 +++++++-- app/effects_api/tasks_controller.py | 23 +++++++++++--- 4 files changed, 63 insertions(+), 24 deletions(-) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index a8da899..9287430 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -51,14 +51,6 @@ async def get_socio_economic_prediction( return await effects_service.evaluate_master_plan(params, token) -# @development_router.get("/F_35") -# async def territory_transformation( -# params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], -# token: str = Depends(verify_token), -# ): -# return await effects_service.territory_transformation_scenario(token, params) - - @development_router.get("/territory_transformation") async def territory_transformation( params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index db123cb..8fdef7c 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -59,7 +59,6 @@ ) -# TODO add caching service class EffectsService: def __init__( @@ -728,14 +727,6 @@ async def territory_transformation_scenario_before( prov_gdf = prov_gdf.drop(axis="columns", columns="provision_weak") prov_gdfs_before[st_name] = prov_gdf - # prov_totals = {} - # for st_name, prov_gdf in prov_gdfs_before.items(): - # if prov_gdf.demand.sum() == 0: - # total = None - # else: - # total = float(provision_strong_total(prov_gdf)) - # prov_totals[st_name] = total - existing_data = cached["data"] if cached else {} existing_data["before"] = { n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items() @@ -1014,7 +1005,23 @@ async def values_oriented_requirements( token: str, params: ValuesDevelopmentDTO, ): + method_name = "values_oriented_requirements" + params = await self.get_optimal_func_zone_data(params, token) + + params_for_hash = await self.build_hash_params(params, token) + phash = cache.params_hash(params_for_hash) + + info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + updated_at = info["updated_at"] + + cached = cache.load(method_name, params.scenario_id, phash) + if cached and cached["meta"].get("scenario_updated_at") == updated_at and "result" in cached["data"]: + payload = cached["data"]["result"] + result_df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) + result_df.index.name = payload.get("index_name", None) + return result_df + project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( @@ -1080,6 +1087,22 @@ async def values_oriented_requirements( index=index, columns=["social_value_level"], ) + result_df.index.name = "social_value_id" + + payload = { + "columns": result_df.columns.tolist(), + "index": result_df.index.tolist(), + "data": result_df.values.tolist(), + "index_name": result_df.index.name, + } + cache.save( + method_name, + params.scenario_id, + params_for_hash, + {"result": payload}, + scenario_updated_at=updated_at, + ) + return result_df diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 2be2b2d..c7b6f74 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -26,13 +26,13 @@ class AnyTask: - def __init__(self, method: str, scenario_id: int, token: str, params: Any): + def __init__(self, method: str, scenario_id: int, token: str, params: Any, params_hash: str): self.method = method self.scenario_id = scenario_id self.token = token self.params = params + self.param_hash = params_hash - self.param_hash = cache.params_hash(params.model_dump()) self.task_id = f"{method}_{scenario_id}_{self.param_hash}" self.status: Literal["queued", "running", "done", "failed"] = "queued" self.result: dict | None = None @@ -82,6 +82,17 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: self.error = str(exc) +async def create_task(method: str, token: str, params) -> str: + norm_params = await effects_service.get_optimal_func_zone_data(params, token) + params_for_hash = await effects_service.build_hash_params(norm_params, token) + phash = cache.params_hash(params_for_hash) + + task = AnyTask(method, norm_params.scenario_id, token, norm_params, phash) + _task_map[task.task_id] = task + await _task_queue.put(task) + return task.task_id + + async def _worker(): while True: task: AnyTask = await _task_queue.get() diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 6fb5325..f8b5540 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -1,4 +1,3 @@ -import uuid from typing import Annotated from fastapi import APIRouter @@ -16,7 +15,6 @@ from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception from .dto.development_dto import ContextDevelopmentDTO -from .dto.transformation_effects_dto import TerritoryTransformationDTO from .effects_service import effects_service router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -38,7 +36,10 @@ async def create_task( raise http_exception(404, f"method '{method}' is not registered", method) params_filled = await effects_service.get_optimal_func_zone_data(params, token) - phash = cache.params_hash(params_filled.model_dump()) + + params_for_hash = await effects_service.build_hash_params(params_filled, token) + phash = cache.params_hash(params_for_hash) + task_id = f"{method}_{params_filled.scenario_id}_{phash}" if cache.load(method, params_filled.scenario_id, phash): @@ -48,7 +49,7 @@ async def create_task( if existing and existing.status in {"queued", "running"}: return {"task_id": task_id, "status": existing.status} - task = AnyTask(method, params_filled.scenario_id, token, params_filled) + task = AnyTask(method, params_filled.scenario_id, token, params_filled, params_for_hash) _task_map[task_id] = task await _task_queue.put(task) @@ -64,7 +65,7 @@ async def task_status(task_id: str): @router.get("/territory_transformation/{scenario_id}/{service_name}") -async def get_tt_layer(scenario_id: int, service_name: str): +async def get_territory_transformation_layer(scenario_id: int, service_name: str): cached = cache.load_latest("territory_transformation", scenario_id) if not cached: raise http_exception(404, "no saved result for this scenario", scenario_id) @@ -83,3 +84,15 @@ async def get_tt_layer(scenario_id: int, service_name: str): raise http_exception(404, f"service '{service_name}' not found") return JSONResponse(content={"before": fc_before, "after": fc_after}) + + +@router.get("/get_from_cache/{method_name}/{scenario_id}") +async def get_layer(scenario_id: int, method_name: str): + cached = cache.load_latest(method_name, scenario_id) + if not cached: + raise http_exception(404, "no saved result for this scenario", scenario_id) + + data: dict = cached["data"] + return JSONResponse(content=data) + + From cb534778e3b4314de5c84b57298b1ad2ea51f1e9 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Tue, 2 Sep 2025 17:49:32 +0300 Subject: [PATCH 062/161] fix(f_36_logic): 1. Task fixes 2. Logic fixes 3. Constants comments --- Dockerfile | 2 +- app/common/utils/geodata.py | 4 +-- app/effects_api/constants/const.py | 9 +++-- app/effects_api/effects_service.py | 28 ++++++--------- app/effects_api/modules/context_service.py | 11 +++--- app/effects_api/modules/scenario_service.py | 9 +++-- app/effects_api/modules/task_service.py | 34 ++++++++++++++----- .../schemas/socio_economic_response_schema.py | 2 +- app/gateways/urban_api_gateway.py | 3 -- app/main.py | 7 ++-- 10 files changed, 59 insertions(+), 50 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3926b3e..b1f9107 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,4 +27,4 @@ WORKDIR /app COPY . /app # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug -CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --timeout 1000 --workers $APP_WORKERS app.main:app"] \ No newline at end of file +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --workers $APP_WORKERS app.main:app"] \ No newline at end of file diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 1758a0a..e7cb4d7 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -5,7 +5,7 @@ from app.effects_api.constants.const import COL_RU -def _gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: +def gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: if "provision_weak" in gdf.columns: gdf = gdf.drop(columns="provision_weak") gdf = gdf.rename( @@ -14,5 +14,5 @@ def _gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: return json.loads(gdf.to_crs(4326).to_json(drop_id=True)) -def _fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: +def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index f09479c..bea4960 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -110,9 +110,10 @@ "population": [["properties", "population_balanced"]], } - +#For each Infrastructure_type we will also add a weighting factor to give preference to the basic service, and another switch for capabilities. INFRASTRUCTURES_WEIGHTS = {"basic": 0.5714, "additional": 0.2857, "comfort": 0.1429} +#Mapping for translation of english provision properties COL_RU = { "demand": "Спрос", "capacity": "Емкость сервисов", @@ -124,7 +125,11 @@ "capacity_without": "Емкость сервисов за пределами нормативной доступности", "provision_strong": "Обеспеченность сервисами", } - +#ID of living building physical_object_type_id LIVING_BUILDINGS_ID = 4 + +#ID of road physical_object_function_id ROADS_ID = 26 + +#ID of water objects physical_object_function_id WATER_ID = 4 diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index d9c8cef..4afa069 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -39,7 +39,7 @@ from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import _fc_to_gdf, _gdf_to_ru_fc +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, @@ -187,7 +187,6 @@ async def enrich_with_services_scenario( b_srv, _ = aggregate_objects(blocks, services) b_srv[["capacity", "count"]] = ( b_srv[["capacity", "count"]].fillna(0).astype(int) - ) blocks = blocks.join( b_srv.drop(columns=["geometry"]).rename( @@ -482,7 +481,7 @@ async def evaluate_master_plan( 4. Feed summarised indicators into SocialRegressor. """ - logger.info(f"Evaluating master plan effects with {params.model_dump()}") + logger.info("Evaluating master plan effects") params = await self.get_optimal_func_zone_data(params, token) project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) project_info = await urban_api_gateway.get_all_project_info(project_id, token) @@ -544,7 +543,9 @@ async def evaluate_master_plan( crs=4326, ) ter_blocks = ( - blocks.sjoin(territory.to_crs(blocks.crs), how="left") + blocks.sjoin( + territory.to_crs(territory.estimate_utm_crs()), how="left" + ) .dropna(subset="index_right") .drop(columns="index_right") ) @@ -556,9 +557,6 @@ async def evaluate_master_plan( ter_blocks, ter_input ) - logger.info( - f"Finished evaluating master plan effects with {params.model_dump()}" - ) return SocioEconomicResponseSchema( socio_economic_prediction=main_res.socio_economic_prediction, split_prediction=context_results if context_results else None, @@ -577,7 +575,6 @@ async def calc_project_development( DevelopmentResponseSchema: Response schema with development indicators """ - logger.info(f"Calculating development for project {params.model_dump()}") params = await self.get_optimal_func_zone_data(params, token) blocks, buildings = await self.aggregate_blocks_layer_scenario( params.scenario_id, @@ -588,9 +585,6 @@ async def calc_project_development( res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") res.update({"params_data": params.model_dump()}) - logger.info( - f"Finished calculating development for project {params.model_dump()}" - ) return DevelopmentResponseSchema(**res) async def calc_context_development( @@ -605,7 +599,6 @@ async def calc_context_development( DevelopmentResponseSchema: Response schema with development indicators """ - logger.info(f"Calculating development for context {params.model_dump()}") params = await self.get_optimal_func_zone_data(params, token) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( params.scenario_id, @@ -625,9 +618,6 @@ async def calc_context_development( res = await self.run_development_parameters(blocks) res = res.to_dict(orient="list") res.update({"params_data": params.model_dump()}) - logger.info( - f"Finished calculating development for context {params.model_dump()}" - ) return DevelopmentResponseSchema(**res) async def _get_accessibility_context( @@ -692,7 +682,7 @@ async def territory_transformation_scenario_before( and cached["meta"]["scenario_updated_at"] == updated_at and "before" in cached["data"] ): - return {n: _fc_to_gdf(fc) for n, fc in cached["data"]["before"].items()} + return {n: fc_to_gdf(fc) for n, fc in cached["data"]["before"].items()} logger.info("Cache stale or missing: recalculating BEFORE") @@ -739,7 +729,7 @@ async def territory_transformation_scenario_before( existing_data = cached["data"] if cached else {} existing_data["before"] = { - n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items() + n: gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items() } cache.save( @@ -892,7 +882,7 @@ async def territory_transformation_scenario_after( ) from_cache = cached["data"] if cached else {} - from_cache["after"] = {n: _gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()} + from_cache["after"] = {n: gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()} from_cache["opt_context"] = { "best_x": best_x, } @@ -1015,6 +1005,7 @@ async def values_oriented_requirements( token: str, params: ValuesDevelopmentDTO, ): + service_type = params.service_type method_name = "values_oriented_requirements" params = await self.get_optimal_func_zone_data(params, token) @@ -1113,6 +1104,7 @@ async def values_oriented_requirements( scenario_updated_at=updated_at, ) + return result_df diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index af3ad4a..bbbaef1 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -14,17 +14,14 @@ from app.effects_api.modules.services_service import adapt_services -async def _get_project_boundaries(project_id: int, token: str): +async def _get_project_boundaries(project_id: int): return gpd.GeoDataFrame( - geometry=[ - await urban_api_gateway.get_project_geometry(project_id, token=token) - ], - crs=4326, + geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 ) -async def _get_context_boundaries(project_id: int, token: str) -> gpd.GeoDataFrame: - project = await urban_api_gateway.get_project(project_id, token) +async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: + project = await urban_api_gateway.get_project(project_id) context_ids = project["properties"]["context"] geometries = [ await urban_api_gateway.get_territory_geometry(territory_id) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 2bc3790..d7bb65b 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -49,10 +49,9 @@ def close_gaps(gdf, tolerance): # taken from momepy return gpd.GeoSeries(snapped, crs=gdf.crs) -async def _get_project_boundaries(project_id: int, token: str): +async def _get_project_boundaries(project_id: int): return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id, token)], - crs=4326, + geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 ) @@ -112,7 +111,7 @@ async def _get_scenario_blocks( async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: scenario = await urban_api_gateway.get_scenario(scenario_id, token) project_id = scenario["project"]["project_id"] - project = await urban_api_gateway.get_project(project_id, token) + project = await urban_api_gateway.get_project(project_id) base_scenario_id = project["base_scenario"]["id"] return project_id, base_scenario_id @@ -159,7 +158,7 @@ async def _get_best_functional_zones_source( async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataFrame: project_id, base_scenario_id = await _get_scenario_info(user_scenario_id, token) - project_boundaries = await _get_project_boundaries(project_id, token) + project_boundaries = await _get_project_boundaries(project_id) crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index c7b6f74..ad1cc27 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -103,15 +103,33 @@ async def _worker(): worker_task: asyncio.Task | None = None -def init_worker(app: FastAPI): - global worker_task - worker_task = asyncio.create_task(_worker(), name="any_task_worker") +class Worker: + def __init__(self): + self.is_alive = True + self.task: asyncio.Task | None = None + async def run(self): + while self.is_alive: + task: AnyTask = await _task_queue.get() + await asyncio.to_thread(task.run_sync) + _task_queue.task_done() + + def start(self): + self.task = asyncio.create_task(self.run(), name="any_task_worker") + + async def stop(self): + self.is_alive = False + if self.task: + self.task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self.task + +worker = Worker() @asynccontextmanager async def lifespan(app: FastAPI): - yield - if worker_task: - worker_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await worker_task + worker.start() + try: + yield + finally: + await worker.stop() diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index e978e27..432902e 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -38,7 +38,7 @@ def rename_attributes(cls, value: dict[str, SocioEconomicParams]): return { ( k - if k not in soc_economy_pred_name_map.keys() + if k not in pred_columns_names_map.keys() else soc_economy_pred_name_map[k] ): v for k, v in value.items() diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index dd50b02..839bb54 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -23,7 +23,6 @@ async def get_physical_objects( res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", params=kwargs, - headers={"Authorization": f"Bearer {token}"}, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( @@ -34,7 +33,6 @@ async def get_services(self, scenario_id: int, **kwargs: Any) -> gpd.GeoDataFram res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/services_with_geometry", params=kwargs, - headers={"Authorization": f"Bearer {token}"}, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( @@ -53,7 +51,6 @@ async def get_functional_zones( res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/functional_zones", params={"year": year, "source": source}, - headers={"Authorization": f"Bearer {token}"}, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( diff --git a/app/main.py b/app/main.py index bdb945a..1bf2758 100644 --- a/app/main.py +++ b/app/main.py @@ -1,3 +1,5 @@ +from contextlib import asynccontextmanager + from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware @@ -5,7 +7,7 @@ from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.effects_controller import development_router -from app.effects_api.modules.task_service import init_worker, lifespan +from app.effects_api.modules.task_service import lifespan from app.effects_api.tasks_controller import router as tasks_router from app.system_router.system_controller import system_router @@ -15,7 +17,6 @@ description="API for calculating effects of territory transformation with BlocksNet library", lifespan=lifespan, ) -init_worker(app) origins = ["*"] @@ -23,7 +24,7 @@ app.add_middleware( CORSMiddleware, allow_origins=origins, - allow_credentials=False, + allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) From b144514400fa8dc690d836677cffb8bf8183afc8 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Tue, 2 Sep 2025 19:38:31 +0300 Subject: [PATCH 063/161] fix(commit_history): 1. Token additions 2. Urban api requests fixes --- app/effects_api/effects_service.py | 14 +++--- app/effects_api/modules/context_service.py | 52 ++++++++++++--------- app/effects_api/modules/scenario_service.py | 8 ++-- app/gateways/urban_api_gateway.py | 23 ++++++--- 4 files changed, 59 insertions(+), 38 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 4afa069..9571fa4 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -232,7 +232,7 @@ async def load_context_blocks( scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, int]: project_id = await urban_api_gateway.get_project_id(scenario_id, token) - blocks = await get_context_blocks(project_id, scenario_id) + blocks = await get_context_blocks(project_id, scenario_id, token) blocks["site_area"] = blocks.area return blocks, project_id @@ -251,10 +251,10 @@ async def assign_land_use_context( @staticmethod async def enrich_with_context_buildings( - blocks: gpd.GeoDataFrame, scenario_id: int + blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - buildings = await get_context_buildings(scenario_id) + buildings = await get_context_buildings(scenario_id, token) if buildings is None: blocks["count_buildings"] = 0 blocks["is_living"] = None @@ -279,7 +279,7 @@ async def enrich_with_context_services( stypes = await urban_api_gateway.get_service_types() stypes = await adapt_service_types(stypes) - sdict = await get_context_services(scenario_id, stypes) + sdict = await get_context_services(scenario_id, stypes, token) for stype, services in sdict.items(): services = services.to_crs(blocks.crs) @@ -315,7 +315,7 @@ async def aggregate_blocks_layer_context( ) logger.info("Aggregating buildings for context") - blocks, buildings = await self.enrich_with_context_buildings(blocks, scenario_id) + blocks, buildings = await self.enrich_with_context_buildings(blocks, scenario_id, token) logger.info("Aggregating services for context") blocks = await self.enrich_with_context_services(blocks, scenario_id, token) @@ -988,8 +988,8 @@ async def values_transformation( solution_df = facade.solution_to_services_df(best_x) - if params.required_service: - solution_df = solution_df.loc[solution_df["service_type"] == params.required_service] + # if params.required_service: + # solution_df = solution_df.loc[solution_df["service_type"] == params.required_service] result = json.loads( solution_df.to_json(orient="records", date_format="iso") diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index bbbaef1..874c6ab 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -14,14 +14,17 @@ from app.effects_api.modules.services_service import adapt_services -async def _get_project_boundaries(project_id: int): +async def _get_project_boundaries(scenario_id: int, token: str): return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + geometry=[ + await urban_api_gateway.get_project_geometry(scenario_id, token=token) + ], + crs=4326, ) -async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: - project = await urban_api_gateway.get_project(project_id) +async def _get_context_boundaries(scenario_id: int, token: str) -> gpd.GeoDataFrame: + project = await urban_api_gateway.get_project(scenario_id, token) context_ids = project["properties"]["context"] geometries = [ await urban_api_gateway.get_territory_geometry(territory_id) @@ -30,29 +33,29 @@ async def _get_context_boundaries(project_id: int) -> gpd.GeoDataFrame: return gpd.GeoDataFrame(geometry=geometries, crs=4326) -async def _get_context_roads(scenario_id: int): +async def _get_context_roads(scenario_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects( - scenario_id, physical_object_function_id=ROADS_ID + scenario_id, token, physical_object_function_id=ROADS_ID ) return gdf[["geometry"]].reset_index(drop=True) -async def _get_context_water(scenario_id: int): +async def _get_context_water(scenario_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects( - scenario_id, physical_object_function_id=WATER_ID + scenario_id, token=token, physical_object_function_id=WATER_ID ) return gdf[["geometry"]].reset_index(drop=True) async def _get_context_blocks( - scenario_id: int, boundaries: gpd.GeoDataFrame + scenario_id: int, boundaries: gpd.GeoDataFrame, token ) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await _get_context_water(scenario_id) + water = await _get_context_water(scenario_id, token) water = water.to_crs(crs) - roads = await _get_context_roads(scenario_id) + roads = await _get_context_roads(scenario_id, token) roads = roads.to_crs(crs) roads.geometry = close_gaps(roads, 1) @@ -61,9 +64,9 @@ async def _get_context_blocks( return blocks -async def get_context_blocks(project_id: int, scenario_id: int): - project_boundaries = await _get_project_boundaries(project_id) - context_boundaries = await _get_context_boundaries(project_id) +async def get_context_blocks(project_id: int, scenario_id: int, token): + project_boundaries = await _get_project_boundaries(project_id, token) + context_boundaries = await _get_context_boundaries(project_id, token) crs = context_boundaries.estimate_utm_crs() context_boundaries = context_boundaries.to_crs(crs) @@ -72,16 +75,16 @@ async def get_context_blocks(project_id: int, scenario_id: int): context_boundaries = context_boundaries.overlay( project_boundaries, how="difference" ) - return await _get_context_blocks(scenario_id, context_boundaries) + return await _get_context_blocks(scenario_id, context_boundaries, token) async def get_context_functional_zones( scenario_id: int, source: str, year: int, token: str ) -> gpd.GeoDataFrame: - sources_df = await urban_api_gateway.get_functional_zones_sources(scenario_id) + sources_df = await urban_api_gateway.get_functional_zones_sources(scenario_id, token) year, source = await _get_best_functional_zones_source(sources_df, source, year) functional_zones = await urban_api_gateway.get_functional_zones( - scenario_id, year, source + scenario_id, year, source, token ) functional_zones = functional_zones.loc[ functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) @@ -89,17 +92,24 @@ async def get_context_functional_zones( return adapt_functional_zones(functional_zones) -async def get_context_buildings(scenario_id: int): +async def get_context_buildings(scenario_id: int, token: str): gdf = await urban_api_gateway.get_physical_objects( - scenario_id, physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True + scenario_id, + token, + physical_object_type_id=LIVING_BUILDINGS_ID, + centers_only=True, ) gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) -async def get_context_services(scenario_id: int, service_types: pd.DataFrame): - gdf = await urban_api_gateway.get_services(scenario_id, centers_only=True) +async def get_context_services( + scenario_id: int, service_types: pd.DataFrame, token: str +): + gdf = await urban_api_gateway.get_services( + scenario_id, token, centers_only=True + ) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index d7bb65b..5079ca0 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -49,9 +49,9 @@ def close_gaps(gdf, tolerance): # taken from momepy return gpd.GeoSeries(snapped, crs=gdf.crs) -async def _get_project_boundaries(project_id: int): +async def _get_project_boundaries(project_id: int, token): return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id)], crs=4326 + geometry=[await urban_api_gateway.get_project_geometry(project_id, token)], crs=4326 ) @@ -111,7 +111,7 @@ async def _get_scenario_blocks( async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: scenario = await urban_api_gateway.get_scenario(scenario_id, token) project_id = scenario["project"]["project_id"] - project = await urban_api_gateway.get_project(project_id) + project = await urban_api_gateway.get_project(project_id, token) base_scenario_id = project["base_scenario"]["id"] return project_id, base_scenario_id @@ -158,7 +158,7 @@ async def _get_best_functional_zones_source( async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataFrame: project_id, base_scenario_id = await _get_scenario_info(user_scenario_id, token) - project_boundaries = await _get_project_boundaries(project_id) + project_boundaries = await _get_project_boundaries(project_id, token) crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index 839bb54..f5e3a25 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -18,20 +18,27 @@ def __init__(self, base_url: str) -> None: # TODO context async def get_physical_objects( - self, scenario_id: int, **kwargs: dict + self, + scenario_id: int, + token: str, + **params: Any, ) -> gpd.GeoDataFrame: + headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", - params=kwargs, + headers=headers, + params=params, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( "physical_object_id" ) - async def get_services(self, scenario_id: int, **kwargs: Any) -> gpd.GeoDataFrame: + async def get_services(self, scenario_id: int, token, **kwargs: Any) -> gpd.GeoDataFrame: + headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/services_with_geometry", + headers=headers, params=kwargs, ) features = res["features"] @@ -39,18 +46,22 @@ async def get_services(self, scenario_id: int, **kwargs: Any) -> gpd.GeoDataFram "service_id" ) - async def get_functional_zones_sources(self, scenario_id: int) -> pd.DataFrame: + async def get_functional_zones_sources(self, scenario_id: int, token: str) -> pd.DataFrame: + headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( - f"/api/v1/scenarios/{scenario_id}/context/functional_zone_sources" + f"/api/v1/scenarios/{scenario_id}/context/functional_zone_sources", + headers=headers, ) return pd.DataFrame(res) async def get_functional_zones( - self, scenario_id: int, year: int, source: int + self, scenario_id: int, year: int, source: int, token: str ) -> gpd.GeoDataFrame: + headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/functional_zones", params={"year": year, "source": source}, + headers=headers, ) features = res["features"] return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( From 70cd888ae046484f0381b4221ecf573b150c5b26 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Tue, 2 Sep 2025 20:06:08 +0300 Subject: [PATCH 064/161] feat(tasks_controller): 1. router for getting method names available for tasks creation --- app/effects_api/tasks_controller.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index f8b5540..185ad84 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -25,6 +25,10 @@ async def _with_defaults( ) -> ContextDevelopmentDTO: return await effects_service.get_optimal_func_zone_data(dto, token) +@router.get("/methods") +async def get_methods(): + """router for getting method names available for tasks creation""" + return list(TASK_METHODS.keys()) @router.post("/{method}", status_code=202) async def create_task( From c7dbd10ab2c5211dc556d23e045a03793e9ba3a3 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 3 Sep 2025 14:48:13 +0300 Subject: [PATCH 065/161] fix(dto): 1. Romved repeated DTO --- app/effects_api/dto/values_development_dto.py | 11 ----------- app/effects_api/effects_controller.py | 5 ++--- app/effects_api/effects_service.py | 8 ++++---- 3 files changed, 6 insertions(+), 18 deletions(-) delete mode 100644 app/effects_api/dto/values_development_dto.py diff --git a/app/effects_api/dto/values_development_dto.py b/app/effects_api/dto/values_development_dto.py deleted file mode 100644 index e37d9bd..0000000 --- a/app/effects_api/dto/values_development_dto.py +++ /dev/null @@ -1,11 +0,0 @@ -from pydantic import Field - -from app.effects_api.dto.development_dto import ContextDevelopmentDTO - - -class ValuesDevelopmentDTO(ContextDevelopmentDTO): - required_service: str = Field( - ..., - examples=["school"], - description="Service type to get response on", - ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 9287430..503bd67 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -13,7 +13,6 @@ SocioEconomicPredictionDTO, ) from .dto.transformation_effects_dto import TerritoryTransformationDTO -from .dto.values_development_dto import ValuesDevelopmentDTO from .effects_service import effects_service from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema @@ -64,14 +63,14 @@ async def territory_transformation( @development_router.get("/values_development") async def values_development( - params: Annotated[ValuesDevelopmentDTO, Depends(ValuesDevelopmentDTO)], + params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], token: str = Depends(verify_token) ): return await effects_service.values_transformation(token, params) @development_router.get("/values_oriented_requirements") async def values_requirements( - params: Annotated[ValuesDevelopmentDTO, Depends(ValuesDevelopmentDTO)], + params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], token: str = Depends(verify_token) ): return await effects_service.values_oriented_requirements(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 9571fa4..085ca82 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -35,7 +35,7 @@ get_scenario_services, ) from app.effects_api.modules.service_type_service import adapt_service_types -from .dto.values_development_dto import ValuesDevelopmentDTO +from .dto.transformation_effects_dto import TerritoryTransformationDTO from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception @@ -88,7 +88,7 @@ async def build_hash_params( @staticmethod async def get_optimal_func_zone_data( - params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO | ValuesDevelopmentDTO, + params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO | TerritoryTransformationDTO, token: str, ) -> DevelopmentDTO: """ @@ -932,7 +932,7 @@ async def territory_transformation( async def values_transformation( self, token: str, - params: ValuesDevelopmentDTO, + params: TerritoryTransformationDTO, ) -> pd.DataFrame: method_name = "territory_transformation" @@ -1003,7 +1003,7 @@ def _get_value_level(self, provisions: list[float | None]) -> float: async def values_oriented_requirements( self, token: str, - params: ValuesDevelopmentDTO, + params: TerritoryTransformationDTO, ): service_type = params.service_type method_name = "values_oriented_requirements" From 18edff9ad7603d06fa0b4226101c9ace005a84d0 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 5 Sep 2025 17:04:36 +0300 Subject: [PATCH 066/161] feat(service_type_router): 1. Added router for getting service types on before or after layer 2. Added /{task_id} renamed to /status/{task_id} --- .../modules/service_type_service.py | 30 +++++++++++++++++++ app/effects_api/tasks_controller.py | 9 +++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 143d760..a6249ba 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -4,6 +4,7 @@ import pandas as pd from blocksnet.config import service_types_config +from app.common.caching.caching_service import cache from app.dependencies import urban_api_gateway from app.effects_api.constants.const import SERVICE_TYPES_MAPPING @@ -46,3 +47,32 @@ async def adapt_service_types(service_types_df: pd.DataFrame) -> pd.DataFrame: df["social_values"] = social_vals return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] + +async def get_services_with_ids_from_layer(scenario_id: int, method: str) -> dict: + cached: Optional[dict] = cache.load_latest(method, scenario_id) + if not cached or "data" not in cached: + return {"before": [], "after": []} + + data = cached["data"] + + def map_services(names): + result = [] + for name in names: + matched = [ + {"id": sid, "name": sname} + for sid, sname in SERVICE_TYPES_MAPPING.items() + if sname == name + ] + if matched: + result.extend(matched) + else: + result.append({"id": None, "name": name}) + return result + + before_names = list(data.get("before", {}).keys()) + after_names = list(data.get("after", {}).keys()) + + return { + "before": map_services(before_names), + "after": map_services(after_names), + } diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 185ad84..5947cc2 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -11,6 +11,7 @@ _task_map, _task_queue, ) +from .modules.service_type_service import get_services_with_ids_from_layer from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception @@ -60,13 +61,19 @@ async def create_task( return {"task_id": task_id, "status": "queued"} -@router.get("/{task_id}") +@router.get("/status/{task_id}") async def task_status(task_id: str): task = _task_map.get(task_id) if not task: raise http_exception(404, "task not found", task_id) return await task.to_response() +@router.get("/get_service_types") +async def get_service_types( + scenario_id: int, + method: str = "territory_transformation", +): + return await get_services_with_ids_from_layer(scenario_id, method) @router.get("/territory_transformation/{scenario_id}/{service_name}") async def get_territory_transformation_layer(scenario_id: int, service_name: str): From a469a2adb7119fae4f2f7befe0096fda52b9c90a Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 7 Sep 2025 14:42:50 +0300 Subject: [PATCH 067/161] fix(social_values): 1. Fixed social values requests for service types --- .../modules/service_type_service.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index a6249ba..1eab293 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,5 +1,5 @@ import asyncio -from typing import List, Optional +from typing import List, Optional, Dict import pandas as pd from blocksnet.config import service_types_config @@ -18,33 +18,41 @@ async def _adapt_name(service_type_id: int): return SERVICE_TYPES_MAPPING.get(service_type_id) -async def _adapt_social_values(service_type_id: int): - social_values = await urban_api_gateway.get_service_type_social_values( - service_type_id - ) - if social_values is None: - return None - else: - return list(social_values.index) +_SOCIAL_VALUES_BY_ST: Dict[int, Optional[List[int]]] = {} +_SOCIAL_VALUES_LOCK = asyncio.Lock() + +async def _warmup_social_values(service_type_ids: List[int]) -> None: + missing = [sid for sid in service_type_ids if sid not in _SOCIAL_VALUES_BY_ST] + if not missing: + return + async with _SOCIAL_VALUES_LOCK: + missing = [sid for sid in service_type_ids if sid not in _SOCIAL_VALUES_BY_ST] + if not missing: + return + results = await asyncio.gather( + *(urban_api_gateway.get_service_type_social_values(sid) for sid in missing) + ) + for sid, df in zip(missing, results): + _SOCIAL_VALUES_BY_ST[sid] = None if df is None else list(df.index) +async def _adapt_social_values(service_type_id: int) -> Optional[List[int]]: + await _warmup_social_values([service_type_id]) + return _SOCIAL_VALUES_BY_ST.get(service_type_id) async def adapt_service_types(service_types_df: pd.DataFrame) -> pd.DataFrame: df = service_types_df[["infrastructure_type"]].copy() df["infrastructure_weight"] = service_types_df["weight_value"] - service_type_ids = df.index.tolist() + service_type_ids: List[int] = list(df.index) names: List[Optional[str]] = await asyncio.gather( *(_adapt_name(st_id) for st_id in service_type_ids) ) df["name"] = names - df = df.dropna(subset=["name"]).copy() - social_vals: List[Optional[List[int]]] = await asyncio.gather( - *(_adapt_social_values(st_id) for st_id in df.index) - ) - df["social_values"] = social_vals + await _warmup_social_values(list(df.index)) + df["social_values"] = [ _SOCIAL_VALUES_BY_ST.get(st_id) for st_id in df.index ] return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] From e5cb365f67435645ca6e0b464f2f8519f12a0bfc Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 7 Sep 2025 14:49:59 +0300 Subject: [PATCH 068/161] feat(effects_service): 1. Logic for when single scenario is provided and when project_id with regional_scenario_id is provided --- app/effects_api/dto/development_dto.py | 22 +- app/effects_api/effects_controller.py | 18 +- app/effects_api/effects_service.py | 200 ++++++++++-------- .../schemas/socio_economic_response_schema.py | 20 +- app/gateways/urban_api_gateway.py | 10 +- 5 files changed, 158 insertions(+), 112 deletions(-) diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index 89b41f2..1966601 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -40,7 +40,27 @@ class ContextDevelopmentDTO(DevelopmentDTO): ) -class SocioEconomicPredictionDTO(ContextDevelopmentDTO): +class SocioEconomicByScenarioDTO(ContextDevelopmentDTO): + + split: bool = Field( + default=False, + examples=[False, True], + description="If split will return additional evaluation for each context mo", + ) + + +class SocioEconomicByProjectDTO(BaseModel): + project_id: int = Field( + ..., + examples=[120], + description="Project ID to retrieve data from.", + ) + + regional_scenario_id: int = Field( + ..., + examples=[122], + description="Regional scenario ID using for filtering.", + ) split: bool = Field( default=False, diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 503bd67..b9a0643 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -10,7 +10,8 @@ from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, - SocioEconomicPredictionDTO, + SocioEconomicByProjectDTO, + SocioEconomicByScenarioDTO ) from .dto.transformation_effects_dto import TerritoryTransformationDTO from .effects_service import effects_service @@ -41,13 +42,22 @@ async def get_context_development( @development_router.get( - "/socio_economic_prediction", response_model=SocioEconomicResponseSchema + "/project_socio_economic_prediction", response_model=SocioEconomicResponseSchema ) async def get_socio_economic_prediction( - params: Annotated[SocioEconomicPredictionDTO, Depends(SocioEconomicPredictionDTO)], + params: Annotated[SocioEconomicByProjectDTO, Depends(SocioEconomicByProjectDTO)], token: str = Depends(verify_token), ) -> SocioEconomicResponseSchema: - return await effects_service.evaluate_master_plan(params, token) + return await effects_service.evaluate_master_plan_by_project(params, token) + +@development_router.get( + "/scenario_socio_economic_prediction", response_model=SocioEconomicResponseSchema +) +async def get_socio_economic_prediction( + params: Annotated[SocioEconomicByScenarioDTO, Depends(SocioEconomicByScenarioDTO)], + token: str = Depends(verify_token), +) -> SocioEconomicResponseSchema: + return await effects_service.evaluate_master_plan_by_scenario(params, token) @development_router.get("/territory_transformation") diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 085ca82..f03fdb7 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,5 +1,5 @@ import json -from typing import Any +from typing import Any, Dict, Optional import geopandas as gpd import numpy as np @@ -44,7 +44,7 @@ from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, - SocioEconomicPredictionDTO, + SocioEconomicByProjectDTO, SocioEconomicByScenarioDTO, ) from .modules.context_service import ( get_context_blocks, @@ -88,7 +88,7 @@ async def build_hash_params( @staticmethod async def get_optimal_func_zone_data( - params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicPredictionDTO | TerritoryTransformationDTO, + params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicByProjectDTO | TerritoryTransformationDTO, token: str, ) -> DevelopmentDTO: """ @@ -449,117 +449,133 @@ async def run_social_reg_prediction( res = result_df.to_dict(orient="index") return SocioEconomicSchema(socio_economic_prediction=res) - async def evaluate_master_plan( - self, params: SocioEconomicPredictionDTO, token: str = None - ) -> SocioEconomicSchema: - """ - End-to-end pipeline that fuses *project* and *context* blocks, enriches - them with development parameters and produces socio-economic forecasts - via ``SocialRegressor``. - - Params: - params (ContextDevelopmentDTO): dto class containing following parameters: - scenario_id : int - Scenario to evaluate. - functional_zone_source, functional_zone_year : str | int | None - Source and year for **project** functional zones. - context_functional_zone_source, context_functional_zone_year : str | int | None - Source and year for **context** functional zones. - token : str | None, default None - Optional bearer token for Urban API. + async def evaluate_master_plan_by_project( + self, params: SocioEconomicByProjectDTO, token: Optional[str] = None + ) -> SocioEconomicResponseSchema: + project_id = params.project_id + regional_sid = params.regional_scenario_id + logger.info(f"[Effects] project mode: project_id={project_id}, regional_scenario_id={regional_sid}") - Returns: - SocioEconomicResponseSchema: - pd.DataFrame.to_dict(orient="index") representation as schema with additional params keys: - `pred`, `lower`, `upper`, `is_interval` - – predicted socio-economic indicator and its prediction interval. - - Workflow: - 1. Aggregate context blocks and project blocks. - 2. Merge them, clip land-use shares to 1. - 3. Compute development parameters (`run_development_parameters`). - 4. Feed summarised indicators into SocialRegressor. - """ - - logger.info("Evaluating master plan effects") - params = await self.get_optimal_func_zone_data(params, token) - project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) project_info = await urban_api_gateway.get_all_project_info(project_id, token) - context_blocks, context_buildings = await self.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, + context_territories = project_info.get("properties", {}).get("context") or [] + + base_sid = await urban_api_gateway.get_base_scenario_id(project_id) + ctx_src, ctx_year = await urban_api_gateway.get_optimal_func_zone_request_data( + token=token, data_id=base_sid, source=None, year=None, project=False ) + context_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) - scenario_blocks, scenario_buildings = ( - await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, + context_split: Optional[Dict[int, SocioEconomicSchema]] = None + if params.split and context_territories: + context_split = {} + for tid in context_territories: + territory = gpd.GeoDataFrame( + geometry=[await urban_api_gateway.get_territory_geometry(tid)], crs=4326 + ) + ter_blocks = ( + context_blocks.sjoin(territory.to_crs(territory.estimate_utm_crs()), how="left") + .dropna(subset="index_right").drop(columns="index_right") + ) + ter_data = [ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] + ter_input = pd.DataFrame(ter_data) + context_split[tid] = await self.run_social_reg_prediction(ter_blocks, ter_input) + + scenarios = await urban_api_gateway.get_project_scenarios(project_id, token) + target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == regional_sid] + logger.info(f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={regional_sid})") + + landuse_cols = ["residential", "business", "recreation", "industrial", "transport", "special", "agriculture"] + results_by_scenario: Dict[int, Dict[str, Any]] = {} + + for s in target: + sid = s["scenario_id"] + proj_src, proj_year = await urban_api_gateway.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=None, year=None, project=True ) + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario(sid, proj_src, proj_year, token) + scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + + blocks = gpd.GeoDataFrame(pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs) + blocks[landuse_cols] = blocks[landuse_cols].clip(upper=1) + development_df = await self.run_development_parameters(blocks) + + add_cols = ["build_floor_area", "footprint_area", "living_area", "non_living_area", "population"] + blocks[add_cols] = development_df[add_cols].values + + for lu in LandUse: + blocks[lu.value] = blocks[lu.value] * blocks["site_area"] + + main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] + main_input = pd.DataFrame(main_data) + main_res: SocioEconomicSchema = await self.run_social_reg_prediction(blocks, main_input) + + results_by_scenario[sid] = main_res.socio_economic_prediction + + return SocioEconomicResponseSchema( + socio_economic_prediction=results_by_scenario, + split_prediction=context_split or None, + params_data=params, ) - scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + async def evaluate_master_plan_by_scenario( + self, params: SocioEconomicByScenarioDTO, token: Optional[str] = None + ) -> SocioEconomicResponseSchema: + sid = params.scenario_id + logger.info(f"[Effects] legacy mode: scenario_id={sid}") - blocks = gpd.GeoDataFrame( - pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs, + project_id = await urban_api_gateway.get_project_id(sid, token) + project_info = await urban_api_gateway.get_all_project_info(project_id, token) + context_territories = project_info.get("properties", {}).get("context") or [] + + ctx_src, ctx_year = await urban_api_gateway.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=params.context_func_zone_source, year=params.context_func_source_year, + project=False + ) + proj_src, proj_year = await urban_api_gateway.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=params.proj_func_zone_source, year=params.proj_func_source_year, + project=True ) - cols = [ - "residential", - "business", - "recreation", - "industrial", - "transport", - "special", - "agriculture", - ] - - blocks[cols] = blocks[cols].clip(upper=1) + + context_blocks, _ = await self.aggregate_blocks_layer_context(sid, ctx_src, ctx_year, token) + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario(sid, proj_src, proj_year, token) + scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + + blocks = gpd.GeoDataFrame(pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs) + + landuse_cols = ["residential", "business", "recreation", "industrial", "transport", "special", "agriculture"] + blocks[landuse_cols] = blocks[landuse_cols].clip(upper=1) development_df = await self.run_development_parameters(blocks) - cols = [ - "build_floor_area", - "footprint_area", - "living_area", - "non_living_area", - "population", - ] - blocks[cols] = development_df[cols].values + add_cols = ["build_floor_area", "footprint_area", "living_area", "non_living_area", "population"] + blocks[add_cols] = development_df[add_cols].values + for lu in LandUse: blocks[lu.value] = blocks[lu.value] * blocks["site_area"] + main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] main_input = pd.DataFrame(main_data) - main_res = await self.run_social_reg_prediction(blocks, main_input) - context_results = {} - if params.split: - for context_ter_id in project_info["properties"]["context"]: + main_res: SocioEconomicSchema = await self.run_social_reg_prediction(blocks, main_input) + + split_results: Optional[Dict[int, SocioEconomicSchema]] = None + if params.split and context_territories: + split_results = {} + for tid in context_territories: territory = gpd.GeoDataFrame( - geometry=[ - await urban_api_gateway.get_territory_geometry(context_ter_id) - ], - crs=4326, + geometry=[await urban_api_gateway.get_territory_geometry(tid)], crs=4326 ) ter_blocks = ( - blocks.sjoin( - territory.to_crs(territory.estimate_utm_crs()), how="left" - ) - .dropna(subset="index_right") - .drop(columns="index_right") + blocks.sjoin(territory.to_crs(territory.estimate_utm_crs()), how="left") + .dropna(subset="index_right").drop(columns="index_right") ) - ter_data = [ - ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict() - ] + ter_data = [ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] ter_input = pd.DataFrame(ter_data) - context_results[context_ter_id] = await self.run_social_reg_prediction( - ter_blocks, ter_input - ) + split_results[tid] = await self.run_social_reg_prediction(ter_blocks, ter_input) return SocioEconomicResponseSchema( - socio_economic_prediction=main_res.socio_economic_prediction, - split_prediction=context_results if context_results else None, + socio_economic_prediction={sid: main_res.socio_economic_prediction}, + split_prediction=split_results or None, params_data=params, ) diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index 432902e..ec46280 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -1,9 +1,10 @@ -from typing import Optional +from typing import Optional, Dict, Union from pydantic import BaseModel, Field, field_validator, model_serializer from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO +from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO, SocioEconomicByProjectDTO, \ + SocioEconomicByScenarioDTO from .output_maps import pred_columns_names_map, soc_economy_pred_name_map @@ -60,15 +61,6 @@ def rename_attributes(cls, value: dict[str, SocioEconomicParams]): class SocioEconomicResponseSchema(SocioEconomicSchema): - """ - DTO Class for socio-economic response - Attributes: - socio_economic_prediction (dict[str, SocioEconomicParams]): where SocioEconomicParams is class containing fields - (pred: int, lower: int, upper: float, is_interval: bool) - split_prediction (Optional[list[SocioEconomicSchema]]): optional list of context predictions as list - of SocioEconomicSchema - params_data (DevelopmentDTO | ContextDevelopmentDTO): - """ - - split_prediction: Optional[dict[int, SocioEconomicSchema]] - params_data: DevelopmentDTO | ContextDevelopmentDTO + socio_economic_prediction: Dict[int, Dict[str, SocioEconomicParams]] + split_prediction: Optional[Dict[int, SocioEconomicSchema]] + params_data: Union[DevelopmentDTO, ContextDevelopmentDTO, SocioEconomicByProjectDTO, SocioEconomicByScenarioDTO] diff --git a/app/gateways/urban_api_gateway.py b/app/gateways/urban_api_gateway.py index f5e3a25..8f15d6c 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/gateways/urban_api_gateway.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, Literal, Optional, List import geopandas as gpd import pandas as pd @@ -382,3 +382,11 @@ async def get_base_scenario_id(self, project_id: int) -> int: raise http_exception(404, "base scenario not found", project_id) return base["scenario_id"] + + async def get_project_scenarios(self, project_id: int, token: Optional[str] = None) -> List[Dict[str, Any]]: + headers = {"Authorization": f"Bearer {token}"} if token else None + res = await self.json_handler.get( + f"/api/v1/projects/{project_id}/scenarios", + headers=headers, + ) + return res From aa655ecdf5c2e2030118c7ffeecadc7a659f53ce Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 7 Sep 2025 16:02:23 +0300 Subject: [PATCH 069/161] feat(project_evaluation): 1. Response schema now contains years and sources of each calculated child scenario --- app/effects_api/dto/development_dto.py | 11 +++++- app/effects_api/effects_service.py | 37 +++++++++++++------ .../schemas/socio_economic_response_schema.py | 10 ++++- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index 1966601..cbaf2e9 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional +from typing import Literal, Optional, Dict from pydantic import BaseModel, Field @@ -67,3 +67,12 @@ class SocioEconomicByProjectDTO(BaseModel): examples=[False, True], description="If split will return additional evaluation for each context mo", ) + +class SourceYear(BaseModel): + source: Literal["PZZ", "OSM", "User"] + year: int + +class SocioEconomicByProjectComputedDTO(SocioEconomicByProjectDTO): + context_func_zone_source: Literal["PZZ", "OSM", "User"] + context_func_source_year: int + project_sources: Dict[int, SourceYear] \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index f03fdb7..c5162f8 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -44,7 +44,7 @@ from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, - SocioEconomicByProjectDTO, SocioEconomicByScenarioDTO, + SocioEconomicByProjectDTO, SocioEconomicByScenarioDTO, SourceYear, SocioEconomicByProjectComputedDTO, ) from .modules.context_service import ( get_context_blocks, @@ -486,12 +486,14 @@ async def evaluate_master_plan_by_project( landuse_cols = ["residential", "business", "recreation", "industrial", "transport", "special", "agriculture"] results_by_scenario: Dict[int, Dict[str, Any]] = {} + project_sources: Dict[int, SourceYear] = {} for s in target: sid = s["scenario_id"] proj_src, proj_year = await urban_api_gateway.get_optimal_func_zone_request_data( token=token, data_id=sid, source=None, year=None, project=True ) + project_sources[sid] = SourceYear(source=proj_src, year=proj_year) scenario_blocks, _ = await self.aggregate_blocks_layer_scenario(sid, proj_src, proj_year, token) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) @@ -512,10 +514,19 @@ async def evaluate_master_plan_by_project( results_by_scenario[sid] = main_res.socio_economic_prediction + computed_params = SocioEconomicByProjectComputedDTO( + project_id=project_id, + regional_scenario_id=regional_sid, + split=params.split, + context_func_zone_source=ctx_src, + context_func_source_year=ctx_year, + project_sources=project_sources, + ) + return SocioEconomicResponseSchema( socio_economic_prediction=results_by_scenario, split_prediction=context_split or None, - params_data=params, + params_data=computed_params, ) async def evaluate_master_plan_by_scenario( @@ -527,18 +538,20 @@ async def evaluate_master_plan_by_scenario( project_id = await urban_api_gateway.get_project_id(sid, token) project_info = await urban_api_gateway.get_all_project_info(project_id, token) context_territories = project_info.get("properties", {}).get("context") or [] + params = await self.get_optimal_func_zone_data(params, token) - ctx_src, ctx_year = await urban_api_gateway.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=params.context_func_zone_source, year=params.context_func_source_year, - project=False - ) - proj_src, proj_year = await urban_api_gateway.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=params.proj_func_zone_source, year=params.proj_func_source_year, - project=True - ) + context_blocks, _ = await self.aggregate_blocks_layer_context( + sid, + params.context_func_zone_source, + params.proj_func_source_year, + token) + + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + sid, + params.proj_func_zone_source, + params.proj_func_source_year, + token) - context_blocks, _ = await self.aggregate_blocks_layer_context(sid, ctx_src, ctx_year, token) - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario(sid, proj_src, proj_year, token) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) blocks = gpd.GeoDataFrame(pd.concat([context_blocks, scenario_blocks], ignore_index=True), diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index ec46280..51a249a 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -4,7 +4,7 @@ from app.common.exceptions.http_exception_wrapper import http_exception from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO, SocioEconomicByProjectDTO, \ - SocioEconomicByScenarioDTO + SocioEconomicByScenarioDTO, SocioEconomicByProjectComputedDTO from .output_maps import pred_columns_names_map, soc_economy_pred_name_map @@ -63,4 +63,10 @@ def rename_attributes(cls, value: dict[str, SocioEconomicParams]): class SocioEconomicResponseSchema(SocioEconomicSchema): socio_economic_prediction: Dict[int, Dict[str, SocioEconomicParams]] split_prediction: Optional[Dict[int, SocioEconomicSchema]] - params_data: Union[DevelopmentDTO, ContextDevelopmentDTO, SocioEconomicByProjectDTO, SocioEconomicByScenarioDTO] + params_data: Union[ + DevelopmentDTO, + ContextDevelopmentDTO, + SocioEconomicByProjectDTO, + SocioEconomicByScenarioDTO, + SocioEconomicByProjectComputedDTO, + ] From 24914ee7ca78a0d82a2713905da32e9dab6e7269 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Wed, 10 Sep 2025 21:13:03 +0300 Subject: [PATCH 070/161] feat(comment_fixes): 1. Fixed some cases where token was Optional 2. Featured black + isort code style 3. Renamed urban_api_gateway.py to urban_api_client.py 4. All classes are now initializing in dependencies.py and getting called where needed 5. Separated socio-economic DTOs 6. routers now have separated prefixes f_... for each method --- app/{gateways => clients}/__init__.py | 0 .../urban_api_client.py} | 28 +- app/common/api_handlers/json_api_handler.py | 29 +- app/common/caching/caching_service.py | 5 +- app/common/dto/__init__.py | 0 app/common/dto/models.py | 8 + app/common/dto/types.py | 0 app/dependencies.py | 12 +- app/effects_api/constants/const.py | 10 +- app/effects_api/dto/development_dto.py | 40 +- .../dto/socio_economic_project_dto.py | 31 ++ .../dto/socio_economic_scenario_dto.py | 12 + app/effects_api/effects_controller.py | 29 +- app/effects_api/effects_service.py | 452 +++++++++++------- app/effects_api/modules/context_service.py | 106 ++-- app/effects_api/modules/scenario_service.py | 333 ++++++------- .../modules/service_type_service.py | 46 +- app/effects_api/modules/task_service.py | 26 +- .../schemas/socio_economic_response_schema.py | 13 +- app/effects_api/tasks_controller.py | 30 +- app/main.py | 13 +- requirements.txt | Bin 622 -> 722 bytes 22 files changed, 715 insertions(+), 508 deletions(-) rename app/{gateways => clients}/__init__.py (100%) rename app/{gateways/urban_api_gateway.py => clients/urban_api_client.py} (95%) create mode 100644 app/common/dto/__init__.py create mode 100644 app/common/dto/models.py create mode 100644 app/common/dto/types.py create mode 100644 app/effects_api/dto/socio_economic_project_dto.py create mode 100644 app/effects_api/dto/socio_economic_scenario_dto.py diff --git a/app/gateways/__init__.py b/app/clients/__init__.py similarity index 100% rename from app/gateways/__init__.py rename to app/clients/__init__.py diff --git a/app/gateways/urban_api_gateway.py b/app/clients/urban_api_client.py similarity index 95% rename from app/gateways/urban_api_gateway.py rename to app/clients/urban_api_client.py index 8f15d6c..7285175 100644 --- a/app/gateways/urban_api_gateway.py +++ b/app/clients/urban_api_client.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Literal, Optional, List +from typing import Any, Dict, List, Literal, Optional import geopandas as gpd import pandas as pd @@ -10,11 +10,11 @@ from app.common.exceptions.http_exception_wrapper import http_exception -class UrbanAPIGateway: +class UrbanAPIClient: - def __init__(self, base_url: str) -> None: - self.json_handler = JSONAPIHandler(base_url) - self.__name__ = "UrbanAPIGateway" + def __init__(self, json_handler: JSONAPIHandler) -> None: + self.json_handler = json_handler + self.__name__ = "UrbanAPIClient" # TODO context async def get_physical_objects( @@ -34,7 +34,9 @@ async def get_physical_objects( "physical_object_id" ) - async def get_services(self, scenario_id: int, token, **kwargs: Any) -> gpd.GeoDataFrame: + async def get_services( + self, scenario_id: int, token, **kwargs: Any + ) -> gpd.GeoDataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/services_with_geometry", @@ -46,7 +48,9 @@ async def get_services(self, scenario_id: int, token, **kwargs: Any) -> gpd.GeoD "service_id" ) - async def get_functional_zones_sources(self, scenario_id: int, token: str) -> pd.DataFrame: + async def get_functional_zones_sources( + self, scenario_id: int, token: str + ) -> pd.DataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/context/functional_zone_sources", @@ -55,7 +59,7 @@ async def get_functional_zones_sources(self, scenario_id: int, token: str) -> pd return pd.DataFrame(res) async def get_functional_zones( - self, scenario_id: int, year: int, source: int, token: str + self, scenario_id: int, year: int, source: str, token: str ) -> gpd.GeoDataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( @@ -275,9 +279,7 @@ async def get_project_id( return project_id - async def get_all_project_info( - self, project_id: int, token: Optional[str] = None - ) -> dict: + async def get_all_project_info(self, project_id: int, token: str) -> dict: url = f"/api/v1/projects/{project_id}" try: response = await self.json_handler.get( @@ -383,7 +385,9 @@ async def get_base_scenario_id(self, project_id: int) -> int: return base["scenario_id"] - async def get_project_scenarios(self, project_id: int, token: Optional[str] = None) -> List[Dict[str, Any]]: + async def get_project_scenarios( + self, project_id: int, token: str + ) -> List[Dict[str, Any]]: headers = {"Authorization": f"Bearer {token}"} if token else None res = await self.json_handler.get( f"/api/v1/projects/{project_id}/scenarios", diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index db91c37..678948d 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -17,38 +17,38 @@ def __init__( None """ - self.__name__ = "UrbanAPIGateway" + self.__name__ = "UrbanAPIClient" self.base_url = base_url @staticmethod async def _check_response_status( response: aiohttp.ClientResponse, ) -> list | dict | None: - """Function handles response - - Args: - response (aiohttp.ClientResponse): Response object - Returns: - list|dict: requested data with additional info, e.g. {"retry": True | False, "response": {response.json}} - Raises: - http_exception with response status code from API - """ - + """Function handles response""" if response.status in (200, 201): return await response.json(content_type="application/json") + elif response.status == 500: - if response.content_type == "application/json": + # определим тип контента по заголовку + content_type = (response.headers.get("Content-Type") or "").lower() + + if "application/json" in content_type: response_info = await response.json() - if "reset by peer" in await response_info["error"]: + err = response_info.get("error", "") + if isinstance(err, (dict, list)): + err = str(err) + if "reset by peer" in err: return None else: response_info = await response.text() + raise http_exception( response.status, "Couldn't get data from API", _input=str(response.url), _detail=response_info, ) + else: raise http_exception( response.status, @@ -163,6 +163,7 @@ async def post( endpoint_url=endpoint_url, headers=headers, params=params, + data=data, session=session, ) return result @@ -209,6 +210,7 @@ async def put( endpoint_url=endpoint_url, headers=headers, params=params, + data=data, session=session, ) return result @@ -255,6 +257,7 @@ async def delete( endpoint_url=endpoint_url, headers=headers, params=params, + data=data, session=session, ) return result diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 105b1b5..3b27b70 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -3,7 +3,7 @@ import re from datetime import datetime, timedelta from pathlib import Path -from typing import Any, Literal +from typing import Any _CACHE_DIR = Path().absolute() / "__effects_cache__" _CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -99,6 +99,3 @@ def has( self, method: str, scenario_id: int, max_age: timedelta | None = None ) -> bool: return self.load(method, scenario_id, max_age) is not None - - -cache = FileCache() diff --git a/app/common/dto/__init__.py b/app/common/dto/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/common/dto/models.py b/app/common/dto/models.py new file mode 100644 index 0000000..913fa0d --- /dev/null +++ b/app/common/dto/models.py @@ -0,0 +1,8 @@ +from typing import Literal + +from pydantic import BaseModel + + +class SourceYear(BaseModel): + source: Literal["PZZ", "OSM", "User"] + year: int diff --git a/app/common/dto/types.py b/app/common/dto/types.py new file mode 100644 index 0000000..e69de29 diff --git a/app/dependencies.py b/app/dependencies.py index 71ea9be..1483aec 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -4,7 +4,11 @@ from iduconfig import Config from loguru import logger -from app.gateways.urban_api_gateway import UrbanAPIGateway +from app.clients.urban_api_client import UrbanAPIClient +from app.common.api_handlers.json_api_handler import JSONAPIHandler +from app.common.caching.caching_service import FileCache +from app.effects_api.effects_service import EffectsService +from app.effects_api.modules.scenario_service import ScenarioService absolute_app_path = Path().absolute() config = Config() @@ -19,4 +23,8 @@ level="INFO", ) -urban_api_gateway = UrbanAPIGateway(config.get("URBAN_API")) +json_api_handler = JSONAPIHandler(config.get("URBAN_API")) +urban_api_client = UrbanAPIClient(json_api_handler) +file_cache = FileCache() +scenario_service = ScenarioService(urban_api_client) +effects_service = EffectsService(urban_api_client, file_cache, scenario_service) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index bea4960..d924a05 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -110,10 +110,10 @@ "population": [["properties", "population_balanced"]], } -#For each Infrastructure_type we will also add a weighting factor to give preference to the basic service, and another switch for capabilities. +# For each Infrastructure_type we will also add a weighting factor to give preference to the basic service, and another switch for capabilities. INFRASTRUCTURES_WEIGHTS = {"basic": 0.5714, "additional": 0.2857, "comfort": 0.1429} -#Mapping for translation of english provision properties +# Mapping for translation of english provision properties COL_RU = { "demand": "Спрос", "capacity": "Емкость сервисов", @@ -125,11 +125,11 @@ "capacity_without": "Емкость сервисов за пределами нормативной доступности", "provision_strong": "Обеспеченность сервисами", } -#ID of living building physical_object_type_id +# ID of living building physical_object_type_id LIVING_BUILDINGS_ID = 4 -#ID of road physical_object_function_id +# ID of road physical_object_function_id ROADS_ID = 26 -#ID of water objects physical_object_function_id +# ID of water objects physical_object_function_id WATER_ID = 4 diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index cbaf2e9..447adb3 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -1,4 +1,4 @@ -from typing import Literal, Optional, Dict +from typing import Literal, Optional from pydantic import BaseModel, Field @@ -38,41 +38,3 @@ class ContextDevelopmentDTO(DevelopmentDTO): examples=[2023, 2024], description="Year of the chosen context functional-zone source.", ) - - -class SocioEconomicByScenarioDTO(ContextDevelopmentDTO): - - split: bool = Field( - default=False, - examples=[False, True], - description="If split will return additional evaluation for each context mo", - ) - - -class SocioEconomicByProjectDTO(BaseModel): - project_id: int = Field( - ..., - examples=[120], - description="Project ID to retrieve data from.", - ) - - regional_scenario_id: int = Field( - ..., - examples=[122], - description="Regional scenario ID using for filtering.", - ) - - split: bool = Field( - default=False, - examples=[False, True], - description="If split will return additional evaluation for each context mo", - ) - -class SourceYear(BaseModel): - source: Literal["PZZ", "OSM", "User"] - year: int - -class SocioEconomicByProjectComputedDTO(SocioEconomicByProjectDTO): - context_func_zone_source: Literal["PZZ", "OSM", "User"] - context_func_source_year: int - project_sources: Dict[int, SourceYear] \ No newline at end of file diff --git a/app/effects_api/dto/socio_economic_project_dto.py b/app/effects_api/dto/socio_economic_project_dto.py new file mode 100644 index 0000000..b0b1aa3 --- /dev/null +++ b/app/effects_api/dto/socio_economic_project_dto.py @@ -0,0 +1,31 @@ +from typing import Dict, Literal + +from pydantic import BaseModel, Field + +from app.common.dto.models import SourceYear + + +class SocioEconomicByProjectDTO(BaseModel): + project_id: int = Field( + ..., + examples=[120], + description="Project ID to retrieve data from.", + ) + + regional_scenario_id: int = Field( + ..., + examples=[122], + description="Regional scenario ID using for filtering.", + ) + + split: bool = Field( + default=False, + examples=[False, True], + description="If split will return additional evaluation for each context mo", + ) + + +class SocioEconomicByProjectComputedDTO(SocioEconomicByProjectDTO): + context_func_zone_source: Literal["PZZ", "OSM", "User"] + context_func_source_year: int + project_sources: Dict[int, SourceYear] diff --git a/app/effects_api/dto/socio_economic_scenario_dto.py b/app/effects_api/dto/socio_economic_scenario_dto.py new file mode 100644 index 0000000..d2e9648 --- /dev/null +++ b/app/effects_api/dto/socio_economic_scenario_dto.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from app.effects_api.dto.development_dto import ContextDevelopmentDTO + + +class SocioEconomicByScenarioDTO(ContextDevelopmentDTO): + + split: bool = Field( + default=False, + examples=[False, True], + description="If split will return additional evaluation for each context mo", + ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index b9a0643..45e88b6 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -7,18 +7,22 @@ from app.common.auth.auth import verify_token +from ..dependencies import effects_service from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, - SocioEconomicByProjectDTO, - SocioEconomicByScenarioDTO ) +from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO +from .dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO from .dto.transformation_effects_dto import TerritoryTransformationDTO -from .effects_service import effects_service from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema development_router = APIRouter(prefix="/development", tags=["Effects"]) +f_22_router = APIRouter(prefix="/f22", tags=["Effects"]) +f_26_router = APIRouter(prefix="/f26", tags=["Effects"]) +f_35_router = APIRouter(prefix="/f35", tags=["Effects"]) +f_36_router = APIRouter(prefix="/f36", tags=["Effects"]) @development_router.get( @@ -41,7 +45,7 @@ async def get_context_development( return await effects_service.calc_context_development(token, params) -@development_router.get( +@f_22_router.get( "/project_socio_economic_prediction", response_model=SocioEconomicResponseSchema ) async def get_socio_economic_prediction( @@ -50,7 +54,8 @@ async def get_socio_economic_prediction( ) -> SocioEconomicResponseSchema: return await effects_service.evaluate_master_plan_by_project(params, token) -@development_router.get( + +@f_22_router.get( "/scenario_socio_economic_prediction", response_model=SocioEconomicResponseSchema ) async def get_socio_economic_prediction( @@ -60,7 +65,7 @@ async def get_socio_economic_prediction( return await effects_service.evaluate_master_plan_by_scenario(params, token) -@development_router.get("/territory_transformation") +@f_35_router.get("/territory_transformation") async def territory_transformation( params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], token: str = Depends(verify_token), @@ -71,16 +76,18 @@ async def territory_transformation( geojson_dict = json.loads(gdf.to_json(drop_id=True)) return JSONResponse(content=geojson_dict) -@development_router.get("/values_development") + +@f_26_router.get("/values_development") async def values_development( params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], - token: str = Depends(verify_token) + token: str = Depends(verify_token), ): return await effects_service.values_transformation(token, params) -@development_router.get("/values_oriented_requirements") + +@f_36_router.get("/values_oriented_requirements") async def values_requirements( - params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], - token: str = Depends(verify_token) + params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], + token: str = Depends(verify_token), ): return await effects_service.values_oriented_requirements(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c5162f8..5a03f83 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -27,25 +27,25 @@ ) from loguru import logger -from app.dependencies import urban_api_gateway -from app.effects_api.modules.scenario_service import ( - get_scenario_blocks, - get_scenario_buildings, - get_scenario_functional_zones, - get_scenario_services, -) +from app.effects_api.modules.scenario_service import ScenarioService from app.effects_api.modules.service_type_service import adapt_service_types -from .dto.transformation_effects_dto import TerritoryTransformationDTO -from ..common.caching.caching_service import cache +from ..clients.urban_api_client import UrbanAPIClient +from ..common.caching.caching_service import FileCache +from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, - SocioEconomicByProjectDTO, SocioEconomicByScenarioDTO, SourceYear, SocioEconomicByProjectComputedDTO, ) +from .dto.socio_economic_project_dto import ( + SocioEconomicByProjectComputedDTO, + SocioEconomicByProjectDTO, +) +from .dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO +from .dto.transformation_effects_dto import TerritoryTransformationDTO from .modules.context_service import ( get_context_blocks, get_context_buildings, @@ -60,12 +60,17 @@ class EffectsService: - def __init__( self, + urban_api_client: UrbanAPIClient, + cache: FileCache, + scenario_service: ScenarioService, ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() + self.urban_api_client = urban_api_client + self.cache = cache + self.scenario = scenario_service async def build_hash_params( self, @@ -73,11 +78,11 @@ async def build_hash_params( token: str, ) -> dict: project_id = ( - await urban_api_gateway.get_scenario_info(params.scenario_id, token) + await self.urban_api_client.get_scenario_info(params.scenario_id, token) )["project"]["project_id"] - base_scenario_id = await urban_api_gateway.get_base_scenario_id(project_id) + base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) base_src, base_year = ( - await urban_api_gateway.get_optimal_func_zone_request_data( + await self.urban_api_client.get_optimal_func_zone_request_data( token, base_scenario_id, None, None ) ) @@ -86,9 +91,14 @@ async def build_hash_params( "base_func_zone_year": base_year, } - @staticmethod async def get_optimal_func_zone_data( - params: DevelopmentDTO | ContextDevelopmentDTO | SocioEconomicByProjectDTO | TerritoryTransformationDTO, + self, + params: ( + DevelopmentDTO + | ContextDevelopmentDTO + | SocioEconomicByProjectDTO + | TerritoryTransformationDTO + ), token: str, ) -> DevelopmentDTO: """ @@ -103,7 +113,7 @@ async def get_optimal_func_zone_data( if not params.proj_func_zone_source or not params.proj_func_source_year: (params.proj_func_zone_source, params.proj_func_source_year) = ( - await urban_api_gateway.get_optimal_func_zone_request_data( + await self.urban_api_client.get_optimal_func_zone_request_data( token, params.scenario_id, params.proj_func_zone_source, @@ -118,7 +128,7 @@ async def get_optimal_func_zone_data( ( params.context_func_zone_source, params.context_func_source_year, - ) = await urban_api_gateway.get_optimal_func_zone_request_data( + ) = await self.urban_api_client.get_optimal_func_zone_request_data( token, params.scenario_id, params.context_func_zone_source, @@ -128,33 +138,32 @@ async def get_optimal_func_zone_data( return params return params - @staticmethod - async def load_blocks_scenario(scenario_id: int, token: str) -> gpd.GeoDataFrame: - gdf = await get_scenario_blocks(scenario_id, token) + async def load_blocks_scenario( + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: + gdf = await self.scenario.get_scenario_blocks(scenario_id, token) gdf["site_area"] = gdf.area return gdf - @staticmethod async def assign_land_use_to_blocks_scenario( + self, blocks: gpd.GeoDataFrame, scenario_id: int, source: str | None, year: int | None, token: str, ) -> gpd.GeoDataFrame: - - fzones = await get_scenario_functional_zones(scenario_id, token, source, year) + fzones = await self.scenario.get_scenario_functional_zones( + scenario_id, token, source, year + ) fzones = fzones.to_crs(blocks.crs) - lu = assign_land_use(blocks, fzones, LAND_USE_RULES) return blocks.join(lu.drop(columns=["geometry"])) - @staticmethod async def enrich_with_buildings_scenario( - blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - - buildings = await get_scenario_buildings(scenario_id, token) + buildings = await self.scenario.get_scenario_buildings(scenario_id, token) if buildings is None: blocks["count_buildings"] = 0 return blocks, None @@ -170,17 +179,14 @@ async def enrich_with_buildings_scenario( blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) if "is_living" not in blocks.columns: blocks["is_living"] = None - return blocks, buildings - @staticmethod async def enrich_with_services_scenario( - blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: - - stypes = await urban_api_gateway.get_service_types() - stypes = await adapt_service_types(stypes) - sdict = await get_scenario_services(scenario_id, stypes, token) + stypes = await self.urban_api_client.get_service_types() + stypes = await adapt_service_types(stypes, self.urban_api_client) + sdict = await self.scenario.get_scenario_services(scenario_id, stypes, token) for stype, services in sdict.items(): services = services.to_crs(blocks.crs) @@ -190,10 +196,7 @@ async def enrich_with_services_scenario( ) blocks = blocks.join( b_srv.drop(columns=["geometry"]).rename( - columns={ - "capacity": f"capacity_{stype}", - "count": f"count_{stype}", - } + columns={"capacity": f"capacity_{stype}", "count": f"count_{stype}"} ) ) return blocks @@ -227,34 +230,38 @@ async def aggregate_blocks_layer_scenario( return blocks, buildings - @staticmethod async def load_context_blocks( - scenario_id: int, token: str + self, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, int]: - project_id = await urban_api_gateway.get_project_id(scenario_id, token) - blocks = await get_context_blocks(project_id, scenario_id, token) + project_id = await self.urban_api_client.get_project_id(scenario_id, token) + blocks = await get_context_blocks( + project_id, scenario_id, token, self.urban_api_client + ) blocks["site_area"] = blocks.area return blocks, project_id - @staticmethod async def assign_land_use_context( + self, blocks: gpd.GeoDataFrame, scenario_id: int, source: str | None, year: int | None, token: str, ) -> gpd.GeoDataFrame: - fzones = await get_context_functional_zones(scenario_id, source, year, token) + fzones = await get_context_functional_zones( + scenario_id, source, year, token, self.urban_api_client + ) fzones = fzones.to_crs(blocks.crs) lu = assign_land_use(blocks, fzones, LAND_USE_RULES) return blocks.join(lu.drop(columns=["geometry"])) - @staticmethod async def enrich_with_context_buildings( - blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - buildings = await get_context_buildings(scenario_id, token) + buildings = await get_context_buildings( + scenario_id, token, self.urban_api_client + ) if buildings is None: blocks["count_buildings"] = 0 blocks["is_living"] = None @@ -272,14 +279,15 @@ async def enrich_with_context_buildings( return blocks, buildings - @staticmethod async def enrich_with_context_services( - blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: - stypes = await urban_api_gateway.get_service_types() - stypes = await adapt_service_types(stypes) - sdict = await get_context_services(scenario_id, stypes, token) + stypes = await self.urban_api_client.get_service_types() + stypes = await adapt_service_types(stypes, self.urban_api_client) + sdict = await get_context_services( + scenario_id, stypes, token, self.urban_api_client + ) for stype, services in sdict.items(): services = services.to_crs(blocks.crs) @@ -315,7 +323,9 @@ async def aggregate_blocks_layer_context( ) logger.info("Aggregating buildings for context") - blocks, buildings = await self.enrich_with_context_buildings(blocks, scenario_id, token) + blocks, buildings = await self.enrich_with_context_buildings( + blocks, scenario_id, token + ) logger.info("Aggregating services for context") blocks = await self.enrich_with_context_services(blocks, scenario_id, token) @@ -323,8 +333,7 @@ async def aggregate_blocks_layer_context( logger.success(f"[Context {scenario_id}] blocks layer ready") return blocks, buildings - @staticmethod - async def get_services_layer(scenario_id: int, token: str): + async def get_services_layer(self, scenario_id: int, token: str): """ Fetch every service layer for a scenario, aggregate counts/capacities into the scenario blocks and return the resulting block layer. @@ -339,14 +348,16 @@ async def get_services_layer(scenario_id: int, token: str): `capacity_` and `count_` for each detected service category. """ - blocks = await get_scenario_blocks(scenario_id, token) + blocks = await self.scenario.get_scenario_blocks(scenario_id, token) blocks_crs = blocks.crs logger.info( f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}" ) - service_types = await urban_api_gateway.get_service_types() + service_types = await self.urban_api_client.get_service_types() logger.info(f"{service_types}") - services_dict = await get_scenario_services(scenario_id, service_types, token) + services_dict = await self.scenario.get_scenario_services( + scenario_id, service_types, token + ) for service_type, services in services_dict.items(): services = services.to_crs(blocks_crs) @@ -450,59 +461,101 @@ async def run_social_reg_prediction( return SocioEconomicSchema(socio_economic_prediction=res) async def evaluate_master_plan_by_project( - self, params: SocioEconomicByProjectDTO, token: Optional[str] = None + self, params: SocioEconomicByProjectDTO, token: str ) -> SocioEconomicResponseSchema: - project_id = params.project_id - regional_sid = params.regional_scenario_id - logger.info(f"[Effects] project mode: project_id={project_id}, regional_scenario_id={regional_sid}") + logger.info( + f"[Effects] project mode: project_id={params.project_id}, regional_scenario_id={params.regional_scenario_id}" + ) - project_info = await urban_api_gateway.get_all_project_info(project_id, token) + project_info = await self.urban_api_client.get_all_project_info( + params.project_id, token + ) context_territories = project_info.get("properties", {}).get("context") or [] - base_sid = await urban_api_gateway.get_base_scenario_id(project_id) - ctx_src, ctx_year = await urban_api_gateway.get_optimal_func_zone_request_data( - token=token, data_id=base_sid, source=None, year=None, project=False + base_sid = await self.urban_api_client.get_base_scenario_id(params.project_id) + ctx_src, ctx_year = ( + await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=base_sid, source=None, year=None, project=False + ) + ) + context_blocks, _ = await self.aggregate_blocks_layer_context( + base_sid, ctx_src, ctx_year, token ) - context_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) context_split: Optional[Dict[int, SocioEconomicSchema]] = None if params.split and context_territories: context_split = {} for tid in context_territories: territory = gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_territory_geometry(tid)], crs=4326 + geometry=[await self.urban_api_client.get_territory_geometry(tid)], + crs=4326, ) ter_blocks = ( - context_blocks.sjoin(territory.to_crs(territory.estimate_utm_crs()), how="left") - .dropna(subset="index_right").drop(columns="index_right") + context_blocks.sjoin( + territory.to_crs(territory.estimate_utm_crs()), how="left" + ) + .dropna(subset="index_right") + .drop(columns="index_right") ) - ter_data = [ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] + ter_data = [ + ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict() + ] ter_input = pd.DataFrame(ter_data) - context_split[tid] = await self.run_social_reg_prediction(ter_blocks, ter_input) + context_split[tid] = await self.run_social_reg_prediction( + ter_blocks, ter_input + ) - scenarios = await urban_api_gateway.get_project_scenarios(project_id, token) - target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == regional_sid] - logger.info(f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={regional_sid})") + scenarios = await self.urban_api_client.get_project_scenarios( + params.project_id, token + ) + target = [ + s + for s in scenarios + if (s.get("parent_scenario") or {}).get("id") == params.regional_scenario_id + ] + logger.info( + f"[Effects] matched {len(target)} scenarios in project {params.project_id} (parent={params.regional_scenario_id})" + ) - landuse_cols = ["residential", "business", "recreation", "industrial", "transport", "special", "agriculture"] + landuse_cols = [ + "residential", + "business", + "recreation", + "industrial", + "transport", + "special", + "agriculture", + ] results_by_scenario: Dict[int, Dict[str, Any]] = {} project_sources: Dict[int, SourceYear] = {} for s in target: sid = s["scenario_id"] - proj_src, proj_year = await urban_api_gateway.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=None, year=None, project=True + proj_src, proj_year = ( + await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=None, year=None, project=True + ) ) project_sources[sid] = SourceYear(source=proj_src, year=proj_year) - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario(sid, proj_src, proj_year, token) + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + sid, proj_src, proj_year, token + ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - blocks = gpd.GeoDataFrame(pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs) + blocks = gpd.GeoDataFrame( + pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs, + ) blocks[landuse_cols] = blocks[landuse_cols].clip(upper=1) development_df = await self.run_development_parameters(blocks) - add_cols = ["build_floor_area", "footprint_area", "living_area", "non_living_area", "population"] + add_cols = [ + "build_floor_area", + "footprint_area", + "living_area", + "non_living_area", + "population", + ] blocks[add_cols] = development_df[add_cols].values for lu in LandUse: @@ -510,13 +563,15 @@ async def evaluate_master_plan_by_project( main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] main_input = pd.DataFrame(main_data) - main_res: SocioEconomicSchema = await self.run_social_reg_prediction(blocks, main_input) + main_res: SocioEconomicSchema = await self.run_social_reg_prediction( + blocks, main_input + ) results_by_scenario[sid] = main_res.socio_economic_prediction computed_params = SocioEconomicByProjectComputedDTO( - project_id=project_id, - regional_scenario_id=regional_sid, + project_id=params.project_id, + regional_scenario_id=params.regional_scenario_id, split=params.split, context_func_zone_source=ctx_src, context_func_source_year=ctx_year, @@ -530,38 +585,52 @@ async def evaluate_master_plan_by_project( ) async def evaluate_master_plan_by_scenario( - self, params: SocioEconomicByScenarioDTO, token: Optional[str] = None + self, params: SocioEconomicByScenarioDTO, token: str ) -> SocioEconomicResponseSchema: sid = params.scenario_id logger.info(f"[Effects] legacy mode: scenario_id={sid}") - project_id = await urban_api_gateway.get_project_id(sid, token) - project_info = await urban_api_gateway.get_all_project_info(project_id, token) + project_id = await self.urban_api_client.get_project_id(sid, token) + project_info = await self.urban_api_client.get_all_project_info( + project_id, token + ) context_territories = project_info.get("properties", {}).get("context") or [] params = await self.get_optimal_func_zone_data(params, token) context_blocks, _ = await self.aggregate_blocks_layer_context( - sid, - params.context_func_zone_source, - params.proj_func_source_year, - token) + sid, params.context_func_zone_source, params.proj_func_source_year, token + ) scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - sid, - params.proj_func_zone_source, - params.proj_func_source_year, - token) + sid, params.proj_func_zone_source, params.proj_func_source_year, token + ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - blocks = gpd.GeoDataFrame(pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs) + blocks = gpd.GeoDataFrame( + pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs, + ) - landuse_cols = ["residential", "business", "recreation", "industrial", "transport", "special", "agriculture"] + landuse_cols = [ + "residential", + "business", + "recreation", + "industrial", + "transport", + "special", + "agriculture", + ] blocks[landuse_cols] = blocks[landuse_cols].clip(upper=1) development_df = await self.run_development_parameters(blocks) - add_cols = ["build_floor_area", "footprint_area", "living_area", "non_living_area", "population"] + add_cols = [ + "build_floor_area", + "footprint_area", + "living_area", + "non_living_area", + "population", + ] blocks[add_cols] = development_df[add_cols].values for lu in LandUse: @@ -569,22 +638,32 @@ async def evaluate_master_plan_by_scenario( main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] main_input = pd.DataFrame(main_data) - main_res: SocioEconomicSchema = await self.run_social_reg_prediction(blocks, main_input) + main_res: SocioEconomicSchema = await self.run_social_reg_prediction( + blocks, main_input + ) split_results: Optional[Dict[int, SocioEconomicSchema]] = None if params.split and context_territories: split_results = {} for tid in context_territories: territory = gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_territory_geometry(tid)], crs=4326 + geometry=[await self.urban_api_client.get_territory_geometry(tid)], + crs=4326, ) ter_blocks = ( - blocks.sjoin(territory.to_crs(territory.estimate_utm_crs()), how="left") - .dropna(subset="index_right").drop(columns="index_right") + blocks.sjoin( + territory.to_crs(territory.estimate_utm_crs()), how="left" + ) + .dropna(subset="index_right") + .drop(columns="index_right") ) - ter_data = [ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] + ter_data = [ + ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict() + ] ter_input = pd.DataFrame(ter_data) - split_results[tid] = await self.run_social_reg_prediction(ter_blocks, ter_input) + split_results[tid] = await self.run_social_reg_prediction( + ter_blocks, ter_input + ) return SocioEconomicResponseSchema( socio_economic_prediction={sid: main_res.socio_economic_prediction}, @@ -686,15 +765,15 @@ async def territory_transformation_scenario_before( ): method_name = "territory_transformation" - info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] project_id = info["project"]["project_id"] - base_scenario_id = await urban_api_gateway.get_base_scenario_id(project_id) + base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) params = await self.get_optimal_func_zone_data(params, token) base_src, base_year = ( - await urban_api_gateway.get_optimal_func_zone_request_data( + await self.urban_api_client.get_optimal_func_zone_request_data( token, base_scenario_id, None, None ) ) @@ -703,9 +782,9 @@ async def territory_transformation_scenario_before( "base_func_zone_source": base_src, "base_func_zone_year": base_year, } - phash = cache.params_hash(params_for_hash) + phash = self.cache.params_hash(params_for_hash) - cached = cache.load(method_name, params.scenario_id, phash) + cached = self.cache.load(method_name, params.scenario_id, phash) if ( cached and cached["meta"]["scenario_updated_at"] == updated_at @@ -715,15 +794,15 @@ async def territory_transformation_scenario_before( logger.info("Cache stale or missing: recalculating BEFORE") - service_types = await urban_api_gateway.get_service_types() - service_types = await adapt_service_types(service_types) + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) service_types = service_types[ ~service_types["infrastructure_type"].isna() ].copy() params = await self.get_optimal_func_zone_data(params, token) base_src, base_year = ( - await urban_api_gateway.get_optimal_func_zone_request_data( + await self.urban_api_client.get_optimal_func_zone_request_data( token, base_scenario_id, None, None ) ) @@ -761,7 +840,7 @@ async def territory_transformation_scenario_before( n: gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items() } - cache.save( + self.cache.save( method_name, params.scenario_id, params_for_hash, @@ -771,12 +850,11 @@ async def territory_transformation_scenario_before( return prov_gdfs_before - def _build_facade( - self, - after_blocks: gpd.GeoDataFrame, - acc_mx: pd.DataFrame, - service_types: pd.DataFrame, + self, + after_blocks: gpd.GeoDataFrame, + acc_mx: pd.DataFrame, + service_types: pd.DataFrame, ) -> Facade: blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() @@ -796,7 +874,9 @@ def _build_facade( st_column = f"capacity_{st_name}" if st_column in after_blocks.columns: - df = after_blocks.rename(columns={st_column: "capacity"})[["capacity"]].fillna(0) + df = after_blocks.rename(columns={st_column: "capacity"})[ + ["capacity"] + ].fillna(0) else: df = after_blocks[[]].copy() df["capacity"] = 0 @@ -810,19 +890,21 @@ async def territory_transformation_scenario_after( # provision after method_name = "territory_transformation" - info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] is_based = info["is_based"] if is_based: - raise http_exception(400, "base scenario has no 'after' layer") + raise http_exception( + 400, "base scenario has no 'after' layer needed for calculation" + ) params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) - phash = cache.params_hash(params_for_hash) + phash = self.cache.params_hash(params_for_hash) - cached = cache.load(method_name, params.scenario_id, phash) + cached = self.cache.load(method_name, params.scenario_id, phash) if ( cached and cached["meta"]["scenario_updated_at"] == updated_at @@ -835,8 +917,8 @@ async def territory_transformation_scenario_after( logger.info("AFTER: cache stale or missing; recalculating") - service_types = await urban_api_gateway.get_service_types() - service_types = await adapt_service_types(service_types) + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) service_types = service_types[ ~service_types["infrastructure_type"].isna() ].copy() @@ -916,7 +998,7 @@ async def territory_transformation_scenario_after( "best_x": best_x, } - cache.save( + self.cache.save( method_name, params.scenario_id, params_for_hash, @@ -932,7 +1014,7 @@ async def territory_transformation( params: ContextDevelopmentDTO, ) -> dict[str, Any] | dict[str, dict[str, Any]]: - info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) is_based = info["is_based"] updated_at = info["updated_at"] @@ -941,9 +1023,9 @@ async def territory_transformation( return prov_before params_for_hash = await self.build_hash_params(params, token) - phash = cache.params_hash(params_for_hash) + phash = self.cache.params_hash(params_for_hash) - cached = cache.load("territory_transformation", params.scenario_id, phash) + cached = self.cache.load("territory_transformation", params.scenario_id, phash) if ( cached and cached["meta"]["scenario_updated_at"] == updated_at @@ -959,31 +1041,31 @@ async def territory_transformation( return {"before": prov_before, "after": prov_after} async def values_transformation( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ) -> pd.DataFrame: method_name = "territory_transformation" params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) - phash = cache.params_hash(params_for_hash) + phash = self.cache.params_hash(params_for_hash) - info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] - cached = cache.load(method_name, params.scenario_id, phash) + cached = self.cache.load(method_name, params.scenario_id, phash) need_refresh = ( - not cached - or cached["meta"]["scenario_updated_at"] != updated_at - or "opt_context" not in cached["data"] - or "best_x" not in cached["data"]["opt_context"] + not cached + or cached["meta"]["scenario_updated_at"] != updated_at + or "opt_context" not in cached["data"] + or "best_x" not in cached["data"]["opt_context"] ) if need_refresh: await self.territory_transformation_scenario_after(token, params) - cached = cache.load(method_name, params.scenario_id, phash) + cached = self.cache.load(method_name, params.scenario_id, phash) best_x = cached["data"]["opt_context"]["best_x"] @@ -999,30 +1081,31 @@ async def values_transformation( params.proj_func_source_year, token, ) - after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) - after_blocks["is_project"] = after_blocks["is_project"].fillna(False).astype(bool) + after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( + drop=True + ) + after_blocks["is_project"] = ( + after_blocks["is_project"].fillna(False).astype(bool) + ) graph = get_accessibility_graph(after_blocks, "intermodal") acc_mx = calculate_accessibility_matrix(after_blocks, graph) - service_types = await urban_api_gateway.get_service_types() - service_types = await adapt_service_types(service_types) - service_types = service_types[~service_types["infrastructure_type"].isna()].copy() + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[ + ~service_types["infrastructure_type"].isna() + ].copy() service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) facade = self._build_facade(after_blocks, acc_mx, service_types) solution_df = facade.solution_to_services_df(best_x) - # if params.required_service: - # solution_df = solution_df.loc[solution_df["service_type"] == params.required_service] - - result = json.loads( - solution_df.to_json(orient="records", date_format="iso") - ) + result = json.loads(solution_df.to_json(orient="records", date_format="iso")) return result def _get_value_level(self, provisions: list[float | None]) -> float: @@ -1030,29 +1113,37 @@ def _get_value_level(self, provisions: list[float | None]) -> float: return float(np.mean(vals)) if vals else np.nan async def values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ): - service_type = params.service_type + # service_type = params.service_type method_name = "values_oriented_requirements" params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) - phash = cache.params_hash(params_for_hash) + phash = self.cache.params_hash(params_for_hash) - info = await urban_api_gateway.get_scenario_info(params.scenario_id, token) + info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] - cached = cache.load(method_name, params.scenario_id, phash) - if cached and cached["meta"].get("scenario_updated_at") == updated_at and "result" in cached["data"]: + cached = self.cache.load(method_name, params.scenario_id, phash) + if ( + cached + and cached["meta"].get("scenario_updated_at") == updated_at + and "result" in cached["data"] + ): payload = cached["data"]["result"] - result_df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) + result_df = pd.DataFrame( + data=payload["data"], index=payload["index"], columns=payload["columns"] + ) result_df.index.name = payload.get("index_name", None) return result_df - project_id = await urban_api_gateway.get_project_id(params.scenario_id, token) + project_id = await self.urban_api_client.get_project_id( + params.scenario_id, token + ) context_blocks, context_buildings = await self.aggregate_blocks_layer_context( params.scenario_id, @@ -1061,11 +1152,13 @@ async def values_oriented_requirements( token, ) - scenario_blocks, scenario_buildings = await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, + scenario_blocks, scenario_buildings = ( + await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) @@ -1086,8 +1179,8 @@ async def values_oriented_requirements( social_values_provisions: dict[str, list[float | None]] = {} provisions_gdfs: dict[str, gpd.GeoDataFrame] = {} - service_types = await urban_api_gateway.get_service_types() - service_types = await adapt_service_types(service_types) + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) service_types = service_types[~service_types["social_values"].isna()].copy() graph = get_accessibility_graph(blocks, "intermodal") @@ -1113,7 +1206,10 @@ async def values_oriented_requirements( index = list(social_values_provisions.keys()) result_df = pd.DataFrame( - data=[self._get_value_level(social_values_provisions[sv_id]) for sv_id in index], + data=[ + self._get_value_level(social_values_provisions[sv_id]) + for sv_id in index + ], index=index, columns=["social_value_level"], ) @@ -1125,7 +1221,7 @@ async def values_oriented_requirements( "data": result_df.values.tolist(), "index_name": result_df.index.name, } - cache.save( + self.cache.save( method_name, params.scenario_id, params_for_hash, @@ -1133,8 +1229,4 @@ async def values_oriented_requirements( scenario_updated_at=updated_at, ) - return result_df - - -effects_service = EffectsService() diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 874c6ab..1a33a4e 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -3,59 +3,59 @@ from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services -from app.dependencies import urban_api_gateway +from app.clients.urban_api_client import UrbanAPIClient from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones -from app.effects_api.modules.scenario_service import ( - _get_best_functional_zones_source, - close_gaps, -) +from app.effects_api.modules.scenario_service import close_gaps from app.effects_api.modules.services_service import adapt_services +_SOURCES_PRIORITY = ["PZZ", "OSM", "User"] -async def _get_project_boundaries(scenario_id: int, token: str): - return gpd.GeoDataFrame( - geometry=[ - await urban_api_gateway.get_project_geometry(scenario_id, token=token) - ], - crs=4326, - ) + +async def _get_project_boundaries( + project_id: int, token: str, client: UrbanAPIClient +) -> gpd.GeoDataFrame: + geom = await client.get_project_geometry(project_id, token=token) + return gpd.GeoDataFrame(geometry=[geom], crs=4326) -async def _get_context_boundaries(scenario_id: int, token: str) -> gpd.GeoDataFrame: - project = await urban_api_gateway.get_project(scenario_id, token) +async def _get_context_boundaries( + project_id: int, token: str, client: UrbanAPIClient +) -> gpd.GeoDataFrame: + project = await client.get_project(project_id, token) context_ids = project["properties"]["context"] geometries = [ - await urban_api_gateway.get_territory_geometry(territory_id) + await client.get_territory_geometry(territory_id) for territory_id in context_ids ] return gpd.GeoDataFrame(geometry=geometries, crs=4326) -async def _get_context_roads(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects( +async def _get_context_roads(scenario_id: int, token: str, client: UrbanAPIClient): + gdf = await client.get_physical_objects( scenario_id, token, physical_object_function_id=ROADS_ID ) return gdf[["geometry"]].reset_index(drop=True) -async def _get_context_water(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects( +async def _get_context_water(scenario_id: int, token: str, client: UrbanAPIClient): + gdf = await client.get_physical_objects( scenario_id, token=token, physical_object_function_id=WATER_ID ) return gdf[["geometry"]].reset_index(drop=True) async def _get_context_blocks( - scenario_id: int, boundaries: gpd.GeoDataFrame, token + scenario_id: int, boundaries: gpd.GeoDataFrame, token: str, client: UrbanAPIClient ) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await _get_context_water(scenario_id, token) + water = await _get_context_water(scenario_id, token, client) water = water.to_crs(crs) - roads = await _get_context_roads(scenario_id, token) + + roads = await _get_context_roads(scenario_id, token, client) roads = roads.to_crs(crs) roads.geometry = close_gaps(roads, 1) @@ -64,9 +64,11 @@ async def _get_context_blocks( return blocks -async def get_context_blocks(project_id: int, scenario_id: int, token): - project_boundaries = await _get_project_boundaries(project_id, token) - context_boundaries = await _get_context_boundaries(project_id, token) +async def get_context_blocks( + project_id: int, scenario_id: int, token: str, client: UrbanAPIClient +) -> gpd.GeoDataFrame: + project_boundaries = await _get_project_boundaries(project_id, token, client) + context_boundaries = await _get_context_boundaries(project_id, token, client) crs = context_boundaries.estimate_utm_crs() context_boundaries = context_boundaries.to_crs(crs) @@ -75,15 +77,49 @@ async def get_context_blocks(project_id: int, scenario_id: int, token): context_boundaries = context_boundaries.overlay( project_boundaries, how="difference" ) - return await _get_context_blocks(scenario_id, context_boundaries, token) + return await _get_context_blocks(scenario_id, context_boundaries, token, client) + + +def _choose_best_fz_source( + sources_df: pd.DataFrame, source: str | None, year: int | None +) -> tuple[int, str]: + df = sources_df.copy() + if source and year: + row = df.query("source == @source and year == @year") + if not row.empty: + return int(year), str(source) + source = None + + if source and year is None: + rows = df.query("source == @source") + if not rows.empty: + return int(rows["year"].max()), str(source) + source = None + + if year is not None and source is None: + for s in _SOURCES_PRIORITY: + row = df.query("source == @s and year == @year") + if not row.empty: + return int(year), s + + for s in _SOURCES_PRIORITY: + rows = df.query("source == @s") + if not rows.empty: + return int(rows["year"].max()), s + + raise ValueError("No available functional zone sources to choose from") async def get_context_functional_zones( - scenario_id: int, source: str, year: int, token: str + scenario_id: int, + source: str | None, + year: int | None, + token: str, + client: UrbanAPIClient, ) -> gpd.GeoDataFrame: - sources_df = await urban_api_gateway.get_functional_zones_sources(scenario_id, token) - year, source = await _get_best_functional_zones_source(sources_df, source, year) - functional_zones = await urban_api_gateway.get_functional_zones( + sources_df = await client.get_functional_zones_sources(scenario_id, token) + year, source = _choose_best_fz_source(sources_df, source, year) + functional_zones = await client.get_functional_zones( scenario_id, year, source, token ) functional_zones = functional_zones.loc[ @@ -92,8 +128,8 @@ async def get_context_functional_zones( return adapt_functional_zones(functional_zones) -async def get_context_buildings(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects( +async def get_context_buildings(scenario_id: int, token: str, client: UrbanAPIClient): + gdf = await client.get_physical_objects( scenario_id, token, physical_object_type_id=LIVING_BUILDINGS_ID, @@ -105,11 +141,9 @@ async def get_context_buildings(scenario_id: int, token: str): async def get_context_services( - scenario_id: int, service_types: pd.DataFrame, token: str + scenario_id: int, service_types: pd.DataFrame, token: str, client: UrbanAPIClient ): - gdf = await urban_api_gateway.get_services( - scenario_id, token, centers_only=True - ) + gdf = await client.get_services(scenario_id, token, centers_only=True) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 5079ca0..1714a3a 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,5 +1,3 @@ -from typing import Optional - import geopandas as gpd import numpy as np import pandas as pd @@ -8,8 +6,8 @@ from blocksnet.preprocessing.imputing import impute_buildings, impute_services from loguru import logger +from app.clients.urban_api_client import UrbanAPIClient from app.common.exceptions.http_exception_wrapper import http_exception -from app.dependencies import urban_api_gateway from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones @@ -34,184 +32,187 @@ def close_gaps(gdf, tolerance): # taken from momepy points = shapely.points(np.unique(coords[edges], axis=0)) buffered = shapely.buffer(points, tolerance / 2) - dissolved = shapely.union_all(buffered) - exploded = [ shapely.get_geometry(dissolved, i) for i in range(shapely.get_num_geometries(dissolved)) ] - centroids = shapely.centroid(exploded) - snapped = shapely.snap(geom, shapely.union_all(centroids), tolerance) - return gpd.GeoSeries(snapped, crs=gdf.crs) -async def _get_project_boundaries(project_id: int, token): - return gpd.GeoDataFrame( - geometry=[await urban_api_gateway.get_project_geometry(project_id, token)], crs=4326 - ) - - -async def _get_scenario_roads(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_function_id=ROADS_ID - ) - if gdf is None: - return None - return gdf[["geometry"]].reset_index(drop=True) - - -async def _get_scenario_water(scenario_id: int, token: str): - gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, token, physical_object_function_id=WATER_ID - ) - if gdf is None: - return None - return gdf[["geometry"]].reset_index(drop=True) - - -async def _get_scenario_blocks( - user_scenario_id: int, base_scenario_id: int, boundaries: gpd.GeoDataFrame, token -) -> gpd.GeoDataFrame: - crs = boundaries.crs - boundaries.geometry = boundaries.buffer(-1) - - water = await _get_scenario_water(user_scenario_id, token) - if water is not None and not water.empty: - water = water.to_crs(crs) - water = water.explode() - user_roads = await _get_scenario_roads(user_scenario_id, token) - if user_roads is not None and not user_roads.empty: - user_roads = user_roads.to_crs(crs) - user_roads = user_roads.explode() - base_roads = await _get_scenario_roads(base_scenario_id, token) - if base_roads is not None and not base_roads.empty: - base_roads = base_roads.to_crs(crs) - base_roads = base_roads.explode() - if ( - base_roads is not None - and not base_roads.empty - and user_roads is not None - and not user_roads.empty - ): - roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) - roads.geometry = close_gaps(roads, 1) - roads = roads.explode(column="geometry") - else: - raise http_exception(404, "No objects found for polygons cutting") - - lines, polygons = preprocess_urban_objects(roads, None, water) - blocks = cut_urban_blocks(boundaries, lines, polygons) - return blocks - - -async def _get_scenario_info(scenario_id: int, token: str) -> tuple[int, int]: - scenario = await urban_api_gateway.get_scenario(scenario_id, token) - project_id = scenario["project"]["project_id"] - project = await urban_api_gateway.get_project(project_id, token) - base_scenario_id = project["base_scenario"]["id"] - return project_id, base_scenario_id - - -async def _get_best_functional_zones_source( - sources_df: pd.DataFrame, - source: str | None = None, - year: int | None = None, -) -> tuple[int | None, str | None]: - """ - Pick the (year, source) pair that should be fetched. - - Rules - ----- - 1. Nothing is given: latest year of the highest-priority source (PZZ). - 2. Only `source`: latest year available for that source. - 3. Only `year`: try that year for priority = PZZ, OSM, User. - 4. 'Both given': use them as-is if a match exists, otherwise fall back to rule 3. - """ - if source and year: - row = sources_df.query("source == @source and year == @year") - if not row.empty: - return year, source - year = int(year) - source = None - - if source and year is None: - rows = sources_df.query("source == @source") - if not rows.empty: - return int(rows["year"].max()), source - source = None - - if year is not None and source is None: - for s in SOURCES_PRIORITY: - row = sources_df.query("source == @s and year == @year") - if not row.empty: - return year, s - - for s in SOURCES_PRIORITY: - rows = sources_df.query("source == @s") - if not rows.empty: - return int(rows["year"].max()), s - - -async def get_scenario_blocks(user_scenario_id: int, token: str) -> gpd.GeoDataFrame: - project_id, base_scenario_id = await _get_scenario_info(user_scenario_id, token) - project_boundaries = await _get_project_boundaries(project_id, token) +class ScenarioService: - crs = project_boundaries.estimate_utm_crs() - project_boundaries = project_boundaries.to_crs(crs) + def __init__(self, urban_api_client: UrbanAPIClient): + self.client = urban_api_client - return await _get_scenario_blocks( - user_scenario_id, base_scenario_id, project_boundaries, token - ) - - -async def get_scenario_functional_zones( - scenario_id: int, token: str, source: str = None, year: int = None -) -> gpd.GeoDataFrame: - functional_zones = await urban_api_gateway.get_functional_zones_scenario( - scenario_id, token, year, source - ) - functional_zones = functional_zones.loc[ - functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) - ].reset_index(drop=True) - return adapt_functional_zones(functional_zones) + async def _get_project_boundaries( + self, project_id: int, token: str + ) -> gpd.GeoDataFrame: + geom = await self.client.get_project_geometry(project_id, token) + return gpd.GeoDataFrame(geometry=[geom], crs=4326) + async def _get_scenario_roads(self, scenario_id: int, token: str): + gdf = await self.client.get_physical_objects_scenario( + scenario_id, token, physical_object_function_id=ROADS_ID + ) + if gdf is None: + return None + return gdf[["geometry"]].reset_index(drop=True) -async def get_scenario_buildings(scenario_id: int, token: str): - try: - gdf = await urban_api_gateway.get_physical_objects_scenario( - scenario_id, - token, - physical_object_type_id=LIVING_BUILDINGS_ID, - centers_only=True, + async def _get_scenario_water(self, scenario_id: int, token: str): + gdf = await self.client.get_physical_objects_scenario( + scenario_id, token, physical_object_function_id=WATER_ID ) if gdf is None: return None - gdf = adapt_buildings(gdf.reset_index(drop=True)) - crs = gdf.estimate_utm_crs() - return impute_buildings(gdf.to_crs(crs)).to_crs(4326) - except Exception as e: - logger.exception(e) - raise http_exception( - 404, - f"No buildings found for scenario {scenario_id}", - _input={"scenario_id": scenario_id}, - _detail={"error": repr(e)}, - ) from e - - -async def get_scenario_services( - scenario_id: int, service_types: pd.DataFrame, token: str -): - try: - gdf = await urban_api_gateway.get_services_scenario( - scenario_id, centers_only=True, token=token + return gdf[["geometry"]].reset_index(drop=True) + + async def _get_scenario_blocks( + self, + user_scenario_id: int, + base_scenario_id: int, + boundaries: gpd.GeoDataFrame, + token: str, + ) -> gpd.GeoDataFrame: + crs = boundaries.crs + boundaries.geometry = boundaries.buffer(-1) + + water = await self._get_scenario_water(user_scenario_id, token) + if water is not None and not water.empty: + water = water.to_crs(crs) + water = water.explode() + + user_roads = await self._get_scenario_roads(user_scenario_id, token) + if user_roads is not None and not user_roads.empty: + user_roads = user_roads.to_crs(crs) + user_roads = user_roads.explode() + + base_roads = await self._get_scenario_roads(base_scenario_id, token) + if base_roads is not None and not base_roads.empty: + base_roads = base_roads.to_crs(crs) + base_roads = base_roads.explode() + + if ( + base_roads is not None + and not base_roads.empty + and user_roads is not None + and not user_roads.empty + ): + roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) + roads.geometry = close_gaps(roads, 1) + roads = roads.explode(column="geometry") + else: + raise http_exception(404, "No objects found for polygons cutting") + + lines, polygons = preprocess_urban_objects(roads, None, water) + blocks = cut_urban_blocks(boundaries, lines, polygons) + return blocks + + async def _get_scenario_info(self, scenario_id: int, token: str) -> tuple[int, int]: + scenario = await self.client.get_scenario(scenario_id, token) + project_id = scenario["project"]["project_id"] + project = await self.client.get_project(project_id, token) + base_scenario_id = project["base_scenario"]["id"] + return project_id, base_scenario_id + + async def _get_best_functional_zones_source( + self, + sources_df: pd.DataFrame, + source: str | None = None, + year: int | None = None, + ) -> tuple[int | None, str | None]: + """Выбор (year, source) по приоритетам.""" + if source and year: + row = sources_df.query("source == @source and year == @year") + if not row.empty: + return year, source + year = int(year) + source = None + + if source and year is None: + rows = sources_df.query("source == @source") + if not rows.empty: + return int(rows["year"].max()), source + source = None + + if year is not None and source is None: + for s in SOURCES_PRIORITY: + row = sources_df.query("source == @s and year == @year") + if not row.empty: + return year, s + + for s in SOURCES_PRIORITY: + rows = sources_df.query("source == @s") + if not rows.empty: + return int(rows["year"].max()), s + + async def get_scenario_blocks( + self, user_scenario_id: int, token: str + ) -> gpd.GeoDataFrame: + project_id, base_scenario_id = await self._get_scenario_info( + user_scenario_id, token + ) + project_boundaries = await self._get_project_boundaries(project_id, token) + crs = project_boundaries.estimate_utm_crs() + project_boundaries = project_boundaries.to_crs(crs) + return await self._get_scenario_blocks( + user_scenario_id, base_scenario_id, project_boundaries, token ) - gdf = gdf.to_crs(gdf.estimate_utm_crs()) - gdfs = adapt_services(gdf.reset_index(drop=True), service_types) - return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} - except Exception as e: - logger.error("No buildings found for scenario", e) + + async def get_scenario_functional_zones( + self, + scenario_id: int, + token: str, + source: str | None = None, + year: int | None = None, + ) -> gpd.GeoDataFrame: + functional_zones = await self.client.get_functional_zones_scenario( + scenario_id, token, year, source + ) + functional_zones = functional_zones.loc[ + functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) + ].reset_index(drop=True) + return adapt_functional_zones(functional_zones) + + async def get_scenario_buildings(self, scenario_id: int, token: str): + try: + gdf = await self.client.get_physical_objects_scenario( + scenario_id, + token, + physical_object_type_id=LIVING_BUILDINGS_ID, + centers_only=True, + ) + if gdf is None: + return None + gdf = adapt_buildings(gdf.reset_index(drop=True)) + crs = gdf.estimate_utm_crs() + return impute_buildings(gdf.to_crs(crs)).to_crs(4326) + except Exception as e: + logger.exception(e) + raise http_exception( + 404, + f"No buildings found for scenario {scenario_id}", + _input={"scenario_id": scenario_id}, + _detail={"error": repr(e)}, + ) from e + + async def get_scenario_services( + self, scenario_id: int, service_types: pd.DataFrame, token: str + ): + try: + gdf = await self.client.get_services_scenario( + scenario_id, centers_only=True, token=token + ) + gdf = gdf.to_crs(gdf.estimate_utm_crs()) + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) + return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + except Exception as e: + logger.exception(e) + raise http_exception( + 404, + f"No services found for scenario {scenario_id}", + _input={"scenario_id": scenario_id}, + _detail={"error": repr(e)}, + ) from e diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 1eab293..32cee8a 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,11 +1,11 @@ import asyncio -from typing import List, Optional, Dict +from typing import Dict, List, Optional import pandas as pd from blocksnet.config import service_types_config -from app.common.caching.caching_service import cache -from app.dependencies import urban_api_gateway +from app.clients.urban_api_client import UrbanAPIClient +from app.common.caching.caching_service import FileCache from app.effects_api.constants.const import SERVICE_TYPES_MAPPING for st_id, st_name in SERVICE_TYPES_MAPPING.items(): @@ -14,14 +14,17 @@ assert st_name in service_types_config, f"{st_id}:{st_name} not in config" -async def _adapt_name(service_type_id: int): +async def _adapt_name(service_type_id: int) -> Optional[str]: return SERVICE_TYPES_MAPPING.get(service_type_id) _SOCIAL_VALUES_BY_ST: Dict[int, Optional[List[int]]] = {} _SOCIAL_VALUES_LOCK = asyncio.Lock() -async def _warmup_social_values(service_type_ids: List[int]) -> None: + +async def _warmup_social_values( + service_type_ids: List[int], client: UrbanAPIClient +) -> None: missing = [sid for sid in service_type_ids if sid not in _SOCIAL_VALUES_BY_ST] if not missing: return @@ -30,40 +33,49 @@ async def _warmup_social_values(service_type_ids: List[int]) -> None: if not missing: return results = await asyncio.gather( - *(urban_api_gateway.get_service_type_social_values(sid) for sid in missing) + *(client.get_service_type_social_values(sid) for sid in missing) ) for sid, df in zip(missing, results): _SOCIAL_VALUES_BY_ST[sid] = None if df is None else list(df.index) -async def _adapt_social_values(service_type_id: int) -> Optional[List[int]]: - await _warmup_social_values([service_type_id]) + +async def _adapt_social_values( + service_type_id: int, client: UrbanAPIClient +) -> Optional[List[int]]: + await _warmup_social_values([service_type_id], client) return _SOCIAL_VALUES_BY_ST.get(service_type_id) -async def adapt_service_types(service_types_df: pd.DataFrame) -> pd.DataFrame: + +async def adapt_service_types( + service_types_df: pd.DataFrame, client: UrbanAPIClient +) -> pd.DataFrame: df = service_types_df[["infrastructure_type"]].copy() df["infrastructure_weight"] = service_types_df["weight_value"] - service_type_ids: List[int] = list(df.index) + service_type_ids: List[int] = df.index.tolist() - names: List[Optional[str]] = await asyncio.gather( - *(_adapt_name(st_id) for st_id in service_type_ids) - ) + names = await asyncio.gather(*(_adapt_name(st_id) for st_id in service_type_ids)) df["name"] = names df = df.dropna(subset=["name"]).copy() - await _warmup_social_values(list(df.index)) - df["social_values"] = [ _SOCIAL_VALUES_BY_ST.get(st_id) for st_id in df.index ] + await _warmup_social_values(list(df.index), client) + df["social_values"] = [_SOCIAL_VALUES_BY_ST.get(st_id) for st_id in df.index] return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] -async def get_services_with_ids_from_layer(scenario_id: int, method: str) -> dict: + +async def get_services_with_ids_from_layer( + scenario_id: int, + method: str, + cache: FileCache, +) -> dict: cached: Optional[dict] = cache.load_latest(method, scenario_id) if not cached or "data" not in cached: return {"before": [], "after": []} data = cached["data"] - def map_services(names): + def map_services(names: List[str]): result = [] for name in names: matched = [ diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index ad1cc27..71de78e 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -1,17 +1,14 @@ import asyncio import contextlib import json -import uuid from contextlib import asynccontextmanager -from datetime import timedelta from typing import Any, Callable, Literal import geopandas as gpd from fastapi import FastAPI from loguru import logger -from app.common.caching.caching_service import cache -from app.effects_api.effects_service import effects_service +from app.dependencies import effects_service, file_cache MethodFunc = Callable[[str, Any], "dict[str, Any]"] @@ -26,7 +23,15 @@ class AnyTask: - def __init__(self, method: str, scenario_id: int, token: str, params: Any, params_hash: str): + def __init__( + self, + method: str, + scenario_id: int, + token: str, + params: Any, + params_hash: str, + cache: file_cache, + ): self.method = method self.scenario_id = scenario_id self.token = token @@ -37,6 +42,7 @@ def __init__(self, method: str, scenario_id: int, token: str, params: Any, param self.status: Literal["queued", "running", "done", "failed"] = "queued" self.result: dict | None = None self.error: str | None = None + self.cache = cache async def to_response(self) -> dict: if self.status in {"queued", "running"}: @@ -50,7 +56,7 @@ def run_sync(self) -> None: logger.info(f"[{self.task_id}] started") self.status = "running" - cached = cache.load(self.method, self.scenario_id, self.param_hash) + cached = self.cache.load(self.method, self.scenario_id, self.param_hash) if cached: logger.info(f"[{self.task_id}] loaded from cache") self.result = cached["data"] @@ -85,9 +91,11 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: async def create_task(method: str, token: str, params) -> str: norm_params = await effects_service.get_optimal_func_zone_data(params, token) params_for_hash = await effects_service.build_hash_params(norm_params, token) - phash = cache.params_hash(params_for_hash) + phash = file_cache.params_hash(params_for_hash) - task = AnyTask(method, norm_params.scenario_id, token, norm_params, phash) + task = AnyTask( + method, norm_params.scenario_id, token, norm_params, phash, file_cache + ) _task_map[task.task_id] = task await _task_queue.put(task) return task.task_id @@ -124,8 +132,10 @@ async def stop(self): with contextlib.suppress(asyncio.CancelledError): await self.task + worker = Worker() + @asynccontextmanager async def lifespan(app: FastAPI): worker.start() diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index 51a249a..c6540e5 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -1,11 +1,18 @@ -from typing import Optional, Dict, Union +from typing import Dict, Optional, Union from pydantic import BaseModel, Field, field_validator, model_serializer from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO, SocioEconomicByProjectDTO, \ - SocioEconomicByScenarioDTO, SocioEconomicByProjectComputedDTO +from app.effects_api.dto.development_dto import ( + ContextDevelopmentDTO, + DevelopmentDTO, +) +from ..dto.socio_economic_project_dto import ( + SocioEconomicByProjectComputedDTO, + SocioEconomicByProjectDTO, +) +from ..dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO from .output_maps import pred_columns_names_map, soc_economy_pred_name_map diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 5947cc2..6d67096 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -11,12 +11,11 @@ _task_map, _task_queue, ) -from .modules.service_type_service import get_services_with_ids_from_layer -from ..common.caching.caching_service import cache from ..common.exceptions.http_exception_wrapper import http_exception +from ..dependencies import effects_service, file_cache from .dto.development_dto import ContextDevelopmentDTO -from .effects_service import effects_service +from .modules.service_type_service import get_services_with_ids_from_layer router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -26,11 +25,13 @@ async def _with_defaults( ) -> ContextDevelopmentDTO: return await effects_service.get_optimal_func_zone_data(dto, token) + @router.get("/methods") async def get_methods(): """router for getting method names available for tasks creation""" return list(TASK_METHODS.keys()) + @router.post("/{method}", status_code=202) async def create_task( method: str, @@ -43,18 +44,25 @@ async def create_task( params_filled = await effects_service.get_optimal_func_zone_data(params, token) params_for_hash = await effects_service.build_hash_params(params_filled, token) - phash = cache.params_hash(params_for_hash) + phash = file_cache.params_hash(params_for_hash) task_id = f"{method}_{params_filled.scenario_id}_{phash}" - if cache.load(method, params_filled.scenario_id, phash): + if file_cache.load(method, params_filled.scenario_id, phash): return {"task_id": task_id, "status": "done"} existing = _task_map.get(task_id) if existing and existing.status in {"queued", "running"}: return {"task_id": task_id, "status": existing.status} - task = AnyTask(method, params_filled.scenario_id, token, params_filled, params_for_hash) + task = AnyTask( + method, + params_filled.scenario_id, + token, + params_filled, + params_for_hash, + file_cache, + ) _task_map[task_id] = task await _task_queue.put(task) @@ -68,16 +76,18 @@ async def task_status(task_id: str): raise http_exception(404, "task not found", task_id) return await task.to_response() + @router.get("/get_service_types") async def get_service_types( scenario_id: int, method: str = "territory_transformation", ): - return await get_services_with_ids_from_layer(scenario_id, method) + return await get_services_with_ids_from_layer(scenario_id, method, file_cache) + @router.get("/territory_transformation/{scenario_id}/{service_name}") async def get_territory_transformation_layer(scenario_id: int, service_name: str): - cached = cache.load_latest("territory_transformation", scenario_id) + cached = file_cache.load_latest("territory_transformation", scenario_id) if not cached: raise http_exception(404, "no saved result for this scenario", scenario_id) @@ -99,11 +109,9 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str @router.get("/get_from_cache/{method_name}/{scenario_id}") async def get_layer(scenario_id: int, method_name: str): - cached = cache.load_latest(method_name, scenario_id) + cached = file_cache.load_latest(method_name, scenario_id) if not cached: raise http_exception(404, "no saved result for this scenario", scenario_id) data: dict = cached["data"] return JSONResponse(content=data) - - diff --git a/app/main.py b/app/main.py index 1bf2758..ab159ea 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,13 @@ from fastapi.responses import RedirectResponse from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware -from app.effects_api.effects_controller import development_router +from app.effects_api.effects_controller import ( + development_router, + f_22_router, + f_26_router, + f_35_router, + f_36_router, +) from app.effects_api.modules.task_service import lifespan from app.effects_api.tasks_controller import router as tasks_router from app.system_router.system_controller import system_router @@ -40,3 +46,8 @@ async def read_root(): app.include_router(tasks_router) app.include_router(system_router) app.include_router(development_router) + +app.include_router(f_22_router) +app.include_router(f_26_router) +app.include_router(f_35_router) +app.include_router(f_36_router) diff --git a/requirements.txt b/requirements.txt index cbe3c215bd4d0ee9f5b6efe73d17f843de740d4d..8f9618e6d94d9f5d73e13dee7c44ffa4b2658b66 100644 GIT binary patch delta 108 zcmaFIa*1_A9#aZ00~bR9LlHwNgDyidLq0<;5N0xzFw`;FGMF&vF&F`{0Ru=?5Sk8#S6ewc|m(64-2I?+?>NEq3gXBRv0Yl>v4*&oF delta 7 Ocmcb_`i^Bo9uoi!hXU#V From a4e15fbdb1dc9e1ff677185a84981302c62fd8da Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 11 Sep 2025 14:52:48 +0300 Subject: [PATCH 071/161] feat(comment_fixes_2): 1. Introduced async requests for roads and water objects 2. created unified method for selecting years and sources of functional zones --- app/clients/urban_api_client.py | 6 +-- app/common/utils/geodata.py | 35 ++++++++++++++ app/effects_api/modules/context_service.py | 49 ++++++-------------- app/effects_api/modules/scenario_service.py | 51 +++++---------------- 4 files changed, 62 insertions(+), 79 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 7285175..d4ac4d5 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Dict, List, Literal import geopandas as gpd import pandas as pd @@ -35,7 +35,7 @@ async def get_physical_objects( ) async def get_services( - self, scenario_id: int, token, **kwargs: Any + self, scenario_id: int, token: str, **kwargs: Any ) -> gpd.GeoDataFrame: headers = {"Authorization": f"Bearer {token}"} res = await self.json_handler.get( @@ -263,7 +263,7 @@ async def _get_optimal_source( async def get_project_id( self, scenario_id: int, - token: str | None = None, + token: str, ) -> int: endpoint = f"/api/v1/scenarios/{scenario_id}" response = await self.json_handler.get( diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index e7cb4d7..e9ec58c 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -1,8 +1,10 @@ import json import geopandas as gpd +import pandas as pd from app.effects_api.constants.const import COL_RU +from app.effects_api.modules.scenario_service import SOURCES_PRIORITY def gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: @@ -16,3 +18,36 @@ def gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + + +async def get_best_functional_zones_source( + sources_df: pd.DataFrame, + source: str | None = None, + year: int | None = None, +) -> tuple[int | None, str | None]: + if year is not None: + year = int(year) + + if source and year is not None: + row = sources_df.query("source == @source and year == @year") + if not row.empty: + return year, source + source = None + + if year is not None and source is None: + for s in SOURCES_PRIORITY: + row = sources_df.query("source == @s and year == @year") + if not row.empty: + return year, s + + elif source and year is None: + rows = sources_df.query("source == @source") + if not rows.empty: + return int(rows["year"].max()), source + + for s in SOURCES_PRIORITY: + rows = sources_df.query("source == @s") + if not rows.empty: + return int(rows["year"].max()), s + + raise ValueError("No available functional zone sources to choose from") diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 1a33a4e..112d123 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,9 +1,12 @@ +import asyncio + import geopandas as gpd import pandas as pd from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services from app.clients.urban_api_client import UrbanAPIClient +from app.common.utils.geodata import get_best_functional_zones_source from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones @@ -52,10 +55,12 @@ async def _get_context_blocks( crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await _get_context_water(scenario_id, token, client) - water = water.to_crs(crs) + water, roads = await asyncio.gather( + _get_context_water(scenario_id, token, client), + _get_context_roads(scenario_id, token, client), + ) - roads = await _get_context_roads(scenario_id, token, client) + water = water.to_crs(crs) roads = roads.to_crs(crs) roads.geometry = close_gaps(roads, 1) @@ -67,8 +72,10 @@ async def _get_context_blocks( async def get_context_blocks( project_id: int, scenario_id: int, token: str, client: UrbanAPIClient ) -> gpd.GeoDataFrame: - project_boundaries = await _get_project_boundaries(project_id, token, client) - context_boundaries = await _get_context_boundaries(project_id, token, client) + project_boundaries, context_boundaries = await asyncio.gather( + _get_project_boundaries(project_id, token, client), + _get_context_boundaries(project_id, token, client), + ) crs = context_boundaries.estimate_utm_crs() context_boundaries = context_boundaries.to_crs(crs) @@ -80,36 +87,6 @@ async def get_context_blocks( return await _get_context_blocks(scenario_id, context_boundaries, token, client) -def _choose_best_fz_source( - sources_df: pd.DataFrame, source: str | None, year: int | None -) -> tuple[int, str]: - df = sources_df.copy() - if source and year: - row = df.query("source == @source and year == @year") - if not row.empty: - return int(year), str(source) - source = None - - if source and year is None: - rows = df.query("source == @source") - if not rows.empty: - return int(rows["year"].max()), str(source) - source = None - - if year is not None and source is None: - for s in _SOURCES_PRIORITY: - row = df.query("source == @s and year == @year") - if not row.empty: - return int(year), s - - for s in _SOURCES_PRIORITY: - rows = df.query("source == @s") - if not rows.empty: - return int(rows["year"].max()), s - - raise ValueError("No available functional zone sources to choose from") - - async def get_context_functional_zones( scenario_id: int, source: str | None, @@ -118,7 +95,7 @@ async def get_context_functional_zones( client: UrbanAPIClient, ) -> gpd.GeoDataFrame: sources_df = await client.get_functional_zones_sources(scenario_id, token) - year, source = _choose_best_fz_source(sources_df, source, year) + year, source = await get_best_functional_zones_source(sources_df, source, year) functional_zones = await client.get_functional_zones( scenario_id, year, source, token ) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 1714a3a..684fbe5 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -1,3 +1,5 @@ +import asyncio + import geopandas as gpd import numpy as np import pandas as pd @@ -79,20 +81,20 @@ async def _get_scenario_blocks( crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water = await self._get_scenario_water(user_scenario_id, token) + water, user_roads, base_roads = await asyncio.gather( + self._get_scenario_water(user_scenario_id, token), + self._get_scenario_roads(user_scenario_id, token), + self._get_scenario_roads(base_scenario_id, token), + ) + if water is not None and not water.empty: - water = water.to_crs(crs) - water = water.explode() + water = water.to_crs(crs).explode() - user_roads = await self._get_scenario_roads(user_scenario_id, token) if user_roads is not None and not user_roads.empty: - user_roads = user_roads.to_crs(crs) - user_roads = user_roads.explode() + user_roads = user_roads.to_crs(crs).explode() - base_roads = await self._get_scenario_roads(base_scenario_id, token) if base_roads is not None and not base_roads.empty: - base_roads = base_roads.to_crs(crs) - base_roads = base_roads.explode() + base_roads = base_roads.to_crs(crs).explode() if ( base_roads is not None @@ -117,37 +119,6 @@ async def _get_scenario_info(self, scenario_id: int, token: str) -> tuple[int, i base_scenario_id = project["base_scenario"]["id"] return project_id, base_scenario_id - async def _get_best_functional_zones_source( - self, - sources_df: pd.DataFrame, - source: str | None = None, - year: int | None = None, - ) -> tuple[int | None, str | None]: - """Выбор (year, source) по приоритетам.""" - if source and year: - row = sources_df.query("source == @source and year == @year") - if not row.empty: - return year, source - year = int(year) - source = None - - if source and year is None: - rows = sources_df.query("source == @source") - if not rows.empty: - return int(rows["year"].max()), source - source = None - - if year is not None and source is None: - for s in SOURCES_PRIORITY: - row = sources_df.query("source == @s and year == @year") - if not row.empty: - return year, s - - for s in SOURCES_PRIORITY: - rows = sources_df.query("source == @s") - if not rows.empty: - return int(rows["year"].max()), s - async def get_scenario_blocks( self, user_scenario_id: int, token: str ) -> gpd.GeoDataFrame: From 44ee41b5668211c8906547237750c2c2e14aadf6 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 11 Sep 2025 17:39:49 +0300 Subject: [PATCH 072/161] fix(comment_fixes_3): 1. Refactored get_best_functional_zones_source to geodata.py 2. REduced number of trials --- app/common/utils/geodata.py | 23 ++++++++++------------- app/effects_api/effects_service.py | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index e9ec58c..88d98a5 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -3,6 +3,7 @@ import geopandas as gpd import pandas as pd +from app.common.exceptions.http_exception_wrapper import http_exception from app.effects_api.constants.const import COL_RU from app.effects_api.modules.scenario_service import SOURCES_PRIORITY @@ -25,29 +26,25 @@ async def get_best_functional_zones_source( source: str | None = None, year: int | None = None, ) -> tuple[int | None, str | None]: - if year is not None: - year = int(year) - if source and year is not None: + if source and year: row = sources_df.query("source == @source and year == @year") if not row.empty: return year, source - source = None - - if year is not None and source is None: + return await get_best_functional_zones_source(sources_df, None, year) + elif source and not year: + rows = sources_df.query("source == @source") + if not rows.empty: + return int(rows["year"].max()), source + return await get_best_functional_zones_source(sources_df, None, year) + elif year and not source: for s in SOURCES_PRIORITY: row = sources_df.query("source == @s and year == @year") if not row.empty: return year, s - - elif source and year is None: - rows = sources_df.query("source == @source") - if not rows.empty: - return int(rows["year"].max()), source - for s in SOURCES_PRIORITY: rows = sources_df.query("source == @s") if not rows.empty: return int(rows["year"].max()), s - raise ValueError("No available functional zone sources to choose from") + raise http_exception(404, "No available functional zone sources to choose from") diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 5a03f83..076b8f4 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -952,6 +952,18 @@ async def territory_transformation_scenario_after( * service_types["infrastructure_weight"] ) + if ( + "population" not in after_blocks.columns + or after_blocks["population"].isna().any() + ): + dev_df = await self.run_development_parameters(after_blocks) + after_blocks["population"] = pd.to_numeric( + dev_df["population"], errors="coerce" + ).fillna(0) + else: + after_blocks["population"] = pd.to_numeric( + after_blocks["population"], errors="coerce" + ).fillna(0) facade = self._build_facade(after_blocks, acc_mx, service_types) services_weights = service_types.set_index("name")[ @@ -962,7 +974,7 @@ async def territory_transformation_scenario_after( num_params=facade.num_params, facade=facade, weights=services_weights, - max_evals=50, + max_evals=30, ) constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) tpe_optimizer = TPEOptimizer( @@ -972,7 +984,7 @@ async def territory_transformation_scenario_after( ) best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=50, timeout=60000, initial_runs_num=1 + max_runs=30, timeout=60000, initial_runs_num=1 ) prov_gdfs_after = {} From e547bad8d325c8af2170d5d925877f6489d6a760 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 12 Sep 2025 16:15:22 +0300 Subject: [PATCH 073/161] feat(f_36): 1. Module 36 response featured 2. Method for coords round is featured 3. Route for getting service types by methods now works with f36 4. Additional method for getting info about social values --- app/clients/urban_api_client.py | 4 + app/common/utils/geodata.py | 31 ++++- app/effects_api/effects_service.py | 126 ++++++++++-------- .../modules/service_type_service.py | 21 ++- app/effects_api/tasks_controller.py | 16 +++ 5 files changed, 130 insertions(+), 68 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index d4ac4d5..1874808 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -394,3 +394,7 @@ async def get_project_scenarios( headers=headers, ) return res + + async def get_social_values_info(self) -> dict[int, str]: + res = await self.json_handler.get("/api/v1/social_values") + return {item["soc_value_id"]: item["name"] for item in res} diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 88d98a5..d78fc91 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -1,25 +1,50 @@ +import asyncio import json import geopandas as gpd import pandas as pd +from shapely.geometry.base import BaseGeometry +from shapely.wkt import loads, dumps from app.common.exceptions.http_exception_wrapper import http_exception from app.effects_api.constants.const import COL_RU from app.effects_api.modules.scenario_service import SOURCES_PRIORITY -def gdf_to_ru_fc(gdf: gpd.GeoDataFrame) -> dict: +async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: if "provision_weak" in gdf.columns: gdf = gdf.drop(columns="provision_weak") gdf = gdf.rename( - columns={k: v for k, v in COL_RU.items() if k in gdf.columns}, errors="raise" + columns={k: v for k, v in COL_RU.items() if k in gdf.columns}, + errors="raise", ) - return json.loads(gdf.to_crs(4326).to_json(drop_id=True)) + gdf = gdf.to_crs(4326) + + gdf_copy = gdf.copy() + gdf_copy.geometry = await round_coords(gdf_copy.geometry, ndigits=ndigits) + + return json.loads(gdf_copy.to_json(drop_id=True)) def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") +async def round_coords( + geometry: gpd.GeoSeries | BaseGeometry, + ndigits: int = 6 +) -> gpd.GeoSeries | BaseGeometry: + if isinstance(geometry, gpd.GeoSeries): + return await asyncio.to_thread( + geometry.map, + lambda geom: loads(dumps(geom, rounding_precision=ndigits)), + ) + elif isinstance(geometry, BaseGeometry): + return await asyncio.to_thread( + loads, + dumps(geometry, rounding_precision=ndigits), + ) + else: + raise TypeError("geometry must be GeoSeries or Shapely geometry") async def get_best_functional_zones_source( sources_df: pd.DataFrame, diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 076b8f4..311a39e 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -5,7 +5,7 @@ import numpy as np import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators -from blocksnet.analysis.provision import competitive_provision +from blocksnet.analysis.provision import competitive_provision, provision_strong_total from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use from blocksnet.config import service_types_config @@ -34,7 +34,7 @@ from ..common.caching.caching_service import FileCache from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, @@ -837,7 +837,8 @@ async def territory_transformation_scenario_before( existing_data = cached["data"] if cached else {} existing_data["before"] = { - n: gdf_to_ru_fc(g) for n, g in prov_gdfs_before.items() + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs_before.items() } self.cache.save( @@ -1005,7 +1006,10 @@ async def territory_transformation_scenario_after( ) from_cache = cached["data"] if cached else {} - from_cache["after"] = {n: gdf_to_ru_fc(g) for n, g in prov_gdfs_after.items()} + from_cache["after"] = { + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs_after.items() + } from_cache["opt_context"] = { "best_x": best_x, } @@ -1125,15 +1129,13 @@ def _get_value_level(self, provisions: list[float | None]) -> float: return float(np.mean(vals)) if vals else np.nan async def values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ): - # service_type = params.service_type method_name = "values_oriented_requirements" params = await self.get_optimal_func_zone_data(params, token) - params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) @@ -1141,45 +1143,35 @@ async def values_oriented_requirements( updated_at = info["updated_at"] cached = self.cache.load(method_name, params.scenario_id, phash) - if ( - cached - and cached["meta"].get("scenario_updated_at") == updated_at - and "result" in cached["data"] - ): - payload = cached["data"]["result"] - result_df = pd.DataFrame( - data=payload["data"], index=payload["index"], columns=payload["columns"] - ) - result_df.index.name = payload.get("index_name", None) - return result_df - - project_id = await self.urban_api_client.get_project_id( - params.scenario_id, token - ) + if cached and cached["meta"].get("scenario_updated_at") == updated_at: + if "result" in cached["data"]: + payload = cached["data"]["result"] + result_df = pd.DataFrame( + data=payload["data"], + index=payload["index"], + columns=payload["columns"], + ) + result_df.index.name = payload.get("index_name", None) + return result_df - context_blocks, context_buildings = await self.aggregate_blocks_layer_context( + context_blocks, _ = await self.aggregate_blocks_layer_context( params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token, ) - scenario_blocks, scenario_buildings = ( - await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, - ) + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, ) scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] - scenario_blocks.loc[ - scenario_blocks["is_project"], ["population"] + cap_cols - ] = 0 - + scenario_blocks.loc[scenario_blocks["is_project"], ["population"] + cap_cols] = 0 if "capacity" in scenario_blocks.columns: scenario_blocks = scenario_blocks.drop(columns="capacity") @@ -1188,9 +1180,6 @@ async def values_oriented_requirements( crs=context_blocks.crs, ) - social_values_provisions: dict[str, list[float | None]] = {} - provisions_gdfs: dict[str, gpd.GeoDataFrame] = {} - service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) service_types = service_types[~service_types["social_values"].isna()].copy() @@ -1198,47 +1187,66 @@ async def values_oriented_requirements( graph = get_accessibility_graph(blocks, "intermodal") acc_mx = calculate_accessibility_matrix(blocks, graph) + prov_gdfs: dict[str, gpd.GeoDataFrame] = {} + if cached and cached["meta"].get("scenario_updated_at") == updated_at and "provision" in cached["data"]: + for st_name, fc in cached["data"]["provision"].items(): + prov_gdfs[st_name] = gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + else: + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdf.to_crs(4326).drop(columns="provision_weak", errors="ignore") + num_cols = prov_gdf.select_dtypes(include="number").columns + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + prov_gdfs[st_name] = prov_gdf + + social_values_provisions: dict[str, list[float | None]] = {} for st_id in service_types.index: st_name = service_types.loc[st_id, "name"] social_values = service_types.loc[st_id, "social_values"] - prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdfs.get(st_name) + if prov_gdf is None or prov_gdf.empty: + continue - if "provision_strong" in prov_gdf.columns: - prov_total: float | None = float(prov_gdf["provision_strong"].sum()) - elif "provision" in prov_gdf.columns: - prov_total = float(prov_gdf["provision"].sum()) - else: + if prov_gdf["demand"].sum() == 0: prov_total = None + else: + prov_total = float(provision_strong_total(prov_gdf)) - provisions_gdfs[st_name] = prov_gdf + for sv in social_values: + social_values_provisions.setdefault(sv, []).append(prov_total) - for social_value in social_values: - social_values_provisions.setdefault(social_value, []).append(prov_total) + soc_values_map = await self.urban_api_client.get_social_values_info() index = list(social_values_provisions.keys()) result_df = pd.DataFrame( - data=[ - self._get_value_level(social_values_provisions[sv_id]) - for sv_id in index - ], + data=[self._get_value_level(social_values_provisions[sv]) for sv in index], index=index, columns=["social_value_level"], ) - result_df.index.name = "social_value_id" - payload = { - "columns": result_df.columns.tolist(), - "index": result_df.index.tolist(), - "data": result_df.values.tolist(), - "index_name": result_df.index.name, + values_table = { + int(sv_id): { + "name": soc_values_map.get(sv_id, str(sv_id)), + "value": round(float(val), 2) if val else 0.0, + } + for sv_id, val in result_df["social_value_level"].to_dict().items() } + self.cache.save( method_name, params.scenario_id, params_for_hash, - {"result": payload}, + { + "provision": { + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs.items() + }, + "result": values_table, + }, scenario_updated_at=updated_at, ) return result_df + diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 32cee8a..aaa8f3e 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -89,10 +89,19 @@ def map_services(names: List[str]): result.append({"id": None, "name": name}) return result - before_names = list(data.get("before", {}).keys()) - after_names = list(data.get("after", {}).keys()) + if "before" in data or "after" in data: + before_names = list(data.get("before", {}).keys()) + after_names = list(data.get("after", {}).keys()) + return { + "before": map_services(before_names), + "after": map_services(after_names), + } + + if "provision" in data: + prov_names = list(data["provision"].keys()) + return { + "services": map_services(prov_names) + } + + return {"before": [], "after": []} - return { - "before": map_services(before_names), - "after": map_services(after_names), - } diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 6d67096..a6e37db 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -107,6 +107,22 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str return JSONResponse(content={"before": fc_before, "after": fc_after}) +@router.get("/values_oriented_requirements/{scenario_id}/{service_name}") +async def get_values_oriented_requirements_layer(scenario_id: int, service_name: str): + cached = file_cache.load_latest("values_oriented_requirements", scenario_id) + if not cached: + raise http_exception(404, "no saved result for this scenario", scenario_id) + + data: dict = cached["data"] + + fc_before = data["provision"].get(service_name) + fc_after = data["result"] + if not (fc_before and fc_after): + raise http_exception(404, f"service '{service_name}' not found") + + return JSONResponse(content={"geojson": fc_before, "values_table": fc_after}) + + @router.get("/get_from_cache/{method_name}/{scenario_id}") async def get_layer(scenario_id: int, method_name: str): cached = file_cache.load_latest(method_name, scenario_id) From 43a1454d51cd198ef68e6c2288d42b2288ab2b0f Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 12 Sep 2025 16:20:41 +0300 Subject: [PATCH 074/161] fix(get_values_oriented_requirements_layer): 1. Renamed variables --- app/effects_api/tasks_controller.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index a6e37db..ca89ee2 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -115,12 +115,12 @@ async def get_values_oriented_requirements_layer(scenario_id: int, service_name: data: dict = cached["data"] - fc_before = data["provision"].get(service_name) - fc_after = data["result"] - if not (fc_before and fc_after): + fc_provision = data["provision"].get(service_name) + values_dict = data["result"] + if not (fc_provision and values_dict): raise http_exception(404, f"service '{service_name}' not found") - return JSONResponse(content={"geojson": fc_before, "values_table": fc_after}) + return JSONResponse(content={"geojson": fc_provision, "values_table": values_dict}) @router.get("/get_from_cache/{method_name}/{scenario_id}") From a3bccb624d2e6e83525726eb3912cdc8c2ef13d5 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 12 Sep 2025 16:47:16 +0300 Subject: [PATCH 075/161] fix(round_coords): 1. Reverted to sync --- app/common/utils/geodata.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index d78fc91..a41c525 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -21,7 +21,7 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: gdf = gdf.to_crs(4326) gdf_copy = gdf.copy() - gdf_copy.geometry = await round_coords(gdf_copy.geometry, ndigits=ndigits) + gdf_copy.geometry = await asyncio.to_thread(round_coords, gdf_copy.geometry, ndigits) return json.loads(gdf_copy.to_json(drop_id=True)) @@ -29,23 +29,18 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") -async def round_coords( +def round_coords( geometry: gpd.GeoSeries | BaseGeometry, ndigits: int = 6 ) -> gpd.GeoSeries | BaseGeometry: if isinstance(geometry, gpd.GeoSeries): - return await asyncio.to_thread( - geometry.map, - lambda geom: loads(dumps(geom, rounding_precision=ndigits)), - ) + return geometry.map(lambda geom: loads(dumps(geom, rounding_precision=ndigits))) elif isinstance(geometry, BaseGeometry): - return await asyncio.to_thread( - loads, - dumps(geometry, rounding_precision=ndigits), - ) + return loads(dumps(geometry, rounding_precision=ndigits)) else: raise TypeError("geometry must be GeoSeries or Shapely geometry") + async def get_best_functional_zones_source( sources_df: pd.DataFrame, source: str | None = None, From 7d5303c47704da7836c941a61b3dc52287c03136 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 15 Sep 2025 13:18:54 +0300 Subject: [PATCH 076/161] fix(task_status): 1. /tasks/status/{task_id} is now reveals "done" status --- app/common/caching/caching_service.py | 10 ++++++++++ app/effects_api/tasks_controller.py | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 3b27b70..ec1d54f 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -99,3 +99,13 @@ def has( self, method: str, scenario_id: int, max_age: timedelta | None = None ) -> bool: return self.load(method, scenario_id, max_age) is not None + + def parse_task_id(self, task_id: str): + parts = task_id.split("_") + if len(parts) < 3: + return None, None, None + phash = parts[-1] + scenario_id_raw = parts[-2] + method = "_".join(parts[:-2]) + scenario_id = int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw + return method, scenario_id, phash \ No newline at end of file diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index ca89ee2..156c905 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -71,10 +71,25 @@ async def create_task( @router.get("/status/{task_id}") async def task_status(task_id: str): + method, scenario_id, phash = file_cache.parse_task_id(task_id) + if method and scenario_id is not None and phash: + try: + cached = file_cache.load(method, scenario_id, phash) + if cached: + return {"task_id": task_id, "status": "done"} + except Exception: + pass task = _task_map.get(task_id) - if not task: - raise http_exception(404, "task not found", task_id) - return await task.to_response() + if task: + payload = { + "task_id": getattr(task, "task_id", task_id), + "status": getattr(task, "status", "unknown"), + } + if payload["status"] == "failed" and getattr(task, "error", None): + payload["error"] = str(task.error) + return payload + + raise http_exception(404, "task not found", task_id) @router.get("/get_service_types") From ef48845e27fb35234997cf3ad613e419447047cc Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 16 Sep 2025 20:10:43 +0300 Subject: [PATCH 077/161] fix(tasks): 1. context layers are now passed to the before and after methods where called 2. Running tasks are now called correctly 3. Project territories now have flag "is_project:bool" --- app/common/caching/caching_service.py | 9 ++- app/effects_api/effects_service.py | 102 ++++++++++++++++-------- app/effects_api/modules/task_service.py | 8 +- app/effects_api/tasks_controller.py | 10 +-- 4 files changed, 87 insertions(+), 42 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index ec1d54f..bb7db9f 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -104,8 +104,15 @@ def parse_task_id(self, task_id: str): parts = task_id.split("_") if len(parts) < 3: return None, None, None - phash = parts[-1] + + tail = "_".join(parts[-1:]) scenario_id_raw = parts[-2] method = "_".join(parts[:-2]) + + if len(tail) == 8 and all(c in "0123456789abcdef" for c in tail.lower()): + phash = tail + else: + phash = self.params_hash(tail) + scenario_id = int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw return method, scenario_id, phash \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 311a39e..ccda07c 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -760,8 +760,22 @@ async def _assess_provision( prov_df = prov_df.loc[context_ids].copy() return blocks[["geometry"]].join(prov_df, how="right") + async def calculate_provision_totals( + self, + provision_gdfs_dict: dict[str, gpd.GeoDataFrame], + ndigits: int = 2, + ) -> dict[str, float | None]: + prov_totals: dict[str, float | None] = {} + for st_name, prov_gdf in provision_gdfs_dict.items(): + if prov_gdf.demand.sum() == 0: + prov_totals[st_name] = None + else: + total = float(provision_strong_total(prov_gdf)) + prov_totals[st_name] = round(total, ndigits) + return prov_totals + async def territory_transformation_scenario_before( - self, token: str, params: ContextDevelopmentDTO + self, token: str, params: ContextDevelopmentDTO, context_blocks: gpd.GeoDataFrame ): method_name = "territory_transformation" @@ -807,13 +821,6 @@ async def territory_transformation_scenario_before( ) ) - context_blocks, context_buildings = await self.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, - ) - base_scenario_blocks, base_scenario_buildings = ( await self.aggregate_blocks_layer_scenario( base_scenario_id, base_src, base_year, token @@ -823,6 +830,12 @@ async def territory_transformation_scenario_before( before_blocks = pd.concat([context_blocks, base_scenario_blocks]).reset_index( drop=True ) + + if "is_project" not in before_blocks.columns: + before_blocks["is_project"] = False + else: + before_blocks["is_project"] = before_blocks["is_project"].fillna(False).astype(bool) + graph = get_accessibility_graph(before_blocks, "intermodal") acc_mx = calculate_accessibility_matrix(before_blocks, graph) @@ -831,15 +844,23 @@ async def territory_transformation_scenario_before( st_name = service_types.loc[st_id, "name"] _, demand, accessibility = service_types_config[st_name].values() prov_gdf = await self._assess_provision(before_blocks, acc_mx, st_name) + prov_gdf = prov_gdf.join( + before_blocks[["is_project"]].reindex(prov_gdf.index), + how="left" + ) + prov_gdf["is_project"] = prov_gdf["is_project"].fillna(False).astype(bool) prov_gdf = prov_gdf.to_crs(4326) prov_gdf = prov_gdf.drop(axis="columns", columns="provision_weak") prov_gdfs_before[st_name] = prov_gdf + prov_totals = await self.calculate_provision_totals(prov_gdfs_before) + existing_data = cached["data"] if cached else {} existing_data["before"] = { name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) for name, gdf in prov_gdfs_before.items() } + existing_data["before"]["provision_total_before"] = prov_totals self.cache.save( method_name, @@ -886,7 +907,7 @@ def _build_facade( return facade async def territory_transformation_scenario_after( - self, token, params: ContextDevelopmentDTO | DevelopmentDTO + self, token, params: ContextDevelopmentDTO | DevelopmentDTO, context_blocks: gpd.GeoDataFrame ): # provision after method_name = "territory_transformation" @@ -924,12 +945,12 @@ async def territory_transformation_scenario_after( ~service_types["infrastructure_type"].isna() ].copy() - context_blocks, _ = await self.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, - ) + # context_blocks, _ = await self.aggregate_blocks_layer_context( + # params.scenario_id, + # params.context_func_zone_source, + # params.context_func_source_year, + # token, + # ) scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( params.scenario_id, @@ -975,7 +996,7 @@ async def territory_transformation_scenario_after( num_params=facade.num_params, facade=facade, weights=services_weights, - max_evals=30, + max_evals=25, ) constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) tpe_optimizer = TPEOptimizer( @@ -985,7 +1006,7 @@ async def territory_transformation_scenario_after( ) best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=30, timeout=60000, initial_runs_num=1 + max_runs=25, timeout=60000, initial_runs_num=1 ) prov_gdfs_after = {} @@ -994,16 +1015,23 @@ async def territory_transformation_scenario_after( if st_name in facade._chosen_service_types: prov_df = facade._provision_adapter.get_last_provision_df(st_name) prov_gdf = ( - after_blocks[["geometry"]] + after_blocks[["geometry", "is_project"]] .join(prov_df, how="left") - .to_crs(4326) .drop(columns="provision_weak", errors="ignore") ) - num_cols = prov_gdf.select_dtypes(include="number").columns - prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) - prov_gdfs_after[st_name] = gpd.GeoDataFrame( - prov_gdf, geometry="geometry", crs="EPSG:4326" - ) + + if getattr(prov_gdf, "crs", None) is None: + prov_gdf = gpd.GeoDataFrame(prov_gdf, geometry="geometry", crs=after_blocks.crs) + prov_gdf = prov_gdf.to_crs(4326) + + prov_gdf["is_project"] = prov_gdf["is_project"].fillna(False).astype(bool) + num_cols = [c for c in prov_gdf.select_dtypes(include=["number"]).columns if c != "is_project"] + if num_cols: + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + + prov_gdfs_after[st_name] = gpd.GeoDataFrame(prov_gdf, geometry="geometry", crs="EPSG:4326") + + prov_totals = await self.calculate_provision_totals(prov_gdfs_after) from_cache = cached["data"] if cached else {} from_cache["after"] = { @@ -1013,6 +1041,7 @@ async def territory_transformation_scenario_after( from_cache["opt_context"] = { "best_x": best_x, } + from_cache["after"]["provision_total_after"] = prov_totals self.cache.save( method_name, @@ -1034,7 +1063,13 @@ async def territory_transformation( is_based = info["is_based"] updated_at = info["updated_at"] - prov_before = await self.territory_transformation_scenario_before(token, params) + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, + ) + prov_before = await self.territory_transformation_scenario_before(token, params, context_blocks) if is_based: return prov_before @@ -1053,7 +1088,7 @@ async def territory_transformation( } return {"before": prov_before, "after": prov_after} - prov_after = await self.territory_transformation_scenario_after(token, params) + prov_after = await self.territory_transformation_scenario_after(token, params, context_blocks) return {"before": prov_before, "after": prov_after} async def values_transformation( @@ -1071,6 +1106,13 @@ async def values_transformation( info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, + ) + cached = self.cache.load(method_name, params.scenario_id, phash) need_refresh = ( @@ -1080,17 +1122,11 @@ async def values_transformation( or "best_x" not in cached["data"]["opt_context"] ) if need_refresh: - await self.territory_transformation_scenario_after(token, params) + await self.territory_transformation_scenario_after(token, params, context_blocks) cached = self.cache.load(method_name, params.scenario_id, phash) best_x = cached["data"]["opt_context"]["best_x"] - context_blocks, _ = await self.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, - ) scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( params.scenario_id, params.proj_func_zone_source, diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 71de78e..7ff152b 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -31,6 +31,7 @@ def __init__( params: Any, params_hash: str, cache: file_cache, + task_id: str ): self.method = method self.scenario_id = scenario_id @@ -38,11 +39,12 @@ def __init__( self.params = params self.param_hash = params_hash - self.task_id = f"{method}_{scenario_id}_{self.param_hash}" + # self.task_id = f"{method}_{scenario_id}_{self.param_hash}" self.status: Literal["queued", "running", "done", "failed"] = "queued" self.result: dict | None = None self.error: str | None = None self.cache = cache + self.task_id = task_id async def to_response(self) -> dict: if self.status in {"queued", "running"}: @@ -88,13 +90,13 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: self.error = str(exc) -async def create_task(method: str, token: str, params) -> str: +async def create_task(method: str, token: str, params, task_id: str) -> str: norm_params = await effects_service.get_optimal_func_zone_data(params, token) params_for_hash = await effects_service.build_hash_params(norm_params, token) phash = file_cache.params_hash(params_for_hash) task = AnyTask( - method, norm_params.scenario_id, token, norm_params, phash, file_cache + method, norm_params.scenario_id, token, norm_params, phash, file_cache, task_id ) _task_map[task.task_id] = task await _task_queue.put(task) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 156c905..49bcf8d 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -62,6 +62,7 @@ async def create_task( params_filled, params_for_hash, file_cache, + task_id ) _task_map[task_id] = task await _task_queue.put(task) @@ -79,15 +80,14 @@ async def task_status(task_id: str): return {"task_id": task_id, "status": "done"} except Exception: pass + task = _task_map.get(task_id) if task: - payload = { - "task_id": getattr(task, "task_id", task_id), + return { + "task_id": task_id, "status": getattr(task, "status", "unknown"), + **({"error": str(task.error)} if getattr(task, "status", None) == "failed" and getattr(task, "error", None) else {}) } - if payload["status"] == "failed" and getattr(task, "error", None): - payload["error"] = str(task.error) - return payload raise http_exception(404, "task not found", task_id) From ca6e434c24f0bad669d07cb55dd5b4593fc41c96 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 18 Sep 2025 13:47:00 +0300 Subject: [PATCH 078/161] feat(effects): 1. F26 is now returning blocks geolayer with properties about services location 2. F35 now returning provision tables 3. 404 exception for when no physical objects are not found on territory 4. Fix for case when provision tables got returned in service_types router --- app/clients/urban_api_client.py | 4 + app/common/caching/caching_service.py | 6 +- app/common/utils/geodata.py | 10 +- app/effects_api/effects_controller.py | 3 +- app/effects_api/effects_service.py | 200 ++++++++++++++---- .../modules/service_type_service.py | 20 +- app/effects_api/modules/task_service.py | 2 +- app/effects_api/tasks_controller.py | 47 +++- 8 files changed, 231 insertions(+), 61 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 1874808..5a0826e 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -30,6 +30,10 @@ async def get_physical_objects( params=params, ) features = res["features"] + if not features: + raise http_exception( + 404, f"Physical objects needed for calculations are not found", params + ) return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( "physical_object_id" ) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index bb7db9f..2e8664f 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -114,5 +114,7 @@ def parse_task_id(self, task_id: str): else: phash = self.params_hash(tail) - scenario_id = int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw - return method, scenario_id, phash \ No newline at end of file + scenario_id = ( + int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw + ) + return method, scenario_id, phash diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index a41c525..5bfc9b0 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -4,7 +4,7 @@ import geopandas as gpd import pandas as pd from shapely.geometry.base import BaseGeometry -from shapely.wkt import loads, dumps +from shapely.wkt import dumps, loads from app.common.exceptions.http_exception_wrapper import http_exception from app.effects_api.constants.const import COL_RU @@ -21,7 +21,9 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: gdf = gdf.to_crs(4326) gdf_copy = gdf.copy() - gdf_copy.geometry = await asyncio.to_thread(round_coords, gdf_copy.geometry, ndigits) + gdf_copy.geometry = await asyncio.to_thread( + round_coords, gdf_copy.geometry, ndigits + ) return json.loads(gdf_copy.to_json(drop_id=True)) @@ -29,9 +31,9 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + def round_coords( - geometry: gpd.GeoSeries | BaseGeometry, - ndigits: int = 6 + geometry: gpd.GeoSeries | BaseGeometry, ndigits: int = 6 ) -> gpd.GeoSeries | BaseGeometry: if isinstance(geometry, gpd.GeoSeries): return geometry.map(lambda geom: loads(dumps(geom, rounding_precision=ndigits))) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 45e88b6..ad19f71 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -7,6 +7,7 @@ from app.common.auth.auth import verify_token +from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service from .dto.development_dto import ( ContextDevelopmentDTO, @@ -79,7 +80,7 @@ async def territory_transformation( @f_26_router.get("/values_development") async def values_development( - params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], + params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], token: str = Depends(verify_token), ): return await effects_service.values_transformation(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index ccda07c..7969e84 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -25,6 +25,7 @@ get_accessibility_context, get_accessibility_graph, ) +from iduedu import config from loguru import logger from app.effects_api.modules.scenario_service import ScenarioService @@ -34,7 +35,7 @@ from ..common.caching.caching_service import FileCache from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, round_coords from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES from .dto.development_dto import ( ContextDevelopmentDTO, @@ -58,6 +59,8 @@ SocioEconomicSchema, ) +config.change_logger_lvl("DEBUG") + class EffectsService: def __init__( @@ -761,9 +764,9 @@ async def _assess_provision( return blocks[["geometry"]].join(prov_df, how="right") async def calculate_provision_totals( - self, - provision_gdfs_dict: dict[str, gpd.GeoDataFrame], - ndigits: int = 2, + self, + provision_gdfs_dict: dict[str, gpd.GeoDataFrame], + ndigits: int = 2, ) -> dict[str, float | None]: prov_totals: dict[str, float | None] = {} for st_name, prov_gdf in provision_gdfs_dict.items(): @@ -775,7 +778,10 @@ async def calculate_provision_totals( return prov_totals async def territory_transformation_scenario_before( - self, token: str, params: ContextDevelopmentDTO, context_blocks: gpd.GeoDataFrame + self, + token: str, + params: ContextDevelopmentDTO, + context_blocks: gpd.GeoDataFrame = None, ): method_name = "territory_transformation" @@ -834,7 +840,9 @@ async def territory_transformation_scenario_before( if "is_project" not in before_blocks.columns: before_blocks["is_project"] = False else: - before_blocks["is_project"] = before_blocks["is_project"].fillna(False).astype(bool) + before_blocks["is_project"] = ( + before_blocks["is_project"].fillna(False).astype(bool) + ) graph = get_accessibility_graph(before_blocks, "intermodal") acc_mx = calculate_accessibility_matrix(before_blocks, graph) @@ -845,8 +853,7 @@ async def territory_transformation_scenario_before( _, demand, accessibility = service_types_config[st_name].values() prov_gdf = await self._assess_provision(before_blocks, acc_mx, st_name) prov_gdf = prov_gdf.join( - before_blocks[["is_project"]].reindex(prov_gdf.index), - how="left" + before_blocks[["is_project"]].reindex(prov_gdf.index), how="left" ) prov_gdf["is_project"] = prov_gdf["is_project"].fillna(False).astype(bool) prov_gdf = prov_gdf.to_crs(4326) @@ -907,7 +914,10 @@ def _build_facade( return facade async def territory_transformation_scenario_after( - self, token, params: ContextDevelopmentDTO | DevelopmentDTO, context_blocks: gpd.GeoDataFrame + self, + token, + params: ContextDevelopmentDTO | DevelopmentDTO, + context_blocks: gpd.GeoDataFrame, ): # provision after method_name = "territory_transformation" @@ -1021,15 +1031,25 @@ async def territory_transformation_scenario_after( ) if getattr(prov_gdf, "crs", None) is None: - prov_gdf = gpd.GeoDataFrame(prov_gdf, geometry="geometry", crs=after_blocks.crs) + prov_gdf = gpd.GeoDataFrame( + prov_gdf, geometry="geometry", crs=after_blocks.crs + ) prov_gdf = prov_gdf.to_crs(4326) - prov_gdf["is_project"] = prov_gdf["is_project"].fillna(False).astype(bool) - num_cols = [c for c in prov_gdf.select_dtypes(include=["number"]).columns if c != "is_project"] + prov_gdf["is_project"] = ( + prov_gdf["is_project"].fillna(False).astype(bool) + ) + num_cols = [ + c + for c in prov_gdf.select_dtypes(include=["number"]).columns + if c != "is_project" + ] if num_cols: prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) - prov_gdfs_after[st_name] = gpd.GeoDataFrame(prov_gdf, geometry="geometry", crs="EPSG:4326") + prov_gdfs_after[st_name] = gpd.GeoDataFrame( + prov_gdf, geometry="geometry", crs="EPSG:4326" + ) prov_totals = await self.calculate_provision_totals(prov_gdfs_after) @@ -1069,7 +1089,9 @@ async def territory_transformation( params.context_func_source_year, token, ) - prov_before = await self.territory_transformation_scenario_before(token, params, context_blocks) + prov_before = await self.territory_transformation_scenario_before( + token, params, context_blocks + ) if is_based: return prov_before @@ -1088,15 +1110,16 @@ async def territory_transformation( } return {"before": prov_before, "after": prov_after} - prov_after = await self.territory_transformation_scenario_after(token, params, context_blocks) + prov_after = await self.territory_transformation_scenario_after( + token, params, context_blocks + ) return {"before": prov_before, "after": prov_after} async def values_transformation( self, token: str, params: TerritoryTransformationDTO, - ) -> pd.DataFrame: - + ) -> dict: method_name = "territory_transformation" params = await self.get_optimal_func_zone_data(params, token) @@ -1114,7 +1137,6 @@ async def values_transformation( ) cached = self.cache.load(method_name, params.scenario_id, phash) - need_refresh = ( not cached or cached["meta"]["scenario_updated_at"] != updated_at @@ -1122,7 +1144,9 @@ async def values_transformation( or "best_x" not in cached["data"]["opt_context"] ) if need_refresh: - await self.territory_transformation_scenario_after(token, params, context_blocks) + await self.territory_transformation_scenario_after( + token, params, context_blocks + ) cached = self.cache.load(method_name, params.scenario_id, phash) best_x = cached["data"]["opt_context"]["best_x"] @@ -1133,12 +1157,30 @@ async def values_transformation( params.proj_func_source_year, token, ) - after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( - drop=True - ) - after_blocks["is_project"] = ( - after_blocks["is_project"].fillna(False).astype(bool) - ) + + after_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=False) + if "block_id" in after_blocks.columns: + after_blocks["block_id"] = after_blocks["block_id"].astype(int) + if after_blocks.index.name == "block_id": + after_blocks = after_blocks.reset_index(drop=True) + after_blocks = ( + after_blocks.drop_duplicates(subset="block_id", keep="last") + .set_index("block_id") + .sort_index() + ) + else: + after_blocks.index = after_blocks.index.astype(int) + after_blocks = after_blocks[ + ~after_blocks.index.duplicated(keep="last") + ].sort_index() + after_blocks.index.name = "block_id" + + if "is_project" in after_blocks.columns: + after_blocks["is_project"] = ( + after_blocks["is_project"].fillna(False).astype(bool) + ) + else: + after_blocks["is_project"] = False graph = get_accessibility_graph(after_blocks, "intermodal") acc_mx = calculate_accessibility_matrix(after_blocks, graph) @@ -1154,20 +1196,97 @@ async def values_transformation( ) facade = self._build_facade(after_blocks, acc_mx, service_types) + test_blocks: gpd.GeoDataFrame = after_blocks.loc[ + list(facade._blocks_lu.keys()) + ].copy() + test_blocks.index = test_blocks.index.astype(int) + + solution_df = facade.solution_to_services_df(best_x).copy() + solution_df["block_id"] = solution_df["block_id"].astype(int) + metrics = [ + c + for c in ["site_area", "build_floor_area", "capacity", "count"] + if c in solution_df.columns + ] + zero_dict = {m: 0 for m in metrics} + + if len(metrics): + agg = ( + solution_df.groupby(["block_id", "service_type"])[metrics] + .sum() + .sort_index() + ) + else: + agg = ( + solution_df.groupby(["block_id", "service_type"]) + .size() + .to_frame(name="__dummy__") + .drop(columns="__dummy__") + ) + + def _row_to_dict(s: pd.Series) -> dict: + d = {m: (0 if pd.isna(s.get(m)) else s.get(m)) for m in metrics} + for k, v in d.items(): + try: + fv = float(v) + d[k] = int(fv) if fv.is_integer() else fv + except Exception: + pass + return d + + if len(metrics): + cells = agg.apply(_row_to_dict, axis=1) + else: + cells = agg.apply(lambda _: {}, axis=1) + + wide = cells.unstack("service_type") + wide = wide.reindex(index=test_blocks.index) + + all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) + for s in all_services: + if s not in wide.columns: + wide[s] = np.nan + + def _fill_cell(x): + if isinstance(x, dict): + return x + return zero_dict.copy() + + wide = wide.applymap(_fill_cell) + wide = wide[all_services] + test_blocks_with_services: gpd.GeoDataFrame = test_blocks.join(wide, how="left") + + logger.info("Values transformed complete") + + geom_col = test_blocks_with_services.geometry.name + service_cols = all_services + base_cols = [ + c for c in ["is_project"] if c in test_blocks_with_services.columns + ] - solution_df = facade.solution_to_services_df(best_x) + gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] + gdf_out = gdf_out.to_crs(crs="EPSG:4326") + gdf_out.geometry = round_coords(gdf_out.geometry, 6) + geojson = json.loads(gdf_out.to_json()) - result = json.loads(solution_df.to_json(orient="records", date_format="iso")) - return result + self.cache.save( + "values_transformation", + params.scenario_id, + params_for_hash, + geojson, + scenario_updated_at=updated_at, + ) + + return geojson def _get_value_level(self, provisions: list[float | None]) -> float: vals = [p for p in provisions if p is not None] return float(np.mean(vals)) if vals else np.nan async def values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ): method_name = "values_oriented_requirements" @@ -1207,7 +1326,9 @@ async def values_oriented_requirements( scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] - scenario_blocks.loc[scenario_blocks["is_project"], ["population"] + cap_cols] = 0 + scenario_blocks.loc[ + scenario_blocks["is_project"], ["population"] + cap_cols + ] = 0 if "capacity" in scenario_blocks.columns: scenario_blocks = scenario_blocks.drop(columns="capacity") @@ -1224,14 +1345,22 @@ async def values_oriented_requirements( acc_mx = calculate_accessibility_matrix(blocks, graph) prov_gdfs: dict[str, gpd.GeoDataFrame] = {} - if cached and cached["meta"].get("scenario_updated_at") == updated_at and "provision" in cached["data"]: + if ( + cached + and cached["meta"].get("scenario_updated_at") == updated_at + and "provision" in cached["data"] + ): for st_name, fc in cached["data"]["provision"].items(): - prov_gdfs[st_name] = gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + prov_gdfs[st_name] = gpd.GeoDataFrame.from_features( + fc["features"], crs="EPSG:4326" + ) else: for st_id in service_types.index: st_name = service_types.loc[st_id, "name"] prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - prov_gdf = prov_gdf.to_crs(4326).drop(columns="provision_weak", errors="ignore") + prov_gdf = prov_gdf.to_crs(4326).drop( + columns="provision_weak", errors="ignore" + ) num_cols = prov_gdf.select_dtypes(include="number").columns prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) prov_gdfs[st_name] = prov_gdf @@ -1285,4 +1414,3 @@ async def values_oriented_requirements( ) return result_df - diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index aaa8f3e..a39e2aa 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -75,7 +75,9 @@ async def get_services_with_ids_from_layer( data = cached["data"] - def map_services(names: List[str]): + VALID_SERVICE_NAMES = set(SERVICE_TYPES_MAPPING.values()) + + def map_services(names: List[str]) -> List[dict]: result = [] for name in names: matched = [ @@ -89,19 +91,21 @@ def map_services(names: List[str]): result.append({"id": None, "name": name}) return result + def filter_service_keys(d: dict) -> List[str]: + if not isinstance(d, dict): + return [] + return [k for k in d.keys() if k in VALID_SERVICE_NAMES] + if "before" in data or "after" in data: - before_names = list(data.get("before", {}).keys()) - after_names = list(data.get("after", {}).keys()) + before_names = filter_service_keys(data.get("before", {})) + after_names = filter_service_keys(data.get("after", {})) return { "before": map_services(before_names), "after": map_services(after_names), } if "provision" in data: - prov_names = list(data["provision"].keys()) - return { - "services": map_services(prov_names) - } + prov_names = [k for k in data["provision"].keys() if k in VALID_SERVICE_NAMES] + return {"services": map_services(prov_names)} return {"before": [], "after": []} - diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 7ff152b..25c47a0 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -31,7 +31,7 @@ def __init__( params: Any, params_hash: str, cache: file_cache, - task_id: str + task_id: str, ): self.method = method self.scenario_id = scenario_id diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 49bcf8d..a8f0abc 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -62,7 +62,7 @@ async def create_task( params_filled, params_for_hash, file_cache, - task_id + task_id, ) _task_map[task_id] = task await _task_queue.put(task) @@ -86,7 +86,12 @@ async def task_status(task_id: str): return { "task_id": task_id, "status": getattr(task, "status", "unknown"), - **({"error": str(task.error)} if getattr(task, "status", None) == "failed" and getattr(task, "error", None) else {}) + **( + {"error": str(task.error)} + if getattr(task, "status", None) == "failed" + and getattr(task, "error", None) + else {} + ), } raise http_exception(404, "task not found", task_id) @@ -108,18 +113,42 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str data: dict = cached["data"] - if "after" not in data: - fc = data["before"].get(service_name) + if "after" not in data or not data.get("after"): + fc = data.get("before", {}).get(service_name) if not fc: raise http_exception(404, f"service '{service_name}' not found") return JSONResponse(content=fc) - fc_before = data["before"].get(service_name) - fc_after = data["after"].get(service_name) - if not (fc_before and fc_after): - raise http_exception(404, f"service '{service_name}' not found") + before_dict = data.get("before", {}) or {} + after_dict = data.get("after", {}) or {} + + fc_before = before_dict.get(service_name) + fc_after = after_dict.get(service_name) + + provision_before = before_dict.get("provision_total_before") + provision_after = after_dict.get("provision_total_after") + + if fc_before and fc_after: + return JSONResponse( + content={ + "before": fc_before, + "after": fc_after, + "provision_total_before": provision_before, + "provision_total_after": provision_after, + } + ) + + elif fc_before and not fc_after: + return JSONResponse( + content={"before": fc_before, "provision_total_before": provision_before} + ) + + elif fc_after and not fc_before: + return JSONResponse( + content={"after": fc_after, "provision_total_after": provision_after} + ) - return JSONResponse(content={"before": fc_before, "after": fc_after}) + raise http_exception(404, f"service '{service_name}' not found") @router.get("/values_oriented_requirements/{scenario_id}/{service_name}") From 8ab573d7fe3097d7c0843fd31916fcd61d245625 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 19 Sep 2025 17:47:31 +0300 Subject: [PATCH 079/161] fix(exception_handlers): 1. New business-level logic exception classes added 2. Deleted comments from code --- app/clients/urban_api_client.py | 23 ++++--- app/common/api_handlers/json_api_handler.py | 1 - app/common/exceptions/errors.py | 13 ++++ app/effects_api/effects_controller.py | 1 - app/effects_api/effects_service.py | 9 --- .../modules/service_type_service.py | 63 +++++++++---------- app/effects_api/modules/task_service.py | 1 - app/effects_api/tasks_controller.py | 4 +- app/main.py | 15 ++++- 9 files changed, 71 insertions(+), 59 deletions(-) create mode 100644 app/common/exceptions/errors.py diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 5a0826e..aed9805 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -7,6 +7,7 @@ from loguru import logger from app.common.api_handlers.json_api_handler import JSONAPIHandler +from app.common.exceptions.errors import NoFeaturesError, UpstreamApiError from app.common.exceptions.http_exception_wrapper import http_exception @@ -23,17 +24,19 @@ async def get_physical_objects( token: str, **params: Any, ) -> gpd.GeoDataFrame: - headers = {"Authorization": f"Bearer {token}"} - res = await self.json_handler.get( - f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", - headers=headers, - params=params, - ) - features = res["features"] - if not features: - raise http_exception( - 404, f"Physical objects needed for calculations are not found", params + try: + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", + headers={"Authorization": f"Bearer {token}"}, + params=params, ) + except Exception as e: + raise UpstreamApiError("Urban API request failed") from e + + features = (res or {}).get("features") or [] + if not features: + raise NoFeaturesError("No physical objects to process") + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( "physical_object_id" ) diff --git a/app/common/api_handlers/json_api_handler.py b/app/common/api_handlers/json_api_handler.py index 678948d..ff6e7f9 100644 --- a/app/common/api_handlers/json_api_handler.py +++ b/app/common/api_handlers/json_api_handler.py @@ -29,7 +29,6 @@ async def _check_response_status( return await response.json(content_type="application/json") elif response.status == 500: - # определим тип контента по заголовку content_type = (response.headers.get("Content-Type") or "").lower() if "application/json" in content_type: diff --git a/app/common/exceptions/errors.py b/app/common/exceptions/errors.py new file mode 100644 index 0000000..a3be38a --- /dev/null +++ b/app/common/exceptions/errors.py @@ -0,0 +1,13 @@ +class DomainError(Exception): + pass + + +class UpstreamApiError(DomainError): + def __init__(self, msg, *, status=None, payload=None): + super().__init__(msg) + self.status = status + self.payload = payload or {} + + +class NoFeaturesError(DomainError): + pass diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index ad19f71..fa41c35 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -7,7 +7,6 @@ from app.common.auth.auth import verify_token -from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service from .dto.development_dto import ( ContextDevelopmentDTO, diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 7969e84..a9168c7 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -59,8 +59,6 @@ SocioEconomicSchema, ) -config.change_logger_lvl("DEBUG") - class EffectsService: def __init__( @@ -955,13 +953,6 @@ async def territory_transformation_scenario_after( ~service_types["infrastructure_type"].isna() ].copy() - # context_blocks, _ = await self.aggregate_blocks_layer_context( - # params.scenario_id, - # params.context_func_zone_source, - # params.context_func_source_year, - # token, - # ) - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( params.scenario_id, params.proj_func_zone_source, diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index a39e2aa..657a304 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -8,6 +8,13 @@ from app.common.caching.caching_service import FileCache from app.effects_api.constants.const import SERVICE_TYPES_MAPPING +_SOCIAL_VALUES_BY_ST: Dict[int, Optional[List[int]]] = {} +_SOCIAL_VALUES_LOCK = asyncio.Lock() +_SERVICE_NAME_TO_ID: dict[str, int] = { + name: sid for sid, name in SERVICE_TYPES_MAPPING.items() +} +_VALID_SERVICE_NAMES: set[str] = set(_SERVICE_NAME_TO_ID.keys()) + for st_id, st_name in SERVICE_TYPES_MAPPING.items(): if st_name is None: continue @@ -18,10 +25,6 @@ async def _adapt_name(service_type_id: int) -> Optional[str]: return SERVICE_TYPES_MAPPING.get(service_type_id) -_SOCIAL_VALUES_BY_ST: Dict[int, Optional[List[int]]] = {} -_SOCIAL_VALUES_LOCK = asyncio.Lock() - - async def _warmup_social_values( service_type_ids: List[int], client: UrbanAPIClient ) -> None: @@ -64,48 +67,42 @@ async def adapt_service_types( return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] +def _map_services(names: list[str]) -> list[dict]: + out = [] + get_id = _SERVICE_NAME_TO_ID.get + for n in names: + sid = get_id(n) + out.append({"id": sid, "name": n}) + return out + + +def _filter_service_keys(d: dict | None) -> list[str]: + if not isinstance(d, dict): + return [] + return [k for k in d.keys() if k in _VALID_SERVICE_NAMES] + + async def get_services_with_ids_from_layer( scenario_id: int, method: str, cache: FileCache, ) -> dict: - cached: Optional[dict] = cache.load_latest(method, scenario_id) + cached: dict | None = cache.load_latest(method, scenario_id) if not cached or "data" not in cached: return {"before": [], "after": []} - data = cached["data"] - - VALID_SERVICE_NAMES = set(SERVICE_TYPES_MAPPING.values()) - - def map_services(names: List[str]) -> List[dict]: - result = [] - for name in names: - matched = [ - {"id": sid, "name": sname} - for sid, sname in SERVICE_TYPES_MAPPING.items() - if sname == name - ] - if matched: - result.extend(matched) - else: - result.append({"id": None, "name": name}) - return result - - def filter_service_keys(d: dict) -> List[str]: - if not isinstance(d, dict): - return [] - return [k for k in d.keys() if k in VALID_SERVICE_NAMES] + data: dict = cached["data"] if "before" in data or "after" in data: - before_names = filter_service_keys(data.get("before", {})) - after_names = filter_service_keys(data.get("after", {})) + before_names = _filter_service_keys(data.get("before")) + after_names = _filter_service_keys(data.get("after")) return { - "before": map_services(before_names), - "after": map_services(after_names), + "before": _map_services(before_names), + "after": _map_services(after_names), } if "provision" in data: - prov_names = [k for k in data["provision"].keys() if k in VALID_SERVICE_NAMES] - return {"services": map_services(prov_names)} + prov_names = _filter_service_keys(data["provision"]) + return {"services": _map_services(prov_names)} return {"before": [], "after": []} diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 25c47a0..43fdf39 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -39,7 +39,6 @@ def __init__( self.params = params self.param_hash = params_hash - # self.task_id = f"{method}_{scenario_id}_{self.param_hash}" self.status: Literal["queued", "running", "done", "failed"] = "queued" self.result: dict | None = None self.error: str | None = None diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index a8f0abc..d110d31 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -138,12 +138,12 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str } ) - elif fc_before and not fc_after: + if fc_before and not fc_after: return JSONResponse( content={"before": fc_before, "provision_total_before": provision_before} ) - elif fc_after and not fc_before: + if fc_after and not fc_before: return JSONResponse( content={"after": fc_after, "provision_total_after": provision_after} ) diff --git a/app/main.py b/app/main.py index ab159ea..ec1cd4f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,11 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse +from app.common.exceptions.errors import NoFeaturesError, UpstreamApiError from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.effects_controller import ( development_router, @@ -24,9 +25,19 @@ lifespan=lifespan, ) + +@app.exception_handler(NoFeaturesError) +async def _handle_no_features(request, exc): + raise HTTPException(status_code=404, detail="Physical objects not found") + + +@app.exception_handler(UpstreamApiError) +async def _handle_upstream(request, exc): + raise HTTPException(status_code=502, detail="Upstream API error") + + origins = ["*"] -# disable cors app.add_middleware( CORSMiddleware, allow_origins=origins, From fafc03caaa8e18a4dcd1839b58d81bc327eaca4b Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 19 Sep 2025 22:00:07 +0300 Subject: [PATCH 080/161] fix(exceptions): 1. Fixed exception for physical object requests --- app/clients/urban_api_client.py | 27 +++++++++------------- app/common/caching/caching_service.py | 8 +++---- app/effects_api/modules/context_service.py | 3 +++ app/main.py | 16 +------------ 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index aed9805..bc7f96c 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -7,7 +7,6 @@ from loguru import logger from app.common.api_handlers.json_api_handler import JSONAPIHandler -from app.common.exceptions.errors import NoFeaturesError, UpstreamApiError from app.common.exceptions.http_exception_wrapper import http_exception @@ -23,23 +22,19 @@ async def get_physical_objects( scenario_id: int, token: str, **params: Any, - ) -> gpd.GeoDataFrame: - try: - res = await self.json_handler.get( - f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", - headers={"Authorization": f"Bearer {token}"}, - params=params, - ) - except Exception as e: - raise UpstreamApiError("Urban API request failed") from e - + ) -> gpd.GeoDataFrame | None: + res = await self.json_handler.get( + f"/api/v1/scenarios/{scenario_id}/context/physical_objects_with_geometry", + headers={"Authorization": f"Bearer {token}"}, + params=params, + ) features = (res or {}).get("features") or [] if not features: - raise NoFeaturesError("No physical objects to process") - - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "physical_object_id" - ) + return None + else: + return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( + "physical_object_id" + ) async def get_services( self, scenario_id: int, token: str, **kwargs: Any diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 2e8664f..e7c34ec 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -105,16 +105,14 @@ def parse_task_id(self, task_id: str): if len(parts) < 3: return None, None, None - tail = "_".join(parts[-1:]) + tail = parts[-1] scenario_id_raw = parts[-2] method = "_".join(parts[:-2]) - if len(tail) == 8 and all(c in "0123456789abcdef" for c in tail.lower()): + if len(tail) == 8 and tail.lower().strip("0123456789abcdef") == "": phash = tail else: phash = self.params_hash(tail) - scenario_id = ( - int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw - ) + scenario_id = int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw return method, scenario_id, phash diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 112d123..925b1eb 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -6,6 +6,7 @@ from blocksnet.preprocessing.imputing import impute_buildings, impute_services from app.clients.urban_api_client import UrbanAPIClient +from app.common.exceptions.http_exception_wrapper import http_exception from app.common.utils.geodata import get_best_functional_zones_source from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID from app.effects_api.modules.buildings_service import adapt_buildings @@ -112,6 +113,8 @@ async def get_context_buildings(scenario_id: int, token: str, client: UrbanAPICl physical_object_type_id=LIVING_BUILDINGS_ID, centers_only=True, ) + if gdf is None or gdf.empty: + raise http_exception(404, "No living buildings found for given scenario") gdf = adapt_buildings(gdf.reset_index(drop=True)) crs = gdf.estimate_utm_crs() return impute_buildings(gdf.to_crs(crs)).to_crs(4326) diff --git a/app/main.py b/app/main.py index ec1cd4f..7b3fcba 100644 --- a/app/main.py +++ b/app/main.py @@ -1,11 +1,8 @@ -from contextlib import asynccontextmanager - -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import RedirectResponse -from app.common.exceptions.errors import NoFeaturesError, UpstreamApiError from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.effects_controller import ( development_router, @@ -25,17 +22,6 @@ lifespan=lifespan, ) - -@app.exception_handler(NoFeaturesError) -async def _handle_no_features(request, exc): - raise HTTPException(status_code=404, detail="Physical objects not found") - - -@app.exception_handler(UpstreamApiError) -async def _handle_upstream(request, exc): - raise HTTPException(status_code=502, detail="Upstream API error") - - origins = ["*"] app.add_middleware( From 9a2673799a9d2300919fafb1ca1c3ccd2e4e0bdb Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 19 Sep 2025 22:06:04 +0300 Subject: [PATCH 081/161] fix(constants): 1. Added max runs and evaluations into constants.py --- app/effects_api/constants/const.py | 6 ++++++ app/effects_api/effects_service.py | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index d924a05..a3dea3d 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -133,3 +133,9 @@ # ID of water objects physical_object_function_id WATER_ID = 4 + +#Maximum number of function evaluations +MAX_EVALS = 25 + +#Maximum number of runs for optimization +MAX_RUNS = 25 \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index a9168c7..1448661 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -36,7 +36,7 @@ from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, round_coords -from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES +from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES, MAX_EVALS, MAX_RUNS from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, @@ -997,7 +997,7 @@ async def territory_transformation_scenario_after( num_params=facade.num_params, facade=facade, weights=services_weights, - max_evals=25, + max_evals=MAX_EVALS, ) constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) tpe_optimizer = TPEOptimizer( @@ -1007,7 +1007,7 @@ async def territory_transformation_scenario_after( ) best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=25, timeout=60000, initial_runs_num=1 + max_runs=MAX_RUNS, timeout=60000, initial_runs_num=1 ) prov_gdfs_after = {} From a97bade7bb9984381e9edff2a709c972fdb8c36f Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 21 Sep 2025 20:58:28 +0300 Subject: [PATCH 082/161] fix(tasks): 1. Return for after --- app/effects_api/modules/task_service.py | 11 ++++- app/effects_api/tasks_controller.py | 64 ++++++++++++++++--------- 2 files changed, 52 insertions(+), 23 deletions(-) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 43fdf39..ba5eec0 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -58,7 +58,16 @@ def run_sync(self) -> None: self.status = "running" cached = self.cache.load(self.method, self.scenario_id, self.param_hash) - if cached: + + def cache_complete(method: str, cached_obj: dict | None) -> bool: + if not cached_obj: + return False + data = cached_obj.get("data") or {} + if method == "territory_transformation": + return bool(data.get("after")) + return True + + if cache_complete(self.method, cached): logger.info(f"[{self.task_id}] loaded from cache") self.result = cached["data"] self.status = "done" diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index d110d31..4bb043f 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -1,3 +1,4 @@ +import asyncio from typing import Annotated from fastapi import APIRouter @@ -19,6 +20,16 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) +_locks: dict[str, asyncio.Lock] = {} + + +def _get_lock(key: str) -> asyncio.Lock: + lock = _locks.get(key) + if lock is None: + lock = asyncio.Lock() + _locks[key] = lock + return lock + async def _with_defaults( dto: ContextDevelopmentDTO, token: str @@ -41,33 +52,42 @@ async def create_task( if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) - params_filled = await effects_service.get_optimal_func_zone_data(params, token) + coarse_key = f"{method}:{params.scenario_id}" + lock = _get_lock(coarse_key) - params_for_hash = await effects_service.build_hash_params(params_filled, token) - phash = file_cache.params_hash(params_for_hash) + async with lock: + params_filled = await effects_service.get_optimal_func_zone_data(params, token) + params_for_hash = await effects_service.build_hash_params(params_filled, token) + phash = file_cache.params_hash(params_for_hash) - task_id = f"{method}_{params_filled.scenario_id}_{phash}" + task_id = f"{method}_{params_filled.scenario_id}_{phash}" - if file_cache.load(method, params_filled.scenario_id, phash): - return {"task_id": task_id, "status": "done"} - - existing = _task_map.get(task_id) - if existing and existing.status in {"queued", "running"}: - return {"task_id": task_id, "status": existing.status} + cached = file_cache.load(method, params_filled.scenario_id, phash) + if cached: + data = cached.get("data") or {} + cache_complete = True + if method == "territory_transformation": + cache_complete = bool(data.get("after")) + if cache_complete: + return {"task_id": task_id, "status": "done"} - task = AnyTask( - method, - params_filled.scenario_id, - token, - params_filled, - params_for_hash, - file_cache, - task_id, - ) - _task_map[task_id] = task - await _task_queue.put(task) + existing = _task_map.get(task_id) + if existing and existing.status in {"queued", "running"}: + return {"task_id": task_id, "status": existing.status} + + task = AnyTask( + method, + params_filled.scenario_id, + token, + params_filled, + phash, + file_cache, + task_id, + ) + _task_map[task_id] = task + await _task_queue.put(task) - return {"task_id": task_id, "status": "queued"} + return {"task_id": task_id, "status": "queued"} @router.get("/status/{task_id}") From b4511a13a8508e3d790520ca48fe6085088f277f Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 24 Sep 2025 19:52:50 +0300 Subject: [PATCH 083/161] fix(effects_logic): 1. values transformation methods no longer rewrites territory_transformation cache 2. Added exception handling for graph composing errors 3. Blueprints for mapping english f36 properties to Russian --- app/clients/urban_api_client.py | 1 + app/effects_api/effects_service.py | 124 +++++++++--------- .../modules/service_type_service.py | 27 ++++ 3 files changed, 92 insertions(+), 60 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index bc7f96c..8436821 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -310,6 +310,7 @@ async def get_service_types(self, **kwargs): rows = [ { "service_type_id": it.get("service_type_id"), + # "name": it.get("name"), "infrastructure_type": it.get("infrastructure_type"), "weight_value": it.get("properties", {}).get("weight_value"), } diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 1448661..f65421d 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -916,6 +916,7 @@ async def territory_transformation_scenario_after( token, params: ContextDevelopmentDTO | DevelopmentDTO, context_blocks: gpd.GeoDataFrame, + save_cache: bool = True, ): # provision after method_name = "territory_transformation" @@ -967,7 +968,11 @@ async def territory_transformation_scenario_after( after_blocks["is_project"] = ( after_blocks["is_project"].fillna(False).astype(bool) ) - graph = get_accessibility_graph(after_blocks, "intermodal") + try: + graph = get_accessibility_graph(after_blocks, "intermodal") + except Exception as e: + raise http_exception(500, "Error generating territory graph", _detail=str(e)) + acc_mx = calculate_accessibility_matrix(after_blocks, graph) service_types["infrastructure_weight"] = ( @@ -1054,15 +1059,26 @@ async def territory_transformation_scenario_after( } from_cache["after"]["provision_total_after"] = prov_totals - self.cache.save( - method_name, - params.scenario_id, - params_for_hash, - from_cache, - scenario_updated_at=updated_at, - ) + payload = { + "after": prov_gdfs_after, + "opt_context": {"best_x": best_x}, + } + payload["after"]["provision_total_after"] = prov_totals - return prov_gdfs_after + if save_cache: + self.cache.save( + "territory_transformation", + params.scenario_id, + params_for_hash, + payload, + scenario_updated_at=updated_at, + ) + + return { + "best_x": best_x, + "prov_totals": prov_totals, + "prov_gdfs_after": prov_gdfs_after, + } async def territory_transformation( self, @@ -1107,11 +1123,12 @@ async def territory_transformation( return {"before": prov_before, "after": prov_after} async def values_transformation( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ) -> dict: - method_name = "territory_transformation" + opt_method = "territory_transformation_opt" + params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) @@ -1127,20 +1144,28 @@ async def values_transformation( token, ) - cached = self.cache.load(method_name, params.scenario_id, phash) + opt_cached = self.cache.load(opt_method, params.scenario_id, phash) need_refresh = ( - not cached - or cached["meta"]["scenario_updated_at"] != updated_at - or "opt_context" not in cached["data"] - or "best_x" not in cached["data"]["opt_context"] + not opt_cached + or opt_cached["meta"]["scenario_updated_at"] != updated_at + or "best_x" not in opt_cached["data"] ) if need_refresh: - await self.territory_transformation_scenario_after( - token, params, context_blocks + res = await self.territory_transformation_scenario_after( + token, params, context_blocks, save_cache=False ) - cached = self.cache.load(method_name, params.scenario_id, phash) + best_x_val = res["best_x"] - best_x = cached["data"]["opt_context"]["best_x"] + self.cache.save( + opt_method, + params.scenario_id, + params_for_hash, + {"best_x": best_x_val}, + scenario_updated_at=updated_at, + ) + opt_cached = self.cache.load(opt_method, params.scenario_id, phash) + + best_x = opt_cached["data"]["best_x"] scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( params.scenario_id, @@ -1161,52 +1186,40 @@ async def values_transformation( ) else: after_blocks.index = after_blocks.index.astype(int) - after_blocks = after_blocks[ - ~after_blocks.index.duplicated(keep="last") - ].sort_index() + after_blocks = after_blocks[~after_blocks.index.duplicated(keep="last")].sort_index() after_blocks.index.name = "block_id" if "is_project" in after_blocks.columns: - after_blocks["is_project"] = ( - after_blocks["is_project"].fillna(False).astype(bool) - ) + after_blocks["is_project"] = after_blocks["is_project"].fillna(False).astype(bool) else: after_blocks["is_project"] = False - graph = get_accessibility_graph(after_blocks, "intermodal") + try: + graph = get_accessibility_graph(after_blocks, "intermodal") + except Exception as e: + raise http_exception(500, "Error generating territory graph", _detail=str(e)) + acc_mx = calculate_accessibility_matrix(after_blocks, graph) service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[ - ~service_types["infrastructure_type"].isna() - ].copy() + service_types = service_types[~service_types["infrastructure_type"].isna()].copy() service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) facade = self._build_facade(after_blocks, acc_mx, service_types) - test_blocks: gpd.GeoDataFrame = after_blocks.loc[ - list(facade._blocks_lu.keys()) - ].copy() + test_blocks: gpd.GeoDataFrame = after_blocks.loc[list(facade._blocks_lu.keys())].copy() test_blocks.index = test_blocks.index.astype(int) solution_df = facade.solution_to_services_df(best_x).copy() solution_df["block_id"] = solution_df["block_id"].astype(int) - metrics = [ - c - for c in ["site_area", "build_floor_area", "capacity", "count"] - if c in solution_df.columns - ] + metrics = [c for c in ["site_area", "build_floor_area", "capacity", "count"] if c in solution_df.columns] zero_dict = {m: 0 for m in metrics} if len(metrics): - agg = ( - solution_df.groupby(["block_id", "service_type"])[metrics] - .sum() - .sort_index() - ) + agg = solution_df.groupby(["block_id", "service_type"])[metrics].sum().sort_index() else: agg = ( solution_df.groupby(["block_id", "service_type"]) @@ -1225,13 +1238,8 @@ def _row_to_dict(s: pd.Series) -> dict: pass return d - if len(metrics): - cells = agg.apply(_row_to_dict, axis=1) - else: - cells = agg.apply(lambda _: {}, axis=1) - - wide = cells.unstack("service_type") - wide = wide.reindex(index=test_blocks.index) + cells = agg.apply(_row_to_dict, axis=1) if len(metrics) else agg.apply(lambda _: {}, axis=1) + wide = cells.unstack("service_type").reindex(index=test_blocks.index) all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) for s in all_services: @@ -1239,9 +1247,7 @@ def _row_to_dict(s: pd.Series) -> dict: wide[s] = np.nan def _fill_cell(x): - if isinstance(x, dict): - return x - return zero_dict.copy() + return x if isinstance(x, dict) else zero_dict.copy() wide = wide.applymap(_fill_cell) wide = wide[all_services] @@ -1251,9 +1257,7 @@ def _fill_cell(x): geom_col = test_blocks_with_services.geometry.name service_cols = all_services - base_cols = [ - c for c in ["is_project"] if c in test_blocks_with_services.columns - ] + base_cols = [c for c in ["is_project"] if c in test_blocks_with_services.columns] gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] gdf_out = gdf_out.to_crs(crs="EPSG:4326") diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 657a304..5d6c507 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -106,3 +106,30 @@ async def get_services_with_ids_from_layer( return {"services": _map_services(prov_names)} return {"before": [], "after": []} + +async def build_en_to_ru_map(service_types_df: pd.DataFrame) -> dict[str, str]: + russian_names_dict = {} + for st_id, en_key in SERVICE_TYPES_MAPPING.items(): + if not en_key: + continue + if st_id in service_types_df.index: + ru_name = service_types_df.loc[st_id, "name"] + if isinstance(ru_name, pd.Series): # на всякий + ru_name = ru_name.iloc[0] + if isinstance(ru_name, str) and ru_name.strip(): + russian_names_dict[en_key] = ru_name + return russian_names_dict + +async def remap_properties_keys_in_geojson(geojson: dict, en2ru: dict[str, str]) -> dict: + feats = geojson.get("features", []) + for f in feats: + props = f.get("properties", {}) + to_rename = [(k, en2ru[k]) for k in props.keys() if k in en2ru] + for old_k, new_k in to_rename: + if new_k in props and isinstance(props[new_k], dict) and isinstance(props[old_k], dict): + merged = {**props[old_k], **props[new_k]} + props[new_k] = merged + else: + props[new_k] = props[old_k] + del props[old_k] + return geojson From b458c93f92b5a351c95df76f1362b507ab5697ff Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 26 Sep 2025 14:50:37 +0300 Subject: [PATCH 084/161] fix(effects_logic): 1. mapping english f26 properties to Russian --- app/clients/urban_api_client.py | 2 +- app/common/utils/geodata.py | 4 ++++ app/effects_api/effects_service.py | 33 +++++++++++++----------------- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 8436821..ae3b8c1 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -310,7 +310,7 @@ async def get_service_types(self, **kwargs): rows = [ { "service_type_id": it.get("service_type_id"), - # "name": it.get("name"), + "name": it.get("name"), "infrastructure_type": it.get("infrastructure_type"), "weight_value": it.get("properties", {}).get("weight_value"), } diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 5bfc9b0..325ca8e 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -32,6 +32,10 @@ def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") +def is_fc(obj: dict) -> bool: + return isinstance(obj, dict) and obj.get("type") == "FeatureCollection" and "features" in obj + + def round_coords( geometry: gpd.GeoSeries | BaseGeometry, ndigits: int = 6 ) -> gpd.GeoSeries | BaseGeometry: diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index f65421d..451dc2e 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -29,13 +29,14 @@ from loguru import logger from app.effects_api.modules.scenario_service import ScenarioService -from app.effects_api.modules.service_type_service import adapt_service_types +from app.effects_api.modules.service_type_service import adapt_service_types, build_en_to_ru_map, \ + remap_properties_keys_in_geojson from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, round_coords +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, round_coords, is_fc from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES, MAX_EVALS, MAX_RUNS from .dto.development_dto import ( ContextDevelopmentDTO, @@ -796,10 +797,7 @@ async def territory_transformation_scenario_before( ) ) - params_for_hash = params.model_dump() | { - "base_func_zone_source": base_src, - "base_func_zone_year": base_year, - } + params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) cached = self.cache.load(method_name, params.scenario_id, phash) @@ -808,7 +806,7 @@ async def territory_transformation_scenario_before( and cached["meta"]["scenario_updated_at"] == updated_at and "before" in cached["data"] ): - return {n: fc_to_gdf(fc) for n, fc in cached["data"]["before"].items()} + return {n: fc_to_gdf(fc) for n, fc in cached["data"]["before"].items() if is_fc(fc)} logger.info("Cache stale or missing: recalculating BEFORE") @@ -1049,28 +1047,22 @@ async def territory_transformation_scenario_after( prov_totals = await self.calculate_provision_totals(prov_gdfs_after) - from_cache = cached["data"] if cached else {} - from_cache["after"] = { + after_fc = { name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) for name, gdf in prov_gdfs_after.items() } - from_cache["opt_context"] = { - "best_x": best_x, - } - from_cache["after"]["provision_total_after"] = prov_totals + after_fc["provision_total_after"] = prov_totals - payload = { - "after": prov_gdfs_after, - "opt_context": {"best_x": best_x}, - } - payload["after"]["provision_total_after"] = prov_totals + from_cache = (cached.get("data", {}).copy() if cached else {}) + from_cache["after"] = after_fc + from_cache["opt_context"] = {"best_x": best_x} if save_cache: self.cache.save( "territory_transformation", params.scenario_id, params_for_hash, - payload, + from_cache, # <-- сохраняем объединённые данные scenario_updated_at=updated_at, ) @@ -1263,6 +1255,9 @@ def _fill_cell(x): gdf_out = gdf_out.to_crs(crs="EPSG:4326") gdf_out.geometry = round_coords(gdf_out.geometry, 6) geojson = json.loads(gdf_out.to_json()) + service_types = await self.urban_api_client.get_service_types() + en2ru = await build_en_to_ru_map(service_types) + geojson = await remap_properties_keys_in_geojson(geojson, en2ru) self.cache.save( "values_transformation", From 73f48008b43ace426dd38c7ea1b545b46dc520dd Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 26 Sep 2025 15:51:08 +0300 Subject: [PATCH 085/161] fix(task_status): 1. Task status router fix --- app/effects_api/tasks_controller.py | 41 ++++++++++++++++++----------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 4bb043f..374ebc9 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -37,6 +37,23 @@ async def _with_defaults( return await effects_service.get_optimal_func_zone_data(dto, token) +def _is_fc(x: dict) -> bool: + return isinstance(x, dict) and x.get("type") == "FeatureCollection" and isinstance(x.get("features"), list) + + +def _section_ready(sec: dict | None) -> bool: + return isinstance(sec, dict) and any(_is_fc(v) for v in sec.values()) + + +def _cache_complete(method: str, cached: dict | None) -> bool: + if not cached: + return False + data = cached.get("data") or {} + if method == "territory_transformation": + return _section_ready(data.get("before")) and _section_ready(data.get("after")) + return True + + @router.get("/methods") async def get_methods(): """router for getting method names available for tasks creation""" @@ -63,13 +80,8 @@ async def create_task( task_id = f"{method}_{params_filled.scenario_id}_{phash}" cached = file_cache.load(method, params_filled.scenario_id, phash) - if cached: - data = cached.get("data") or {} - cache_complete = True - if method == "territory_transformation": - cache_complete = bool(data.get("after")) - if cache_complete: - return {"task_id": task_id, "status": "done"} + if _cache_complete(method, cached): + return {"task_id": task_id, "status": "done"} existing = _task_map.get(task_id) if existing and existing.status in {"queued", "running"}: @@ -96,23 +108,22 @@ async def task_status(task_id: str): if method and scenario_id is not None and phash: try: cached = file_cache.load(method, scenario_id, phash) - if cached: + if _cache_complete(method, cached): return {"task_id": task_id, "status": "done"} + if cached: + return {"task_id": task_id, "status": "running"} except Exception: pass task = _task_map.get(task_id) if task: - return { + payload = { "task_id": task_id, "status": getattr(task, "status", "unknown"), - **( - {"error": str(task.error)} - if getattr(task, "status", None) == "failed" - and getattr(task, "error", None) - else {} - ), } + if getattr(task, "status", None) == "failed" and getattr(task, "error", None): + payload["error"] = str(task.error) + return payload raise http_exception(404, "task not found", task_id) From 1fc720953d63a36178b9f1c385d15a3dc4e4a7ec Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 30 Sep 2025 12:09:03 +0300 Subject: [PATCH 086/161] fix(effects_service_cache): 1. Fix for reading cache --- app/effects_api/effects_service.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 451dc2e..5786942 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -935,14 +935,14 @@ async def territory_transformation_scenario_after( cached = self.cache.load(method_name, params.scenario_id, phash) if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "after" in cached["data"] + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "after" in cached["data"] ): - return { - n: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") - for n, fc in cached["data"]["after"].items() - } + gdfs_after = {n: fc_to_gdf(fc) for n, fc in cached["data"]["after"].items() if is_fc(fc)} + totals = cached["data"]["after"].get("provision_total_after") + opt_ctx = (cached.get("data", {}).get("opt_context") or {}) + return {"prov_gdfs_after": gdfs_after, "prov_totals": totals, **opt_ctx} logger.info("AFTER: cache stale or missing; recalculating") @@ -1104,8 +1104,9 @@ async def territory_transformation( and "after" in cached["data"] ): prov_after = { - name: gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") + name: fc_to_gdf(fc) for name, fc in cached["data"]["after"].items() + if is_fc(fc) } return {"before": prov_before, "after": prov_after} From 7622dbede2500d5681a3cc4554d65a7ded315642 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 30 Sep 2025 12:50:32 +0300 Subject: [PATCH 087/161] feat(get_provisions): 1. New router gor extracting provisions from cache --- app/effects_api/tasks_controller.py | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 374ebc9..1850057 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -206,3 +206,37 @@ async def get_layer(scenario_id: int, method_name: str): data: dict = cached["data"] return JSONResponse(content=data) + +@router.get("/get_provisions/{scenario_id}") +async def get_total_provisions(scenario_id: int): + cached = file_cache.load_latest("territory_transformation", scenario_id) + if not cached: + raise http_exception(404, "no saved result for this scenario", scenario_id) + + data: dict = cached["data"] + + before_dict = data.get("before", {}) or {} + after_dict = data.get("after", {}) or {} + + provision_before = before_dict.get("provision_total_before") + provision_after = after_dict.get("provision_total_after") + + if provision_before and provision_after: + return JSONResponse( + content={ + "provision_total_before": provision_before, + "provision_total_after": provision_after, + } + ) + + if provision_before and not provision_after: + return JSONResponse( + content={"provision_total_before": provision_before} + ) + + if provision_after and not provision_before: + return JSONResponse( + content={"provision_total_after": provision_after} + ) + + raise http_exception(404, f"Result for scenario ID{scenario_id} not found") From c12cae0da4439fe102825d6bd49b296225d84db4 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 1 Oct 2025 16:49:59 +0300 Subject: [PATCH 088/161] feat(values_oriented): 1. social_values for base scenario --- app/effects_api/effects_service.py | 159 ++++++++++++++++++++++-- app/effects_api/modules/task_service.py | 2 +- 2 files changed, 149 insertions(+), 12 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 5786942..ac1a569 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Literal import geopandas as gpd import numpy as np @@ -600,7 +600,7 @@ async def evaluate_master_plan_by_scenario( params = await self.get_optimal_func_zone_data(params, token) context_blocks, _ = await self.aggregate_blocks_layer_context( - sid, params.context_func_zone_source, params.proj_func_source_year, token + sid, params.context_func_zone_source, params.context_func_source_year, token ) scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( @@ -1275,9 +1275,11 @@ def _get_value_level(self, provisions: list[float | None]) -> float: return float(np.mean(vals)) if vals else np.nan async def values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO | DevelopmentDTO, + # context_blocks: gpd.GeoDataFrame, + persist: Literal["full", "table_only"] = "full", ): method_name = "values_oriented_requirements" @@ -1390,18 +1392,153 @@ async def values_oriented_requirements( for sv_id, val in result_df["social_value_level"].to_dict().items() } - self.cache.save( - method_name, - params.scenario_id, - params_for_hash, - { + if persist == "full": + payload = { "provision": { name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) for name, gdf in prov_gdfs.items() }, "result": values_table, - }, + } + else: # "table_only" + payload = { + "result": values_table, + } + + self.cache.save( + method_name, + params.scenario_id, + params_for_hash, + payload, scenario_updated_at=updated_at, ) return result_df + + async def run_values_oriented_requirements( + self, + token: str, + params: TerritoryTransformationDTO, + ): + """ + Находит базовый сценарий, считает текущий (full) и базовый (table_only), + читает обе таблицы из кэша и дописывает их в кэш текущего сценария. + """ + method_name = "values_oriented_requirements" + + # ВАЖНО: тут у вас была опечатка: использовался proj_func_source_year для контекста. + # context_blocks, _ = await self.aggregate_blocks_layer_context( + # params.scenario_id, + # params.context_func_zone_source, + # params.context_func_source_year, # ← исправлено + # token, + # ) + + df_current = await self.values_oriented_requirements(token, params, persist="full") + + params_for_hash_curr = await self.build_hash_params(params, token) + phash_curr = self.cache.params_hash(params_for_hash_curr) + info_curr = await self.urban_api_client.get_scenario_info(params.scenario_id, token) + updated_at_curr = info_curr["updated_at"] + + cached_curr = self.cache.load(method_name, params.scenario_id, phash_curr) + if not (cached_curr and cached_curr["meta"].get("scenario_updated_at") == updated_at_curr): + raise RuntimeError("Не удалось найти кэш текущего сценария после расчёта.") + + def _extract_values_table(payload, soc_values_map): + import pandas as pd + if isinstance(payload, dict) and "data" not in payload: + return payload + df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) + df.index.name = payload.get("index_name", None) + col = df.columns[0] + return { + int(sv_id): {"name": soc_values_map.get(sv_id, str(sv_id)), + "value": round(float(val), 2) if val else 0.0} + for sv_id, val in df[col].to_dict().items() + } + + soc_values_map = await self.urban_api_client.get_social_values_info() + table_current = _extract_values_table(cached_curr["data"]["result"], soc_values_map) + + project_id = (info_curr.get("project") or {}).get("project_id") + regional_id = (info_curr.get("parent_scenario") or {}).get("id") + base_id, base_updated_at = None, None + + if project_id and regional_id: + proj_scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + + def _truthy_is_based(x): + v = x.get("is_based") + return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + + def _parent_id(x): + p = x.get("parent_scenario") + return p.get("id") if isinstance(p, dict) else p + + def _sid(x): + try: + return int(x.get("scenario_id")) + except Exception: + return None + + matches = [ + s for s in proj_scenarios + if _truthy_is_based(s) and _parent_id(s) == regional_id and _sid(s) is not None + ] + if not matches: + only_based = [s for s in proj_scenarios if _truthy_is_based(s) and _sid(s) is not None] + if only_based: + only_based.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + matches = [only_based[0]] + if matches: + matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + base_id = _sid(matches[0]) + base_updated_at = matches[0].get("updated_at") + + table_base = {} + + if base_id: + params_base = params.model_copy(update={ + "scenario_id": base_id, + "proj_func_zone_source": None, + "proj_func_source_year": None, + "context_func_zone_source": None, + "context_func_source_year": None, + }) + params_base = await self.get_optimal_func_zone_data(params_base, token) + + params_for_hash_base = await self.build_hash_params(params_base, token) + phash_base = self.cache.params_hash(params_for_hash_base) + + if not base_updated_at: + info_base = await self.urban_api_client.get_scenario_info(base_id, token) + base_updated_at = info_base.get("updated_at") + + cached_base = self.cache.load(method_name, base_id, phash_base) + + if cached_base and cached_base["meta"].get("scenario_updated_at") == base_updated_at and "result" in \ + cached_base["data"]: + table_base = _extract_values_table(cached_base["data"]["result"], soc_values_map) + else: + _ = await self.values_oriented_requirements( + token, params_base, persist="table_only" + ) + cached_base = self.cache.load(method_name, base_id, phash_base) + if cached_base and cached_base["meta"].get("scenario_updated_at") == base_updated_at and "result" in \ + cached_base["data"]: + table_base = _extract_values_table(cached_base["data"]["result"], soc_values_map) + + self.cache.save( + method_name, + params.scenario_id, + params_for_hash_curr, + { + **cached_curr["data"], + "values_table_current": table_current, + "values_table_base": table_base, + }, + scenario_updated_at=updated_at_curr, + ) + + return df_current, table_current, table_base diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index ba5eec0..b609666 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -15,7 +15,7 @@ TASK_METHODS: dict[str, MethodFunc] = { "territory_transformation": effects_service.territory_transformation, "values_transformation": effects_service.values_transformation, - "values_oriented_requirements": effects_service.values_oriented_requirements, + "values_oriented_requirements": effects_service.run_values_oriented_requirements, } _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() From 586a91b6efce83b1b61ce633792028a1563277d6 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 1 Oct 2025 19:25:12 +0300 Subject: [PATCH 089/161] feat(social_values): 1. F 36 now calculating for only base scenarios --- app/common/utils/ids_convertation.py | 40 +++ app/effects_api/effects_service.py | 311 +++++++++++++----------- app/effects_api/modules/task_service.py | 2 +- app/effects_api/tasks_controller.py | 76 ++++-- 4 files changed, 268 insertions(+), 161 deletions(-) create mode 100644 app/common/utils/ids_convertation.py diff --git a/app/common/utils/ids_convertation.py b/app/common/utils/ids_convertation.py new file mode 100644 index 0000000..f190921 --- /dev/null +++ b/app/common/utils/ids_convertation.py @@ -0,0 +1,40 @@ +from typing import Any, Dict, Optional + + +def _truthy_is_based(v: Any) -> bool: + return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + + +def _parent_id(s: Dict[str, Any]) -> Optional[int]: + p = s.get("parent_scenario") + return p.get("id") if isinstance(p, dict) else p + + +def _sid(s: Dict[str, Any]) -> Optional[int]: + try: + return int(s.get("scenario_id")) + except Exception: + return None + + +async def _resolve_base_id(urban_api_client, token: str, scenario_id: int) -> int: + info = await urban_api_client.get_scenario_info(scenario_id, token) + project_id = (info.get("project") or {}).get("project_id") + regional_id = (info.get("parent_scenario") or {}).get("id") + + if not project_id or not regional_id: + return scenario_id + + scenarios = await urban_api_client.get_project_scenarios(project_id, token) + matches = [ + s for s in scenarios + if _truthy_is_based(s.get("is_based")) and _parent_id(s) == regional_id and _sid(s) is not None + ] + if not matches: + only_based = [s for s in scenarios if _truthy_is_based(s.get("is_based")) and _sid(s) is not None] + if not only_based: + return scenario_id + matches = only_based + + matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + return _sid(matches[0]) or scenario_id diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index ac1a569..eabe154 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -63,10 +63,10 @@ class EffectsService: def __init__( - self, - urban_api_client: UrbanAPIClient, - cache: FileCache, - scenario_service: ScenarioService, + self, + urban_api_client: UrbanAPIClient, + cache: FileCache, + scenario_service: ScenarioService, ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() @@ -75,9 +75,9 @@ def __init__( self.scenario = scenario_service async def build_hash_params( - self, - params: ContextDevelopmentDTO | DevelopmentDTO, - token: str, + self, + params: ContextDevelopmentDTO | DevelopmentDTO, + token: str, ) -> dict: project_id = ( await self.urban_api_client.get_scenario_info(params.scenario_id, token) @@ -94,14 +94,14 @@ async def build_hash_params( } async def get_optimal_func_zone_data( - self, - params: ( - DevelopmentDTO - | ContextDevelopmentDTO - | SocioEconomicByProjectDTO - | TerritoryTransformationDTO - ), - token: str, + self, + params: ( + DevelopmentDTO + | ContextDevelopmentDTO + | SocioEconomicByProjectDTO + | TerritoryTransformationDTO + ), + token: str, ) -> DevelopmentDTO: """ Get optimal functional zone source and year for the project scenario. @@ -124,8 +124,8 @@ async def get_optimal_func_zone_data( ) if isinstance(params, ContextDevelopmentDTO): if ( - not params.context_func_zone_source - or not params.context_func_source_year + not params.context_func_zone_source + or not params.context_func_source_year ): ( params.context_func_zone_source, @@ -141,19 +141,19 @@ async def get_optimal_func_zone_data( return params async def load_blocks_scenario( - self, scenario_id: int, token: str + self, scenario_id: int, token: str ) -> gpd.GeoDataFrame: gdf = await self.scenario.get_scenario_blocks(scenario_id, token) gdf["site_area"] = gdf.area return gdf async def assign_land_use_to_blocks_scenario( - self, - blocks: gpd.GeoDataFrame, - scenario_id: int, - source: str | None, - year: int | None, - token: str, + self, + blocks: gpd.GeoDataFrame, + scenario_id: int, + source: str | None, + year: int | None, + token: str, ) -> gpd.GeoDataFrame: fzones = await self.scenario.get_scenario_functional_zones( scenario_id, token, source, year @@ -163,7 +163,7 @@ async def assign_land_use_to_blocks_scenario( return blocks.join(lu.drop(columns=["geometry"])) async def enrich_with_buildings_scenario( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: buildings = await self.scenario.get_scenario_buildings(scenario_id, token) if buildings is None: @@ -184,7 +184,7 @@ async def enrich_with_buildings_scenario( return blocks, buildings async def enrich_with_services_scenario( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: stypes = await self.urban_api_client.get_service_types() stypes = await adapt_service_types(stypes, self.urban_api_client) @@ -204,11 +204,11 @@ async def enrich_with_services_scenario( return blocks async def aggregate_blocks_layer_scenario( - self, - scenario_id: int, - source: str | None = None, - year: int | None = None, - token: str | None = None, + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: logger.info(f"[Scenario {scenario_id}] load blocks") @@ -233,7 +233,7 @@ async def aggregate_blocks_layer_scenario( return blocks, buildings async def load_context_blocks( - self, scenario_id: int, token: str + self, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, int]: project_id = await self.urban_api_client.get_project_id(scenario_id, token) blocks = await get_context_blocks( @@ -243,12 +243,12 @@ async def load_context_blocks( return blocks, project_id async def assign_land_use_context( - self, - blocks: gpd.GeoDataFrame, - scenario_id: int, - source: str | None, - year: int | None, - token: str, + self, + blocks: gpd.GeoDataFrame, + scenario_id: int, + source: str | None, + year: int | None, + token: str, ) -> gpd.GeoDataFrame: fzones = await get_context_functional_zones( scenario_id, source, year, token, self.urban_api_client @@ -258,7 +258,7 @@ async def assign_land_use_context( return blocks.join(lu.drop(columns=["geometry"])) async def enrich_with_context_buildings( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: buildings = await get_context_buildings( @@ -282,7 +282,7 @@ async def enrich_with_context_buildings( return blocks, buildings async def enrich_with_context_services( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: stypes = await self.urban_api_client.get_service_types() @@ -309,11 +309,11 @@ async def enrich_with_context_services( return blocks async def aggregate_blocks_layer_context( - self, - scenario_id: int, - source: str | None = None, - year: int | None = None, - token: str | None = None, + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: logger.info(f"[Context {scenario_id}] load blocks") @@ -385,7 +385,7 @@ async def get_services_layer(self, scenario_id: int, token: str): @staticmethod async def run_development_parameters( - blocks_gdf: gpd.GeoDataFrame, + blocks_gdf: gpd.GeoDataFrame, ) -> pd.DataFrame: """ Compute core *development* indicators (FSI, GSI, MXI, etc.) for each @@ -432,9 +432,9 @@ async def run_development_parameters( return development_df async def run_social_reg_prediction( - self, - blocks: gpd.GeoDataFrame, - data_input: pd.DataFrame, + self, + blocks: gpd.GeoDataFrame, + data_input: pd.DataFrame, ): """ Function runs social regression from blocksnet @@ -457,13 +457,13 @@ async def run_social_reg_prediction( } result_df = pd.DataFrame.from_dict(result_data) result_df["is_interval"] = (result_df["pred"] <= result_df["upper"]) & ( - result_df["pred"] >= result_df["lower"] + result_df["pred"] >= result_df["lower"] ) res = result_df.to_dict(orient="index") return SocioEconomicSchema(socio_economic_prediction=res) async def evaluate_master_plan_by_project( - self, params: SocioEconomicByProjectDTO, token: str + self, params: SocioEconomicByProjectDTO, token: str ) -> SocioEconomicResponseSchema: logger.info( f"[Effects] project mode: project_id={params.project_id}, regional_scenario_id={params.regional_scenario_id}" @@ -587,7 +587,7 @@ async def evaluate_master_plan_by_project( ) async def evaluate_master_plan_by_scenario( - self, params: SocioEconomicByScenarioDTO, token: str + self, params: SocioEconomicByScenarioDTO, token: str ) -> SocioEconomicResponseSchema: sid = params.scenario_id logger.info(f"[Effects] legacy mode: scenario_id={sid}") @@ -674,7 +674,7 @@ async def evaluate_master_plan_by_scenario( ) async def calc_project_development( - self, token: str, params: DevelopmentDTO + self, token: str, params: DevelopmentDTO ) -> DevelopmentResponseSchema: """ Function calculates development only for project with blocksnet @@ -698,7 +698,7 @@ async def calc_project_development( return DevelopmentResponseSchema(**res) async def calc_context_development( - self, token: str, params: ContextDevelopmentDTO + self, token: str, params: ContextDevelopmentDTO ) -> DevelopmentResponseSchema: """ Function calculates development for context with project with blocksnet @@ -731,7 +731,7 @@ async def calc_context_development( return DevelopmentResponseSchema(**res) async def _get_accessibility_context( - self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float ) -> list[int]: blocks["population"] = blocks["population"].fillna(0) project_blocks = blocks.copy() @@ -741,7 +741,7 @@ async def _get_accessibility_context( return list(context_blocks.index) async def _assess_provision( - self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str ) -> gpd.GeoDataFrame: _, demand, accessibility = service_types_config[service_type].values() blocks["is_project"] = blocks["is_project"].fillna(False).astype(bool) @@ -763,9 +763,9 @@ async def _assess_provision( return blocks[["geometry"]].join(prov_df, how="right") async def calculate_provision_totals( - self, - provision_gdfs_dict: dict[str, gpd.GeoDataFrame], - ndigits: int = 2, + self, + provision_gdfs_dict: dict[str, gpd.GeoDataFrame], + ndigits: int = 2, ) -> dict[str, float | None]: prov_totals: dict[str, float | None] = {} for st_name, prov_gdf in provision_gdfs_dict.items(): @@ -777,10 +777,10 @@ async def calculate_provision_totals( return prov_totals async def territory_transformation_scenario_before( - self, - token: str, - params: ContextDevelopmentDTO, - context_blocks: gpd.GeoDataFrame = None, + self, + token: str, + params: ContextDevelopmentDTO, + context_blocks: gpd.GeoDataFrame = None, ): method_name = "territory_transformation" @@ -802,9 +802,9 @@ async def territory_transformation_scenario_before( cached = self.cache.load(method_name, params.scenario_id, phash) if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "before" in cached["data"] + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "before" in cached["data"] ): return {n: fc_to_gdf(fc) for n, fc in cached["data"]["before"].items() if is_fc(fc)} @@ -876,10 +876,10 @@ async def territory_transformation_scenario_before( return prov_gdfs_before def _build_facade( - self, - after_blocks: gpd.GeoDataFrame, - acc_mx: pd.DataFrame, - service_types: pd.DataFrame, + self, + after_blocks: gpd.GeoDataFrame, + acc_mx: pd.DataFrame, + service_types: pd.DataFrame, ) -> Facade: blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() @@ -910,11 +910,11 @@ def _build_facade( return facade async def territory_transformation_scenario_after( - self, - token, - params: ContextDevelopmentDTO | DevelopmentDTO, - context_blocks: gpd.GeoDataFrame, - save_cache: bool = True, + self, + token, + params: ContextDevelopmentDTO | DevelopmentDTO, + context_blocks: gpd.GeoDataFrame, + save_cache: bool = True, ): # provision after method_name = "territory_transformation" @@ -974,13 +974,13 @@ async def territory_transformation_scenario_after( acc_mx = calculate_accessibility_matrix(after_blocks, graph) service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) if ( - "population" not in after_blocks.columns - or after_blocks["population"].isna().any() + "population" not in after_blocks.columns + or after_blocks["population"].isna().any() ): dev_df = await self.run_development_parameters(after_blocks) after_blocks["population"] = pd.to_numeric( @@ -1062,7 +1062,7 @@ async def territory_transformation_scenario_after( "territory_transformation", params.scenario_id, params_for_hash, - from_cache, # <-- сохраняем объединённые данные + from_cache, scenario_updated_at=updated_at, ) @@ -1073,9 +1073,9 @@ async def territory_transformation_scenario_after( } async def territory_transformation( - self, - token: str, - params: ContextDevelopmentDTO, + self, + token: str, + params: ContextDevelopmentDTO, ) -> dict[str, Any] | dict[str, dict[str, Any]]: info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) @@ -1099,9 +1099,9 @@ async def territory_transformation( cached = self.cache.load("territory_transformation", params.scenario_id, phash) if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "after" in cached["data"] + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "after" in cached["data"] ): prov_after = { name: fc_to_gdf(fc) @@ -1278,29 +1278,77 @@ async def values_oriented_requirements( self, token: str, params: TerritoryTransformationDTO | DevelopmentDTO, - # context_blocks: gpd.GeoDataFrame, persist: Literal["full", "table_only"] = "full", ): method_name = "values_oriented_requirements" - params = await self.get_optimal_func_zone_data(params, token) - params_for_hash = await self.build_hash_params(params, token) - phash = self.cache.params_hash(params_for_hash) + info_curr = await self.urban_api_client.get_scenario_info(params.scenario_id, token) + updated_at_curr = info_curr["updated_at"] + project_id = (info_curr.get("project") or {}).get("project_id") + regional_id = (info_curr.get("parent_scenario") or {}).get("id") - info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - updated_at = info["updated_at"] + base_id: Optional[int] = None + if project_id and regional_id: + proj_scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) - cached = self.cache.load(method_name, params.scenario_id, phash) - if cached and cached["meta"].get("scenario_updated_at") == updated_at: - if "result" in cached["data"]: - payload = cached["data"]["result"] - result_df = pd.DataFrame( - data=payload["data"], - index=payload["index"], - columns=payload["columns"], - ) - result_df.index.name = payload.get("index_name", None) - return result_df + def _truthy_is_based(x): + v = x.get("is_based") + return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + + def _parent_id(x): + p = x.get("parent_scenario") + return p.get("id") if isinstance(p, dict) else p + + def _sid(x): + try: + return int(x.get("scenario_id")) + except Exception: + return None + + matches = [ + s for s in proj_scenarios + if _truthy_is_based(s) and _parent_id(s) == regional_id and _sid(s) is not None + ] + if not matches: + only_based = [s for s in proj_scenarios if _truthy_is_based(s) and _sid(s) is not None] + if only_based: + only_based.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + matches = [only_based[0]] + if matches: + matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + base_id = _sid(matches[0]) + + if base_id is None: + base_id = params.scenario_id + + params_base = params.model_copy(update={ + "scenario_id": base_id, + "proj_func_zone_source": None, + "proj_func_source_year": None, + "context_func_zone_source": None, + "context_func_source_year": None, + }) + params_base = await self.get_optimal_func_zone_data(params_base, token) + + params_for_hash_base = await self.build_hash_params(params_base, token) + phash_base = self.cache.params_hash(params_for_hash_base) + info_base = await self.urban_api_client.get_scenario_info(base_id, token) + updated_at_base = info_base["updated_at"] + + def _result_to_df(payload: Any) -> pd.DataFrame: + if isinstance(payload, dict) and "data" not in payload: + items = sorted(((int(k), v.get("value", 0.0)) for k, v in payload.items()), key=lambda t: t[0]) + idx = [k for k, _ in items] + vals = [float(v) if v is not None else 0.0 for _, v in items] + return pd.DataFrame({"social_value_level": vals}, index=idx) + df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) + df.index.name = payload.get("index_name", None) + return df + + cached_base = self.cache.load(method_name, base_id, phash_base) + if cached_base and cached_base["meta"].get("scenario_updated_at") == updated_at_base and "result" in \ + cached_base["data"]: + return _result_to_df(cached_base["data"]["result"]) context_blocks, _ = await self.aggregate_blocks_layer_context( params.scenario_id, @@ -1310,18 +1358,15 @@ async def values_oriented_requirements( ) scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, + params_base.scenario_id, + params_base.proj_func_zone_source, + params_base.proj_func_source_year, token, ) - scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] - scenario_blocks.loc[ - scenario_blocks["is_project"], ["population"] + cap_cols - ] = 0 + scenario_blocks.loc[scenario_blocks["is_project"], ["population"] + cap_cols] = 0 if "capacity" in scenario_blocks.columns: scenario_blocks = scenario_blocks.drop(columns="capacity") @@ -1338,25 +1383,13 @@ async def values_oriented_requirements( acc_mx = calculate_accessibility_matrix(blocks, graph) prov_gdfs: dict[str, gpd.GeoDataFrame] = {} - if ( - cached - and cached["meta"].get("scenario_updated_at") == updated_at - and "provision" in cached["data"] - ): - for st_name, fc in cached["data"]["provision"].items(): - prov_gdfs[st_name] = gpd.GeoDataFrame.from_features( - fc["features"], crs="EPSG:4326" - ) - else: - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - prov_gdf = prov_gdf.to_crs(4326).drop( - columns="provision_weak", errors="ignore" - ) - num_cols = prov_gdf.select_dtypes(include="number").columns - prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) - prov_gdfs[st_name] = prov_gdf + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdf.to_crs(4326).drop(columns="provision_weak", errors="ignore") + num_cols = prov_gdf.select_dtypes(include="number").columns + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + prov_gdfs[st_name] = prov_gdf social_values_provisions: dict[str, list[float | None]] = {} for st_id in service_types.index: @@ -1400,17 +1433,15 @@ async def values_oriented_requirements( }, "result": values_table, } - else: # "table_only" - payload = { - "result": values_table, - } + else: + payload = {"result": values_table} self.cache.save( method_name, - params.scenario_id, - params_for_hash, + base_id, # ← ключ кэша по базовому сценарию + params_for_hash_base, # ← и хэш тоже базовый payload, - scenario_updated_at=updated_at, + scenario_updated_at=updated_at_base, ) return result_df @@ -1426,14 +1457,6 @@ async def run_values_oriented_requirements( """ method_name = "values_oriented_requirements" - # ВАЖНО: тут у вас была опечатка: использовался proj_func_source_year для контекста. - # context_blocks, _ = await self.aggregate_blocks_layer_context( - # params.scenario_id, - # params.context_func_zone_source, - # params.context_func_source_year, # ← исправлено - # token, - # ) - df_current = await self.values_oriented_requirements(token, params, persist="full") params_for_hash_curr = await self.build_hash_params(params, token) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index b609666..ba5eec0 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -15,7 +15,7 @@ TASK_METHODS: dict[str, MethodFunc] = { "territory_transformation": effects_service.territory_transformation, "values_transformation": effects_service.values_transformation, - "values_oriented_requirements": effects_service.run_values_oriented_requirements, + "values_oriented_requirements": effects_service.values_oriented_requirements, } _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 1850057..9797a50 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -14,7 +14,8 @@ ) from ..common.exceptions.http_exception_wrapper import http_exception -from ..dependencies import effects_service, file_cache +from ..common.utils.ids_convertation import _resolve_base_id +from ..dependencies import effects_service, file_cache, urban_api_client from .dto.development_dto import ContextDevelopmentDTO from .modules.service_type_service import get_services_with_ids_from_layer @@ -32,7 +33,7 @@ def _get_lock(key: str) -> asyncio.Lock: async def _with_defaults( - dto: ContextDevelopmentDTO, token: str + dto: ContextDevelopmentDTO, token: str ) -> ContextDevelopmentDTO: return await effects_service.get_optimal_func_zone_data(dto, token) @@ -62,9 +63,9 @@ async def get_methods(): @router.post("/{method}", status_code=202) async def create_task( - method: str, - params: Annotated[ContextDevelopmentDTO, Depends()], - token: str = Depends(verify_token), + method: str, + params: Annotated[ContextDevelopmentDTO, Depends()], + token: str = Depends(verify_token), ): if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) @@ -130,8 +131,8 @@ async def task_status(task_id: str): @router.get("/get_service_types") async def get_service_types( - scenario_id: int, - method: str = "territory_transformation", + scenario_id: int, + method: str = "territory_transformation", ): return await get_services_with_ids_from_layer(scenario_id, method, file_cache) @@ -183,19 +184,61 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str @router.get("/values_oriented_requirements/{scenario_id}/{service_name}") -async def get_values_oriented_requirements_layer(scenario_id: int, service_name: str): - cached = file_cache.load_latest("values_oriented_requirements", scenario_id) +async def get_values_oriented_requirements_layer( + scenario_id: int, + service_name: str, + token: str = Depends(verify_token), +): + base_id = await _resolve_base_id(urban_api_client, token, scenario_id) + + cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: - raise http_exception(404, "no saved result for this scenario", scenario_id) + raise http_exception(404, f"no saved result for base scenario {base_id}", base_id) - data: dict = cached["data"] + info_base = await urban_api_client.get_scenario_info(base_id, token) + if cached.get("meta", {}).get("scenario_updated_at") != info_base.get("updated_at"): + raise http_exception(404, f"stale cache for base scenario {base_id}, recompute required", base_id) + + data: dict = cached.get("data", {}) + prov = (data.get("provision") or {}).get(service_name) + values_dict = data.get("result") + + if not prov: + raise http_exception(404, f"service '{service_name}' not found in base scenario {base_id}") + + return JSONResponse( + content={ + "base_scenario_id": base_id, + "geojson": prov, + "values_table": values_dict, + } + ) - fc_provision = data["provision"].get(service_name) - values_dict = data["result"] - if not (fc_provision and values_dict): - raise http_exception(404, f"service '{service_name}' not found") - return JSONResponse(content={"geojson": fc_provision, "values_table": values_dict}) +@router.get("/values_oriented_requirements_table/{scenario_id}/{service_name}") +async def get_values_oriented_requirements_table( + scenario_id: int, + token: str = Depends(verify_token), +): + base_id = await _resolve_base_id(urban_api_client, token, scenario_id) + + cached = file_cache.load_latest("values_oriented_requirements", base_id) + if not cached: + raise http_exception(404, f"no saved result for base scenario {base_id}", base_id) + + info_base = await urban_api_client.get_scenario_info(base_id, token) + if cached.get("meta", {}).get("scenario_updated_at") != info_base.get("updated_at"): + raise http_exception(404, f"stale cache for base scenario {base_id}, recompute required", base_id) + + data: dict = cached.get("data", {}) + values_dict = data.get("result") + + return JSONResponse( + content={ + "base_scenario_id": base_id, + "values_table": values_dict, + } + ) @router.get("/get_from_cache/{method_name}/{scenario_id}") @@ -207,6 +250,7 @@ async def get_layer(scenario_id: int, method_name: str): data: dict = cached["data"] return JSONResponse(content=data) + @router.get("/get_provisions/{scenario_id}") async def get_total_provisions(scenario_id: int): cached = file_cache.load_latest("territory_transformation", scenario_id) From 2b0a78b2874d9a69594198a2bf912a07570be2a7 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 2 Oct 2025 20:49:52 +0300 Subject: [PATCH 090/161] fix(base_roads): 1. exclusion of the base roads from all methods 2. logic for cache saving mid container updates 3. logic for forcefull recalculation of the cached methods --- app/effects_api/dto/development_dto.py | 4 + app/effects_api/effects_controller.py | 52 ++++----- app/effects_api/effects_service.py | 113 ++++++++++++++------ app/effects_api/modules/scenario_service.py | 26 ++--- docker-compose.yml | 4 +- 5 files changed, 120 insertions(+), 79 deletions(-) diff --git a/app/effects_api/dto/development_dto.py b/app/effects_api/dto/development_dto.py index 447adb3..a628f4e 100644 --- a/app/effects_api/dto/development_dto.py +++ b/app/effects_api/dto/development_dto.py @@ -4,6 +4,10 @@ class DevelopmentDTO(BaseModel): + force: bool = Field( + default=False, description="flag for recalculating the scenario" + ) + scenario_id: int = Field( ..., examples=[822], diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index fa41c35..52c176e 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -65,29 +65,29 @@ async def get_socio_economic_prediction( return await effects_service.evaluate_master_plan_by_scenario(params, token) -@f_35_router.get("/territory_transformation") -async def territory_transformation( - params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], - token: str = Depends(verify_token), -): - gdf = await effects_service.territory_transformation_scenario_before(token, params) - gdf = gdf.to_crs(4326) - - geojson_dict = json.loads(gdf.to_json(drop_id=True)) - return JSONResponse(content=geojson_dict) - - -@f_26_router.get("/values_development") -async def values_development( - params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], - token: str = Depends(verify_token), -): - return await effects_service.values_transformation(token, params) - - -@f_36_router.get("/values_oriented_requirements") -async def values_requirements( - params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], - token: str = Depends(verify_token), -): - return await effects_service.values_oriented_requirements(token, params) +# @f_35_router.get("/territory_transformation") +# async def territory_transformation( +# params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], +# token: str = Depends(verify_token), +# ): +# gdf = await effects_service.territory_transformation_scenario_before(token, params) +# gdf = gdf.to_crs(4326) +# +# geojson_dict = json.loads(gdf.to_json(drop_id=True)) +# return JSONResponse(content=geojson_dict) +# +# +# @f_26_router.get("/values_development") +# async def values_development( +# params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], +# token: str = Depends(verify_token), +# ): +# return await effects_service.values_transformation(token, params) +# +# +# @f_36_router.get("/values_oriented_requirements") +# async def values_requirements( +# params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], +# token: str = Depends(verify_token), +# ): +# return await effects_service.values_oriented_requirements(token, params) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 451dc2e..8406139 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -25,19 +25,26 @@ get_accessibility_context, get_accessibility_graph, ) -from iduedu import config from loguru import logger from app.effects_api.modules.scenario_service import ScenarioService -from app.effects_api.modules.service_type_service import adapt_service_types, build_en_to_ru_map, \ - remap_properties_keys_in_geojson +from app.effects_api.modules.service_type_service import ( + adapt_service_types, + build_en_to_ru_map, + remap_properties_keys_in_geojson, +) from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, round_coords, is_fc -from .constants.const import INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES, MAX_EVALS, MAX_RUNS +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords +from .constants.const import ( + INFRASTRUCTURES_WEIGHTS, + LAND_USE_RULES, + MAX_EVALS, + MAX_RUNS, +) from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, @@ -88,7 +95,9 @@ async def build_hash_params( token, base_scenario_id, None, None ) ) - return params.model_dump() | { + p = params.model_dump() + p.pop("force", None) + return p | { "base_func_zone_source": base_src, "base_func_zone_year": base_year, } @@ -791,22 +800,23 @@ async def territory_transformation_scenario_before( params = await self.get_optimal_func_zone_data(params, token) - base_src, base_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token, base_scenario_id, None, None - ) - ) - params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) - cached = self.cache.load(method_name, params.scenario_id, phash) + force = getattr(params, "force", False) + cached = ( + None if force else self.cache.load(method_name, params.scenario_id, phash) + ) if ( cached and cached["meta"]["scenario_updated_at"] == updated_at and "before" in cached["data"] ): - return {n: fc_to_gdf(fc) for n, fc in cached["data"]["before"].items() if is_fc(fc)} + return { + n: fc_to_gdf(fc) + for n, fc in cached["data"]["before"].items() + if is_fc(fc) + } logger.info("Cache stale or missing: recalculating BEFORE") @@ -969,7 +979,9 @@ async def territory_transformation_scenario_after( try: graph = get_accessibility_graph(after_blocks, "intermodal") except Exception as e: - raise http_exception(500, "Error generating territory graph", _detail=str(e)) + raise http_exception( + 500, "Error generating territory graph", _detail=str(e) + ) acc_mx = calculate_accessibility_matrix(after_blocks, graph) @@ -1053,7 +1065,7 @@ async def territory_transformation_scenario_after( } after_fc["provision_total_after"] = prov_totals - from_cache = (cached.get("data", {}).copy() if cached else {}) + from_cache = cached.get("data", {}).copy() if cached else {} from_cache["after"] = after_fc from_cache["opt_context"] = {"best_x": best_x} @@ -1115,9 +1127,9 @@ async def territory_transformation( return {"before": prov_before, "after": prov_after} async def values_transformation( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ) -> dict: opt_method = "territory_transformation_opt" @@ -1125,6 +1137,7 @@ async def values_transformation( params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) + force = getattr(params, "force", False) info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] @@ -1136,11 +1149,14 @@ async def values_transformation( token, ) - opt_cached = self.cache.load(opt_method, params.scenario_id, phash) + opt_cached = ( + None if force else self.cache.load(opt_method, params.scenario_id, phash) + ) need_refresh = ( - not opt_cached - or opt_cached["meta"]["scenario_updated_at"] != updated_at - or "best_x" not in opt_cached["data"] + force + or not opt_cached + or opt_cached["meta"]["scenario_updated_at"] != updated_at + or "best_x" not in opt_cached["data"] ) if need_refresh: res = await self.territory_transformation_scenario_after( @@ -1178,40 +1194,58 @@ async def values_transformation( ) else: after_blocks.index = after_blocks.index.astype(int) - after_blocks = after_blocks[~after_blocks.index.duplicated(keep="last")].sort_index() + after_blocks = after_blocks[ + ~after_blocks.index.duplicated(keep="last") + ].sort_index() after_blocks.index.name = "block_id" if "is_project" in after_blocks.columns: - after_blocks["is_project"] = after_blocks["is_project"].fillna(False).astype(bool) + after_blocks["is_project"] = ( + after_blocks["is_project"].fillna(False).astype(bool) + ) else: after_blocks["is_project"] = False try: graph = get_accessibility_graph(after_blocks, "intermodal") except Exception as e: - raise http_exception(500, "Error generating territory graph", _detail=str(e)) + raise http_exception( + 500, "Error generating territory graph", _detail=str(e) + ) acc_mx = calculate_accessibility_matrix(after_blocks, graph) service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[~service_types["infrastructure_type"].isna()].copy() + service_types = service_types[ + ~service_types["infrastructure_type"].isna() + ].copy() service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) facade = self._build_facade(after_blocks, acc_mx, service_types) - test_blocks: gpd.GeoDataFrame = after_blocks.loc[list(facade._blocks_lu.keys())].copy() + test_blocks: gpd.GeoDataFrame = after_blocks.loc[ + list(facade._blocks_lu.keys()) + ].copy() test_blocks.index = test_blocks.index.astype(int) solution_df = facade.solution_to_services_df(best_x).copy() solution_df["block_id"] = solution_df["block_id"].astype(int) - metrics = [c for c in ["site_area", "build_floor_area", "capacity", "count"] if c in solution_df.columns] + metrics = [ + c + for c in ["site_area", "build_floor_area", "capacity", "count"] + if c in solution_df.columns + ] zero_dict = {m: 0 for m in metrics} if len(metrics): - agg = solution_df.groupby(["block_id", "service_type"])[metrics].sum().sort_index() + agg = ( + solution_df.groupby(["block_id", "service_type"])[metrics] + .sum() + .sort_index() + ) else: agg = ( solution_df.groupby(["block_id", "service_type"]) @@ -1230,7 +1264,11 @@ def _row_to_dict(s: pd.Series) -> dict: pass return d - cells = agg.apply(_row_to_dict, axis=1) if len(metrics) else agg.apply(lambda _: {}, axis=1) + cells = ( + agg.apply(_row_to_dict, axis=1) + if len(metrics) + else agg.apply(lambda _: {}, axis=1) + ) wide = cells.unstack("service_type").reindex(index=test_blocks.index) all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) @@ -1249,7 +1287,9 @@ def _fill_cell(x): geom_col = test_blocks_with_services.geometry.name service_cols = all_services - base_cols = [c for c in ["is_project"] if c in test_blocks_with_services.columns] + base_cols = [ + c for c in ["is_project"] if c in test_blocks_with_services.columns + ] gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] gdf_out = gdf_out.to_crs(crs="EPSG:4326") @@ -1283,11 +1323,14 @@ async def values_oriented_requirements( params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) + force = getattr(params, "force", False) info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] - cached = self.cache.load(method_name, params.scenario_id, phash) + cached = ( + None if force else self.cache.load(method_name, params.scenario_id, phash) + ) if cached and cached["meta"].get("scenario_updated_at") == updated_at: if "result" in cached["data"]: payload = cached["data"]["result"] diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 684fbe5..60f3b17 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -74,37 +74,29 @@ async def _get_scenario_water(self, scenario_id: int, token: str): async def _get_scenario_blocks( self, user_scenario_id: int, - base_scenario_id: int, boundaries: gpd.GeoDataFrame, token: str, ) -> gpd.GeoDataFrame: crs = boundaries.crs boundaries.geometry = boundaries.buffer(-1) - water, user_roads, base_roads = await asyncio.gather( + ( + water, + user_roads, + ) = await asyncio.gather( self._get_scenario_water(user_scenario_id, token), self._get_scenario_roads(user_scenario_id, token), - self._get_scenario_roads(base_scenario_id, token), ) if water is not None and not water.empty: water = water.to_crs(crs).explode() if user_roads is not None and not user_roads.empty: - user_roads = user_roads.to_crs(crs).explode() - - if base_roads is not None and not base_roads.empty: - base_roads = base_roads.to_crs(crs).explode() - - if ( - base_roads is not None - and not base_roads.empty - and user_roads is not None - and not user_roads.empty - ): - roads = pd.concat([user_roads, base_roads]).reset_index(drop=True) - roads.geometry = close_gaps(roads, 1) - roads = roads.explode(column="geometry") + user_roads = user_roads.to_crs(crs).explode().reset_index(drop=True) + + if user_roads is not None and not user_roads.empty: + user_roads.geometry = close_gaps(user_roads, 1) + roads = user_roads.explode(column="geometry") else: raise http_exception(404, "No objects found for polygons cutting") diff --git a/docker-compose.yml b/docker-compose.yml index ca5f3af..6db91d2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,6 @@ services: context: . dockerfile: ./Dockerfile ports: - - 80:80 \ No newline at end of file + - "80:80" + volumes: + - ./__effects_cache__:/app/__effects_cache__ \ No newline at end of file From a64f3f9c1f17439c29d9b47620fcf3a89b366301 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 2 Oct 2025 20:58:36 +0300 Subject: [PATCH 091/161] fix(forced): 1. continuation of forced logic implementation --- app/effects_api/effects_service.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 8406139..1b28d5b 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -818,7 +818,7 @@ async def territory_transformation_scenario_before( if is_fc(fc) } - logger.info("Cache stale or missing: recalculating BEFORE") + logger.info("Cache stale, missing or forced: calculating BEFORE") service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) @@ -943,7 +943,10 @@ async def territory_transformation_scenario_after( params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) - cached = self.cache.load(method_name, params.scenario_id, phash) + force = getattr(params, "force", False) + cached = ( + None if force else self.cache.load(method_name, params.scenario_id, phash) + ) if ( cached and cached["meta"]["scenario_updated_at"] == updated_at @@ -954,7 +957,7 @@ async def territory_transformation_scenario_after( for n, fc in cached["data"]["after"].items() } - logger.info("AFTER: cache stale or missing; recalculating") + logger.info("Cache stale, missing or forced: calculating AFTER") service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) From a2c0a70939827b63339d1ad8c9be55845f1624ed Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 2 Oct 2025 21:42:48 +0300 Subject: [PATCH 092/161] fix(create_task): 1. continuation of forced logic implementation in tasks --- app/effects_api/effects_service.py | 2 +- app/effects_api/modules/scenario_service.py | 2 +- app/effects_api/modules/task_service.py | 6 ++++-- app/effects_api/tasks_controller.py | 10 ++++++---- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 1b28d5b..81693e9 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1077,7 +1077,7 @@ async def territory_transformation_scenario_after( "territory_transformation", params.scenario_id, params_for_hash, - from_cache, # <-- сохраняем объединённые данные + from_cache, scenario_updated_at=updated_at, ) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 60f3b17..c33726e 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -121,7 +121,7 @@ async def get_scenario_blocks( crs = project_boundaries.estimate_utm_crs() project_boundaries = project_boundaries.to_crs(crs) return await self._get_scenario_blocks( - user_scenario_id, base_scenario_id, project_boundaries, token + user_scenario_id, project_boundaries, token ) async def get_scenario_functional_zones( diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index ba5eec0..5a51eb7 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -57,7 +57,9 @@ def run_sync(self) -> None: logger.info(f"[{self.task_id}] started") self.status = "running" - cached = self.cache.load(self.method, self.scenario_id, self.param_hash) + force = getattr(self.params, "force", False) + + cached = None if force else self.cache.load(self.method, self.scenario_id, self.param_hash) def cache_complete(method: str, cached_obj: dict | None) -> bool: if not cached_obj: @@ -67,7 +69,7 @@ def cache_complete(method: str, cached_obj: dict | None) -> bool: return bool(data.get("after")) return True - if cache_complete(self.method, cached): + if not force and cache_complete(self.method, cached): logger.info(f"[{self.task_id}] loaded from cache") self.result = cached["data"] self.status = "done" diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 374ebc9..eb9ffe1 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -79,12 +79,14 @@ async def create_task( task_id = f"{method}_{params_filled.scenario_id}_{phash}" - cached = file_cache.load(method, params_filled.scenario_id, phash) - if _cache_complete(method, cached): + force = getattr(params, "force", False) + + cached = None if force else file_cache.load(method, params_filled.scenario_id, phash) + if not force and _cache_complete(method, cached): return {"task_id": task_id, "status": "done"} - existing = _task_map.get(task_id) - if existing and existing.status in {"queued", "running"}: + existing = None if force else _task_map.get(task_id) + if not force and existing and existing.status in {"queued", "running"}: return {"task_id": task_id, "status": existing.status} task = AnyTask( From 0ee04b7e85520f075cdbc80777f9605d2545d963 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 3 Oct 2025 15:53:45 +0300 Subject: [PATCH 093/161] fix(run_values_oriented_requirements): 1. Indentation fix 2. Renamed router --- app/effects_api/effects_service.py | 770 ++++++++++++++-------------- app/effects_api/tasks_controller.py | 62 ++- 2 files changed, 419 insertions(+), 413 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 470aa11..8a8aca7 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Optional, Literal +from typing import Any, Dict, Literal, Optional import geopandas as gpd import numpy as np @@ -70,10 +70,10 @@ class EffectsService: def __init__( - self, - urban_api_client: UrbanAPIClient, - cache: FileCache, - scenario_service: ScenarioService, + self, + urban_api_client: UrbanAPIClient, + cache: FileCache, + scenario_service: ScenarioService, ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() @@ -82,9 +82,9 @@ def __init__( self.scenario = scenario_service async def build_hash_params( - self, - params: ContextDevelopmentDTO | DevelopmentDTO, - token: str, + self, + params: ContextDevelopmentDTO | DevelopmentDTO, + token: str, ) -> dict: project_id = ( await self.urban_api_client.get_scenario_info(params.scenario_id, token) @@ -103,14 +103,14 @@ async def build_hash_params( } async def get_optimal_func_zone_data( - self, - params: ( - DevelopmentDTO - | ContextDevelopmentDTO - | SocioEconomicByProjectDTO - | TerritoryTransformationDTO - ), - token: str, + self, + params: ( + DevelopmentDTO + | ContextDevelopmentDTO + | SocioEconomicByProjectDTO + | TerritoryTransformationDTO + ), + token: str, ) -> DevelopmentDTO: """ Get optimal functional zone source and year for the project scenario. @@ -133,8 +133,8 @@ async def get_optimal_func_zone_data( ) if isinstance(params, ContextDevelopmentDTO): if ( - not params.context_func_zone_source - or not params.context_func_source_year + not params.context_func_zone_source + or not params.context_func_source_year ): ( params.context_func_zone_source, @@ -150,19 +150,19 @@ async def get_optimal_func_zone_data( return params async def load_blocks_scenario( - self, scenario_id: int, token: str + self, scenario_id: int, token: str ) -> gpd.GeoDataFrame: gdf = await self.scenario.get_scenario_blocks(scenario_id, token) gdf["site_area"] = gdf.area return gdf async def assign_land_use_to_blocks_scenario( - self, - blocks: gpd.GeoDataFrame, - scenario_id: int, - source: str | None, - year: int | None, - token: str, + self, + blocks: gpd.GeoDataFrame, + scenario_id: int, + source: str | None, + year: int | None, + token: str, ) -> gpd.GeoDataFrame: fzones = await self.scenario.get_scenario_functional_zones( scenario_id, token, source, year @@ -172,7 +172,7 @@ async def assign_land_use_to_blocks_scenario( return blocks.join(lu.drop(columns=["geometry"])) async def enrich_with_buildings_scenario( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: buildings = await self.scenario.get_scenario_buildings(scenario_id, token) if buildings is None: @@ -193,7 +193,7 @@ async def enrich_with_buildings_scenario( return blocks, buildings async def enrich_with_services_scenario( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: stypes = await self.urban_api_client.get_service_types() stypes = await adapt_service_types(stypes, self.urban_api_client) @@ -213,11 +213,11 @@ async def enrich_with_services_scenario( return blocks async def aggregate_blocks_layer_scenario( - self, - scenario_id: int, - source: str | None = None, - year: int | None = None, - token: str | None = None, + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: logger.info(f"[Scenario {scenario_id}] load blocks") @@ -242,7 +242,7 @@ async def aggregate_blocks_layer_scenario( return blocks, buildings async def load_context_blocks( - self, scenario_id: int, token: str + self, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, int]: project_id = await self.urban_api_client.get_project_id(scenario_id, token) blocks = await get_context_blocks( @@ -252,12 +252,12 @@ async def load_context_blocks( return blocks, project_id async def assign_land_use_context( - self, - blocks: gpd.GeoDataFrame, - scenario_id: int, - source: str | None, - year: int | None, - token: str, + self, + blocks: gpd.GeoDataFrame, + scenario_id: int, + source: str | None, + year: int | None, + token: str, ) -> gpd.GeoDataFrame: fzones = await get_context_functional_zones( scenario_id, source, year, token, self.urban_api_client @@ -267,7 +267,7 @@ async def assign_land_use_context( return blocks.join(lu.drop(columns=["geometry"])) async def enrich_with_context_buildings( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: buildings = await get_context_buildings( @@ -291,7 +291,7 @@ async def enrich_with_context_buildings( return blocks, buildings async def enrich_with_context_services( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str ) -> gpd.GeoDataFrame: stypes = await self.urban_api_client.get_service_types() @@ -318,11 +318,11 @@ async def enrich_with_context_services( return blocks async def aggregate_blocks_layer_context( - self, - scenario_id: int, - source: str | None = None, - year: int | None = None, - token: str | None = None, + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: logger.info(f"[Context {scenario_id}] load blocks") @@ -394,7 +394,7 @@ async def get_services_layer(self, scenario_id: int, token: str): @staticmethod async def run_development_parameters( - blocks_gdf: gpd.GeoDataFrame, + blocks_gdf: gpd.GeoDataFrame, ) -> pd.DataFrame: """ Compute core *development* indicators (FSI, GSI, MXI, etc.) for each @@ -441,9 +441,9 @@ async def run_development_parameters( return development_df async def run_social_reg_prediction( - self, - blocks: gpd.GeoDataFrame, - data_input: pd.DataFrame, + self, + blocks: gpd.GeoDataFrame, + data_input: pd.DataFrame, ): """ Function runs social regression from blocksnet @@ -466,13 +466,13 @@ async def run_social_reg_prediction( } result_df = pd.DataFrame.from_dict(result_data) result_df["is_interval"] = (result_df["pred"] <= result_df["upper"]) & ( - result_df["pred"] >= result_df["lower"] + result_df["pred"] >= result_df["lower"] ) res = result_df.to_dict(orient="index") return SocioEconomicSchema(socio_economic_prediction=res) async def evaluate_master_plan_by_project( - self, params: SocioEconomicByProjectDTO, token: str + self, params: SocioEconomicByProjectDTO, token: str ) -> SocioEconomicResponseSchema: logger.info( f"[Effects] project mode: project_id={params.project_id}, regional_scenario_id={params.regional_scenario_id}" @@ -596,7 +596,7 @@ async def evaluate_master_plan_by_project( ) async def evaluate_master_plan_by_scenario( - self, params: SocioEconomicByScenarioDTO, token: str + self, params: SocioEconomicByScenarioDTO, token: str ) -> SocioEconomicResponseSchema: sid = params.scenario_id logger.info(f"[Effects] legacy mode: scenario_id={sid}") @@ -683,7 +683,7 @@ async def evaluate_master_plan_by_scenario( ) async def calc_project_development( - self, token: str, params: DevelopmentDTO + self, token: str, params: DevelopmentDTO ) -> DevelopmentResponseSchema: """ Function calculates development only for project with blocksnet @@ -707,7 +707,7 @@ async def calc_project_development( return DevelopmentResponseSchema(**res) async def calc_context_development( - self, token: str, params: ContextDevelopmentDTO + self, token: str, params: ContextDevelopmentDTO ) -> DevelopmentResponseSchema: """ Function calculates development for context with project with blocksnet @@ -740,7 +740,7 @@ async def calc_context_development( return DevelopmentResponseSchema(**res) async def _get_accessibility_context( - self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float ) -> list[int]: blocks["population"] = blocks["population"].fillna(0) project_blocks = blocks.copy() @@ -750,7 +750,7 @@ async def _get_accessibility_context( return list(context_blocks.index) async def _assess_provision( - self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str ) -> gpd.GeoDataFrame: _, demand, accessibility = service_types_config[service_type].values() blocks["is_project"] = blocks["is_project"].fillna(False).astype(bool) @@ -772,9 +772,9 @@ async def _assess_provision( return blocks[["geometry"]].join(prov_df, how="right") async def calculate_provision_totals( - self, - provision_gdfs_dict: dict[str, gpd.GeoDataFrame], - ndigits: int = 2, + self, + provision_gdfs_dict: dict[str, gpd.GeoDataFrame], + ndigits: int = 2, ) -> dict[str, float | None]: prov_totals: dict[str, float | None] = {} for st_name, prov_gdf in provision_gdfs_dict.items(): @@ -786,10 +786,10 @@ async def calculate_provision_totals( return prov_totals async def territory_transformation_scenario_before( - self, - token: str, - params: ContextDevelopmentDTO, - context_blocks: gpd.GeoDataFrame = None, + self, + token: str, + params: ContextDevelopmentDTO, + context_blocks: gpd.GeoDataFrame = None, ): method_name = "territory_transformation" @@ -808,9 +808,9 @@ async def territory_transformation_scenario_before( None if force else self.cache.load(method_name, params.scenario_id, phash) ) if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "before" in cached["data"] + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "before" in cached["data"] ): return { n: fc_to_gdf(fc) @@ -849,8 +849,12 @@ async def territory_transformation_scenario_before( before_blocks["is_project"] = ( before_blocks["is_project"].fillna(False).astype(bool) ) - - graph = get_accessibility_graph(before_blocks, "intermodal") + try: + graph = get_accessibility_graph(before_blocks, "intermodal") + except Exception as e: + raise http_exception( + 500, "Error generating territory graph", _detail=str(e) + ) acc_mx = calculate_accessibility_matrix(before_blocks, graph) prov_gdfs_before = {} @@ -886,10 +890,10 @@ async def territory_transformation_scenario_before( return prov_gdfs_before def _build_facade( - self, - after_blocks: gpd.GeoDataFrame, - acc_mx: pd.DataFrame, - service_types: pd.DataFrame, + self, + after_blocks: gpd.GeoDataFrame, + acc_mx: pd.DataFrame, + service_types: pd.DataFrame, ) -> Facade: blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() @@ -920,11 +924,11 @@ def _build_facade( return facade async def territory_transformation_scenario_after( - self, - token, - params: ContextDevelopmentDTO | DevelopmentDTO, - context_blocks: gpd.GeoDataFrame, - save_cache: bool = True, + self, + token, + params: ContextDevelopmentDTO | DevelopmentDTO, + context_blocks: gpd.GeoDataFrame, + save_cache: bool = True, ): # provision after method_name = "territory_transformation" @@ -948,13 +952,17 @@ async def territory_transformation_scenario_after( None if force else self.cache.load(method_name, params.scenario_id, phash) ) if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "after" in cached["data"] + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "after" in cached["data"] ): - gdfs_after = {n: fc_to_gdf(fc) for n, fc in cached["data"]["after"].items() if is_fc(fc)} + gdfs_after = { + n: fc_to_gdf(fc) + for n, fc in cached["data"]["after"].items() + if is_fc(fc) + } totals = cached["data"]["after"].get("provision_total_after") - opt_ctx = (cached.get("data", {}).get("opt_context") or {}) + opt_ctx = cached.get("data", {}).get("opt_context") or {} return {"prov_gdfs_after": gdfs_after, "prov_totals": totals, **opt_ctx} logger.info("Cache stale, missing or forced: calculating AFTER") @@ -989,13 +997,13 @@ async def territory_transformation_scenario_after( acc_mx = calculate_accessibility_matrix(after_blocks, graph) service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) if ( - "population" not in after_blocks.columns - or after_blocks["population"].isna().any() + "population" not in after_blocks.columns + or after_blocks["population"].isna().any() ): dev_df = await self.run_development_parameters(after_blocks) after_blocks["population"] = pd.to_numeric( @@ -1088,9 +1096,9 @@ async def territory_transformation_scenario_after( } async def territory_transformation( - self, - token: str, - params: ContextDevelopmentDTO, + self, + token: str, + params: ContextDevelopmentDTO, ) -> dict[str, Any] | dict[str, dict[str, Any]]: info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) @@ -1114,9 +1122,9 @@ async def territory_transformation( cached = self.cache.load("territory_transformation", params.scenario_id, phash) if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "after" in cached["data"] + cached + and cached["meta"]["scenario_updated_at"] == updated_at + and "after" in cached["data"] ): prov_after = { name: fc_to_gdf(fc) @@ -1322,212 +1330,277 @@ async def values_oriented_requirements( token: str, params: TerritoryTransformationDTO | DevelopmentDTO, persist: Literal["full", "table_only"] = "full", -): + ): + + method_name = "values_oriented_requirements" + + info_curr = await self.urban_api_client.get_scenario_info( + params.scenario_id, token + ) + updated_at_curr = info_curr["updated_at"] + project_id = (info_curr.get("project") or {}).get("project_id") + regional_id = (info_curr.get("parent_scenario") or {}).get("id") + + force: bool = bool(getattr(params, "force", False)) + + base_id: Optional[int] = None + if project_id and regional_id: + proj_scenarios = await self.urban_api_client.get_project_scenarios( + project_id, token + ) + + def _truthy_is_based(x): + v = x.get("is_based") + return ( + v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + ) - method_name = "values_oriented_requirements" + def _parent_id(x): + p = x.get("parent_scenario") + return p.get("id") if isinstance(p, dict) else p - info_curr = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - updated_at_curr = info_curr["updated_at"] - project_id = (info_curr.get("project") or {}).get("project_id") - regional_id = (info_curr.get("parent_scenario") or {}).get("id") + def _sid(x): + try: + return int(x.get("scenario_id")) + except Exception: + return None - force: bool = bool(getattr(params, "force", False)) + matches = [ + s + for s in proj_scenarios + if _truthy_is_based(s) + and _parent_id(s) == regional_id + and _sid(s) is not None + ] + if not matches: + only_based = [ + s + for s in proj_scenarios + if _truthy_is_based(s) and _sid(s) is not None + ] + if only_based: + only_based.sort( + key=lambda x: ( + x.get("updated_at") is not None, + x.get("updated_at"), + ), + reverse=True, + ) + matches = [only_based[0]] + if matches: + matches.sort( + key=lambda x: ( + x.get("updated_at") is not None, + x.get("updated_at"), + ), + reverse=True, + ) + base_id = _sid(matches[0]) - base_id: Optional[int] = None - if project_id and regional_id: - proj_scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + if base_id is None: + base_id = params.scenario_id - def _truthy_is_based(x): - v = x.get("is_based") - return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + params_base = params.model_copy( + update={ + "scenario_id": base_id, + "proj_func_zone_source": None, + "proj_func_source_year": None, + "context_func_zone_source": None, + "context_func_source_year": None, + } + ) + params_base = await self.get_optimal_func_zone_data(params_base, token) - def _parent_id(x): - p = x.get("parent_scenario") - return p.get("id") if isinstance(p, dict) else p + params_for_hash_base = await self.build_hash_params(params_base, token) + phash_base = self.cache.params_hash(params_for_hash_base) + info_base = await self.urban_api_client.get_scenario_info(base_id, token) + updated_at_base = info_base["updated_at"] - def _sid(x): - try: - return int(x.get("scenario_id")) - except Exception: - return None + def _result_to_df(payload: Any) -> pd.DataFrame: + if isinstance(payload, dict) and "data" not in payload: + items = sorted( + ((int(k), v.get("value", 0.0)) for k, v in payload.items()), + key=lambda t: t[0], + ) + idx = [k for k, _ in items] + vals = [float(v) if v is not None else 0.0 for _, v in items] + return pd.DataFrame({"social_value_level": vals}, index=idx) + df = pd.DataFrame( + data=payload["data"], index=payload["index"], columns=payload["columns"] + ) + df.index.name = payload.get("index_name", None) + return df - matches = [ - s for s in proj_scenarios - if _truthy_is_based(s) and _parent_id(s) == regional_id and _sid(s) is not None - ] - if not matches: - only_based = [s for s in proj_scenarios if _truthy_is_based(s) and _sid(s) is not None] - if only_based: - only_based.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) - matches = [only_based[0]] - if matches: - matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) - base_id = _sid(matches[0]) - - if base_id is None: - base_id = params.scenario_id - - params_base = params.model_copy(update={ - "scenario_id": base_id, - "proj_func_zone_source": None, - "proj_func_source_year": None, - "context_func_zone_source": None, - "context_func_source_year": None, - }) - params_base = await self.get_optimal_func_zone_data(params_base, token) - - params_for_hash_base = await self.build_hash_params(params_base, token) - phash_base = self.cache.params_hash(params_for_hash_base) - info_base = await self.urban_api_client.get_scenario_info(base_id, token) - updated_at_base = info_base["updated_at"] - - def _result_to_df(payload: Any) -> pd.DataFrame: - if isinstance(payload, dict) and "data" not in payload: - items = sorted(((int(k), v.get("value", 0.0)) for k, v in payload.items()), key=lambda t: t[0]) - idx = [k for k, _ in items] - vals = [float(v) if v is not None else 0.0 for _, v in items] - return pd.DataFrame({"social_value_level": vals}, index=idx) - df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) - df.index.name = payload.get("index_name", None) - return df - - if not force: - cached_base = self.cache.load(method_name, base_id, phash_base) - if cached_base and cached_base["meta"].get("scenario_updated_at") == updated_at_base and "result" in cached_base["data"]: - return _result_to_df(cached_base["data"]["result"]) - - context_blocks, _ = await self.aggregate_blocks_layer_context( - params.scenario_id, - params_base.context_func_zone_source, - params_base.context_func_source_year, - token, - ) + if not force: + cached_base = self.cache.load(method_name, base_id, phash_base) + if ( + cached_base + and cached_base["meta"].get("scenario_updated_at") == updated_at_base + and "result" in cached_base["data"] + ): + return _result_to_df(cached_base["data"]["result"]) - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - params_base.scenario_id, - params_base.proj_func_zone_source, - params_base.proj_func_source_year, - token, - ) - scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - - cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] - scenario_blocks.loc[scenario_blocks["is_project"], ["population"] + cap_cols] = 0 - if "capacity" in scenario_blocks.columns: - scenario_blocks = scenario_blocks.drop(columns="capacity") - - blocks = gpd.GeoDataFrame( - pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs, - ) - - service_types = await self.urban_api_client.get_service_types() - service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[~service_types["social_values"].isna()].copy() - - graph = get_accessibility_graph(blocks, "intermodal") - acc_mx = calculate_accessibility_matrix(blocks, graph) - - prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - prov_gdf = prov_gdf.to_crs(4326).drop(columns="provision_weak", errors="ignore") - num_cols = prov_gdf.select_dtypes(include="number").columns - prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) - prov_gdfs[st_name] = prov_gdf - - social_values_provisions: Dict[str, list[float | None]] = {} - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - social_values = service_types.loc[st_id, "social_values"] - prov_gdf = prov_gdfs.get(st_name) - if prov_gdf is None or prov_gdf.empty: - continue - prov_total = None if prov_gdf["demand"].sum() == 0 else float(provision_strong_total(prov_gdf)) - for sv in social_values: - social_values_provisions.setdefault(sv, []).append(prov_total) - - soc_values_map = await self.urban_api_client.get_social_values_info() - index = list(social_values_provisions.keys()) - result_df = pd.DataFrame( - data=[self._get_value_level(social_values_provisions[sv]) for sv in index], - index=index, - columns=["social_value_level"], - ) - values_table = { - int(sv_id): { - "name": soc_values_map.get(sv_id, str(sv_id)), - "value": round(float(val), 2) if val else 0.0, - } - for sv_id, val in result_df["social_value_level"].to_dict().items() - } - - if persist == "full": - payload = { - "provision": { - name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) - for name, gdf in prov_gdfs.items() - }, - "result": values_table, + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, + params_base.context_func_zone_source, + params_base.context_func_source_year, + token, + ) + + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + params_base.scenario_id, + params_base.proj_func_zone_source, + params_base.proj_func_source_year, + token, + ) + scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + + cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] + scenario_blocks.loc[ + scenario_blocks["is_project"], ["population"] + cap_cols + ] = 0 + if "capacity" in scenario_blocks.columns: + scenario_blocks = scenario_blocks.drop(columns="capacity") + + blocks = gpd.GeoDataFrame( + pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs, + ) + + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[~service_types["social_values"].isna()].copy() + + try: + graph = get_accessibility_graph(blocks, "intermodal") + except Exception as e: + raise http_exception( + 500, "Error generating territory graph", _detail=str(e) + ) + acc_mx = calculate_accessibility_matrix(blocks, graph) + + prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdf.to_crs(4326).drop( + columns="provision_weak", errors="ignore" + ) + num_cols = prov_gdf.select_dtypes(include="number").columns + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + prov_gdfs[st_name] = prov_gdf + + social_values_provisions: Dict[str, list[float | None]] = {} + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + social_values = service_types.loc[st_id, "social_values"] + prov_gdf = prov_gdfs.get(st_name) + if prov_gdf is None or prov_gdf.empty: + continue + prov_total = ( + None + if prov_gdf["demand"].sum() == 0 + else float(provision_strong_total(prov_gdf)) + ) + for sv in social_values: + social_values_provisions.setdefault(sv, []).append(prov_total) + + soc_values_map = await self.urban_api_client.get_social_values_info() + index = list(social_values_provisions.keys()) + result_df = pd.DataFrame( + data=[self._get_value_level(social_values_provisions[sv]) for sv in index], + index=index, + columns=["social_value_level"], + ) + values_table = { + int(sv_id): { + "name": soc_values_map.get(sv_id, str(sv_id)), + "value": round(float(val), 2) if val else 0.0, + } + for sv_id, val in result_df["social_value_level"].to_dict().items() } - else: - payload = {"result": values_table} - self.cache.save( - method_name, - base_id, - params_for_hash_base, - payload, - scenario_updated_at=updated_at_base, - ) + if persist == "full": + payload = { + "provision": { + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs.items() + }, + "result": values_table, + } + else: + payload = {"result": values_table} + + self.cache.save( + method_name, + base_id, + params_for_hash_base, + payload, + scenario_updated_at=updated_at_base, + ) - return result_df + return result_df async def run_values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, + self, + token: str, + params: TerritoryTransformationDTO, ): method_name = "values_oriented_requirements" - df_current = await self.values_oriented_requirements(token, params, persist="full") + df_current = await self.values_oriented_requirements( + token, params, persist="full" + ) params_for_hash_curr = await self.build_hash_params(params, token) phash_curr = self.cache.params_hash(params_for_hash_curr) - info_curr = await self.urban_api_client.get_scenario_info(params.scenario_id, token) + info_curr = await self.urban_api_client.get_scenario_info( + params.scenario_id, token + ) updated_at_curr = info_curr["updated_at"] cached_curr = self.cache.load(method_name, params.scenario_id, phash_curr) - if not (cached_curr and cached_curr["meta"].get("scenario_updated_at") == updated_at_curr): + if not ( + cached_curr + and cached_curr["meta"].get("scenario_updated_at") == updated_at_curr + ): raise RuntimeError("Не удалось найти кэш текущего сценария после расчёта.") def _extract_values_table(payload, soc_values_map): - import pandas as pd if isinstance(payload, dict) and "data" not in payload: return payload - df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) + df = pd.DataFrame( + data=payload["data"], index=payload["index"], columns=payload["columns"] + ) df.index.name = payload.get("index_name", None) col = df.columns[0] return { - int(sv_id): {"name": soc_values_map.get(sv_id, str(sv_id)), - "value": round(float(val), 2) if val else 0.0} + int(sv_id): { + "name": soc_values_map.get(sv_id, str(sv_id)), + "value": round(float(val), 2) if val else 0.0, + } for sv_id, val in df[col].to_dict().items() } soc_values_map = await self.urban_api_client.get_social_values_info() - table_current = _extract_values_table(cached_curr["data"]["result"], soc_values_map) + table_current = _extract_values_table( + cached_curr["data"]["result"], soc_values_map + ) project_id = (info_curr.get("project") or {}).get("project_id") regional_id = (info_curr.get("parent_scenario") or {}).get("id") base_id, base_updated_at = None, None if project_id and regional_id: - proj_scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + proj_scenarios = await self.urban_api_client.get_project_scenarios( + project_id, token + ) def _truthy_is_based(x): v = x.get("is_based") - return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + return ( + v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + ) def _parent_id(x): p = x.get("parent_scenario") @@ -1540,51 +1613,84 @@ def _sid(x): return None matches = [ - s for s in proj_scenarios - if _truthy_is_based(s) and _parent_id(s) == regional_id and _sid(s) is not None + s + for s in proj_scenarios + if _truthy_is_based(s) + and _parent_id(s) == regional_id + and _sid(s) is not None ] if not matches: - only_based = [s for s in proj_scenarios if _truthy_is_based(s) and _sid(s) is not None] + only_based = [ + s + for s in proj_scenarios + if _truthy_is_based(s) and _sid(s) is not None + ] if only_based: - only_based.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + only_based.sort( + key=lambda x: ( + x.get("updated_at") is not None, + x.get("updated_at"), + ), + reverse=True, + ) matches = [only_based[0]] if matches: - matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) + matches.sort( + key=lambda x: ( + x.get("updated_at") is not None, + x.get("updated_at"), + ), + reverse=True, + ) base_id = _sid(matches[0]) base_updated_at = matches[0].get("updated_at") - table_base = {} + table_base: Dict[int, Dict[str, Any]] = {} if base_id: - params_base = params.model_copy(update={ - "scenario_id": base_id, - "proj_func_zone_source": None, - "proj_func_source_year": None, - "context_func_zone_source": None, - "context_func_source_year": None, - }) + params_base = params.model_copy( + update={ + "scenario_id": base_id, + "proj_func_zone_source": None, + "proj_func_source_year": None, + "context_func_zone_source": None, + "context_func_source_year": None, + } + ) params_base = await self.get_optimal_func_zone_data(params_base, token) params_for_hash_base = await self.build_hash_params(params_base, token) phash_base = self.cache.params_hash(params_for_hash_base) if not base_updated_at: - info_base = await self.urban_api_client.get_scenario_info(base_id, token) + info_base = await self.urban_api_client.get_scenario_info( + base_id, token + ) base_updated_at = info_base.get("updated_at") cached_base = self.cache.load(method_name, base_id, phash_base) - - if cached_base and cached_base["meta"].get("scenario_updated_at") == base_updated_at and "result" in \ - cached_base["data"]: - table_base = _extract_values_table(cached_base["data"]["result"], soc_values_map) + if ( + cached_base + and cached_base["meta"].get("scenario_updated_at") == base_updated_at + and "result" in cached_base["data"] + ): + table_base = _extract_values_table( + cached_base["data"]["result"], soc_values_map + ) else: _ = await self.values_oriented_requirements( token, params_base, persist="table_only" ) cached_base = self.cache.load(method_name, base_id, phash_base) - if cached_base and cached_base["meta"].get("scenario_updated_at") == base_updated_at and "result" in \ - cached_base["data"]: - table_base = _extract_values_table(cached_base["data"]["result"], soc_values_map) + if ( + cached_base + and cached_base["meta"].get("scenario_updated_at") + == base_updated_at + and "result" in cached_base["data"] + ): + table_base = _extract_values_table( + cached_base["data"]["result"], soc_values_map + ) self.cache.save( method_name, @@ -1599,115 +1705,3 @@ def _sid(x): ) return df_current, table_current, table_base - - async def run_values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, -): - method_name = "values_oriented_requirements" - - df_current = await self.values_oriented_requirements(token, params, persist="full") - - params_for_hash_curr = await self.build_hash_params(params, token) - phash_curr = self.cache.params_hash(params_for_hash_curr) - info_curr = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - updated_at_curr = info_curr["updated_at"] - - cached_curr = self.cache.load(method_name, params.scenario_id, phash_curr) - if not (cached_curr and cached_curr["meta"].get("scenario_updated_at") == updated_at_curr): - raise RuntimeError("Не удалось найти кэш текущего сценария после расчёта.") - - def _extract_values_table(payload, soc_values_map): - if isinstance(payload, dict) and "data" not in payload: - return payload - df = pd.DataFrame(data=payload["data"], index=payload["index"], columns=payload["columns"]) - df.index.name = payload.get("index_name", None) - col = df.columns[0] - return { - int(sv_id): { - "name": soc_values_map.get(sv_id, str(sv_id)), - "value": round(float(val), 2) if val else 0.0 - } - for sv_id, val in df[col].to_dict().items() - } - - soc_values_map = await self.urban_api_client.get_social_values_info() - table_current = _extract_values_table(cached_curr["data"]["result"], soc_values_map) - - project_id = (info_curr.get("project") or {}).get("project_id") - regional_id = (info_curr.get("parent_scenario") or {}).get("id") - base_id, base_updated_at = None, None - - if project_id and regional_id: - proj_scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) - - def _truthy_is_based(x): - v = x.get("is_based") - return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") - - def _parent_id(x): - p = x.get("parent_scenario") - return p.get("id") if isinstance(p, dict) else p - - def _sid(x): - try: - return int(x.get("scenario_id")) - except Exception: - return None - - matches = [ - s for s in proj_scenarios - if _truthy_is_based(s) and _parent_id(s) == regional_id and _sid(s) is not None - ] - if not matches: - only_based = [s for s in proj_scenarios if _truthy_is_based(s) and _sid(s) is not None] - if only_based: - only_based.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) - matches = [only_based[0]] - if matches: - matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) - base_id = _sid(matches[0]) - base_updated_at = matches[0].get("updated_at") - - table_base: Dict[int, Dict[str, Any]] = {} - - if base_id: - params_base = params.model_copy(update={ - "scenario_id": base_id, - "proj_func_zone_source": None, - "proj_func_source_year": None, - "context_func_zone_source": None, - "context_func_source_year": None, - }) - params_base = await self.get_optimal_func_zone_data(params_base, token) - - params_for_hash_base = await self.build_hash_params(params_base, token) - phash_base = self.cache.params_hash(params_for_hash_base) - - if not base_updated_at: - info_base = await self.urban_api_client.get_scenario_info(base_id, token) - base_updated_at = info_base.get("updated_at") - - cached_base = self.cache.load(method_name, base_id, phash_base) - if cached_base and cached_base["meta"].get("scenario_updated_at") == base_updated_at and "result" in cached_base["data"]: - table_base = _extract_values_table(cached_base["data"]["result"], soc_values_map) - else: - _ = await self.values_oriented_requirements(token, params_base, persist="table_only") - cached_base = self.cache.load(method_name, base_id, phash_base) - if cached_base and cached_base["meta"].get("scenario_updated_at") == base_updated_at and "result" in cached_base["data"]: - table_base = _extract_values_table(cached_base["data"]["result"], soc_values_map) - - self.cache.save( - method_name, - params.scenario_id, - params_for_hash_curr, - { - **cached_curr["data"], - "values_table_current": table_current, - "values_table_base": table_base, - }, - scenario_updated_at=updated_at_curr, - ) - - return df_current, table_current, table_base diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index eb89153..de54c1b 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -33,13 +33,17 @@ def _get_lock(key: str) -> asyncio.Lock: async def _with_defaults( - dto: ContextDevelopmentDTO, token: str + dto: ContextDevelopmentDTO, token: str ) -> ContextDevelopmentDTO: return await effects_service.get_optimal_func_zone_data(dto, token) def _is_fc(x: dict) -> bool: - return isinstance(x, dict) and x.get("type") == "FeatureCollection" and isinstance(x.get("features"), list) + return ( + isinstance(x, dict) + and x.get("type") == "FeatureCollection" + and isinstance(x.get("features"), list) + ) def _section_ready(sec: dict | None) -> bool: @@ -63,9 +67,9 @@ async def get_methods(): @router.post("/{method}", status_code=202) async def create_task( - method: str, - params: Annotated[ContextDevelopmentDTO, Depends()], - token: str = Depends(verify_token), + method: str, + params: Annotated[ContextDevelopmentDTO, Depends()], + token: str = Depends(verify_token), ): if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) @@ -82,7 +86,9 @@ async def create_task( force = getattr(params, "force", False) - cached = None if force else file_cache.load(method, params_filled.scenario_id, phash) + cached = ( + None if force else file_cache.load(method, params_filled.scenario_id, phash) + ) if not force and _cache_complete(method, cached): return {"task_id": task_id, "status": "done"} @@ -133,8 +139,8 @@ async def task_status(task_id: str): @router.get("/get_service_types") async def get_service_types( - scenario_id: int, - method: str = "territory_transformation", + scenario_id: int, + method: str = "territory_transformation", ): return await get_services_with_ids_from_layer(scenario_id, method, file_cache) @@ -187,26 +193,32 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str @router.get("/values_oriented_requirements/{scenario_id}/{service_name}") async def get_values_oriented_requirements_layer( - scenario_id: int, - service_name: str, - token: str = Depends(verify_token), + scenario_id: int, + service_name: str, + token: str = Depends(verify_token), ): base_id = await _resolve_base_id(urban_api_client, token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: - raise http_exception(404, f"no saved result for base scenario {base_id}", base_id) + raise http_exception( + 404, f"no saved result for base scenario {base_id}", base_id + ) info_base = await urban_api_client.get_scenario_info(base_id, token) if cached.get("meta", {}).get("scenario_updated_at") != info_base.get("updated_at"): - raise http_exception(404, f"stale cache for base scenario {base_id}, recompute required", base_id) + raise http_exception( + 404, f"stale cache for base scenario {base_id}, recompute required", base_id + ) data: dict = cached.get("data", {}) prov = (data.get("provision") or {}).get(service_name) values_dict = data.get("result") if not prov: - raise http_exception(404, f"service '{service_name}' not found in base scenario {base_id}") + raise http_exception( + 404, f"service '{service_name}' not found in base scenario {base_id}" + ) return JSONResponse( content={ @@ -217,20 +229,24 @@ async def get_values_oriented_requirements_layer( ) -@router.get("/values_oriented_requirements_table/{scenario_id}/{service_name}") +@router.get("/values_oriented_requirements_table/{scenario_id}") async def get_values_oriented_requirements_table( - scenario_id: int, - token: str = Depends(verify_token), + scenario_id: int, + token: str = Depends(verify_token), ): base_id = await _resolve_base_id(urban_api_client, token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: - raise http_exception(404, f"no saved result for base scenario {base_id}", base_id) + raise http_exception( + 404, f"no saved result for base scenario {base_id}", base_id + ) info_base = await urban_api_client.get_scenario_info(base_id, token) if cached.get("meta", {}).get("scenario_updated_at") != info_base.get("updated_at"): - raise http_exception(404, f"stale cache for base scenario {base_id}, recompute required", base_id) + raise http_exception( + 404, f"stale cache for base scenario {base_id}, recompute required", base_id + ) data: dict = cached.get("data", {}) values_dict = data.get("result") @@ -276,13 +292,9 @@ async def get_total_provisions(scenario_id: int): ) if provision_before and not provision_after: - return JSONResponse( - content={"provision_total_before": provision_before} - ) + return JSONResponse(content={"provision_total_before": provision_before}) if provision_after and not provision_before: - return JSONResponse( - content={"provision_total_after": provision_after} - ) + return JSONResponse(content={"provision_total_after": provision_after}) raise http_exception(404, f"Result for scenario ID{scenario_id} not found") From 82a29be44920c1edd3bf6f36adcb1cb041b4dd83 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 6 Oct 2025 13:31:25 +0300 Subject: [PATCH 094/161] fix(F_36_service_types): 1. fix for router to return service types in f 36 --- app/common/utils/ids_convertation.py | 75 ++++++++++++------- app/dependencies.py | 2 + .../modules/service_type_service.py | 18 ++++- app/effects_api/tasks_controller.py | 13 ++-- 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/app/common/utils/ids_convertation.py b/app/common/utils/ids_convertation.py index f190921..0d63073 100644 --- a/app/common/utils/ids_convertation.py +++ b/app/common/utils/ids_convertation.py @@ -1,40 +1,57 @@ from typing import Any, Dict, Optional +from app.clients.urban_api_client import UrbanAPIClient -def _truthy_is_based(v: Any) -> bool: - return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") +class EffectsUtils: + def __init__( + self, + urban_api_client: UrbanAPIClient, + ): + self.__name__ = "EffectsUtils" + self.urban_api_client = urban_api_client -def _parent_id(s: Dict[str, Any]) -> Optional[int]: - p = s.get("parent_scenario") - return p.get("id") if isinstance(p, dict) else p + def _truthy_is_based(self, v: Any) -> bool: + return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + def _parent_id(self, s: Dict[str, Any]) -> Optional[int]: + p = s.get("parent_scenario") + return p.get("id") if isinstance(p, dict) else p -def _sid(s: Dict[str, Any]) -> Optional[int]: - try: - return int(s.get("scenario_id")) - except Exception: - return None + def _sid(self, s: Dict[str, Any]) -> Optional[int]: + try: + return int(s.get("scenario_id")) + except Exception: + return None + async def resolve_base_id(self, token: str, scenario_id: int) -> int: + info = await self.urban_api_client.get_scenario_info(scenario_id, token) + project_id = (info.get("project") or {}).get("project_id") + regional_id = (info.get("parent_scenario") or {}).get("id") -async def _resolve_base_id(urban_api_client, token: str, scenario_id: int) -> int: - info = await urban_api_client.get_scenario_info(scenario_id, token) - project_id = (info.get("project") or {}).get("project_id") - regional_id = (info.get("parent_scenario") or {}).get("id") - - if not project_id or not regional_id: - return scenario_id - - scenarios = await urban_api_client.get_project_scenarios(project_id, token) - matches = [ - s for s in scenarios - if _truthy_is_based(s.get("is_based")) and _parent_id(s) == regional_id and _sid(s) is not None - ] - if not matches: - only_based = [s for s in scenarios if _truthy_is_based(s.get("is_based")) and _sid(s) is not None] - if not only_based: + if not project_id or not regional_id: return scenario_id - matches = only_based - matches.sort(key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True) - return _sid(matches[0]) or scenario_id + scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + matches = [ + s + for s in scenarios + if self._truthy_is_based(s.get("is_based")) + and self._parent_id(s) == regional_id + and self._sid(s) is not None + ] + if not matches: + only_based = [ + s + for s in scenarios + if self._truthy_is_based(s.get("is_based")) and self._sid(s) is not None + ] + if not only_based: + return scenario_id + matches = only_based + + matches.sort( + key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), + reverse=True, + ) + return self._sid(matches[0]) or scenario_id diff --git a/app/dependencies.py b/app/dependencies.py index 1483aec..e9e9a2e 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,6 +7,7 @@ from app.clients.urban_api_client import UrbanAPIClient from app.common.api_handlers.json_api_handler import JSONAPIHandler from app.common.caching.caching_service import FileCache +from app.common.utils.ids_convertation import EffectsUtils from app.effects_api.effects_service import EffectsService from app.effects_api.modules.scenario_service import ScenarioService @@ -28,3 +29,4 @@ file_cache = FileCache() scenario_service = ScenarioService(urban_api_client) effects_service = EffectsService(urban_api_client, file_cache, scenario_service) +effects_utils = EffectsUtils(urban_api_client) diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 5d6c507..27005d8 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -6,6 +6,7 @@ from app.clients.urban_api_client import UrbanAPIClient from app.common.caching.caching_service import FileCache +from app.common.utils.ids_convertation import EffectsUtils from app.effects_api.constants.const import SERVICE_TYPES_MAPPING _SOCIAL_VALUES_BY_ST: Dict[int, Optional[List[int]]] = {} @@ -86,7 +87,12 @@ async def get_services_with_ids_from_layer( scenario_id: int, method: str, cache: FileCache, + utils: EffectsUtils, + token: str | None = None, ) -> dict: + if method == "values_oriented_requirements": + scenario_id = await utils.resolve_base_id(token, scenario_id) + cached: dict | None = cache.load_latest(method, scenario_id) if not cached or "data" not in cached: return {"before": [], "after": []} @@ -107,6 +113,7 @@ async def get_services_with_ids_from_layer( return {"before": [], "after": []} + async def build_en_to_ru_map(service_types_df: pd.DataFrame) -> dict[str, str]: russian_names_dict = {} for st_id, en_key in SERVICE_TYPES_MAPPING.items(): @@ -120,13 +127,20 @@ async def build_en_to_ru_map(service_types_df: pd.DataFrame) -> dict[str, str]: russian_names_dict[en_key] = ru_name return russian_names_dict -async def remap_properties_keys_in_geojson(geojson: dict, en2ru: dict[str, str]) -> dict: + +async def remap_properties_keys_in_geojson( + geojson: dict, en2ru: dict[str, str] +) -> dict: feats = geojson.get("features", []) for f in feats: props = f.get("properties", {}) to_rename = [(k, en2ru[k]) for k in props.keys() if k in en2ru] for old_k, new_k in to_rename: - if new_k in props and isinstance(props[new_k], dict) and isinstance(props[old_k], dict): + if ( + new_k in props + and isinstance(props[new_k], dict) + and isinstance(props[old_k], dict) + ): merged = {**props[old_k], **props[new_k]} props[new_k] = merged else: diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index de54c1b..f61efcd 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -14,8 +14,8 @@ ) from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.ids_convertation import _resolve_base_id -from ..dependencies import effects_service, file_cache, urban_api_client +from ..common.utils.ids_convertation import EffectsUtils +from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client from .dto.development_dto import ContextDevelopmentDTO from .modules.service_type_service import get_services_with_ids_from_layer @@ -141,8 +141,11 @@ async def task_status(task_id: str): async def get_service_types( scenario_id: int, method: str = "territory_transformation", + token: str = Depends(verify_token), ): - return await get_services_with_ids_from_layer(scenario_id, method, file_cache) + return await get_services_with_ids_from_layer( + scenario_id, method, file_cache, effects_utils, token=token + ) @router.get("/territory_transformation/{scenario_id}/{service_name}") @@ -197,7 +200,7 @@ async def get_values_oriented_requirements_layer( service_name: str, token: str = Depends(verify_token), ): - base_id = await _resolve_base_id(urban_api_client, token, scenario_id) + base_id = await EffectsUtils.resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: @@ -234,7 +237,7 @@ async def get_values_oriented_requirements_table( scenario_id: int, token: str = Depends(verify_token), ): - base_id = await _resolve_base_id(urban_api_client, token, scenario_id) + base_id = await EffectsUtils.resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: From 300f7784ac80b0ee095484213fab1389a85018b2 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 6 Oct 2025 14:01:39 +0300 Subject: [PATCH 095/161] fix(F_36_layers): 1. fix for router to return layers in f 36 --- app/effects_api/tasks_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index f61efcd..9c0a75e 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -200,7 +200,7 @@ async def get_values_oriented_requirements_layer( service_name: str, token: str = Depends(verify_token), ): - base_id = await EffectsUtils.resolve_base_id(token, scenario_id) + base_id = await effects_utils.resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: @@ -237,7 +237,7 @@ async def get_values_oriented_requirements_table( scenario_id: int, token: str = Depends(verify_token), ): - base_id = await EffectsUtils.resolve_base_id(token, scenario_id) + base_id = await effects_utils.resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: From 3b89eebb093a31bc3a67569cd8be19bb260d6de4 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 6 Oct 2025 18:53:54 +0300 Subject: [PATCH 096/161] feat(F_36_layers): 1. Addition for f36 table router with new aggregated deficit table --- app/effects_api/effects_service.py | 205 ++++++---------------------- app/effects_api/tasks_controller.py | 4 + 2 files changed, 43 insertions(+), 166 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 8a8aca7..c8adf41 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1520,6 +1520,38 @@ def _result_to_df(payload: Any) -> pd.DataFrame: for sv_id, val in result_df["social_value_level"].to_dict().items() } + raw_services_df = await self.urban_api_client.get_service_types() + en2ru = await build_en_to_ru_map(raw_services_df) + + demand_left_col = "demand_left" + social_values_table: list[dict] = [] + + for st_id in service_types.index: + st_en = service_types.loc[st_id, "name"] + st_ru = en2ru.get(st_en, st_en) + + linked_ids = list( + map(int, (service_types.loc[st_id, "social_values"] or [])) + ) + linked_ru = [soc_values_map.get(sv_id, str(sv_id)) for sv_id in linked_ids] + + gdf = prov_gdfs.get(st_en) + total_unsatisfied = 0.0 + if gdf is not None and not gdf.empty: + if demand_left_col not in gdf.columns: + raise RuntimeError( + f"Колонка '{demand_left_col}' отсутствует для сервиса '{st_en}'" + ) + total_unsatisfied = float(gdf[demand_left_col].sum()) + + social_values_table.append( + { + "service": st_ru, + "unsatisfied_demand_sum": round(total_unsatisfied, 2), + "social_values": linked_ru, + } + ) + if persist == "full": payload = { "provision": { @@ -1527,9 +1559,15 @@ def _result_to_df(payload: Any) -> pd.DataFrame: for name, gdf in prov_gdfs.items() }, "result": values_table, + "social_values_table": social_values_table, + "services_type_deficit": social_values_table, } else: - payload = {"result": values_table} + payload = { + "result": values_table, + "social_values_table": social_values_table, + "services_type_deficit": social_values_table, + } self.cache.save( method_name, @@ -1540,168 +1578,3 @@ def _result_to_df(payload: Any) -> pd.DataFrame: ) return result_df - - async def run_values_oriented_requirements( - self, - token: str, - params: TerritoryTransformationDTO, - ): - method_name = "values_oriented_requirements" - - df_current = await self.values_oriented_requirements( - token, params, persist="full" - ) - - params_for_hash_curr = await self.build_hash_params(params, token) - phash_curr = self.cache.params_hash(params_for_hash_curr) - info_curr = await self.urban_api_client.get_scenario_info( - params.scenario_id, token - ) - updated_at_curr = info_curr["updated_at"] - - cached_curr = self.cache.load(method_name, params.scenario_id, phash_curr) - if not ( - cached_curr - and cached_curr["meta"].get("scenario_updated_at") == updated_at_curr - ): - raise RuntimeError("Не удалось найти кэш текущего сценария после расчёта.") - - def _extract_values_table(payload, soc_values_map): - if isinstance(payload, dict) and "data" not in payload: - return payload - df = pd.DataFrame( - data=payload["data"], index=payload["index"], columns=payload["columns"] - ) - df.index.name = payload.get("index_name", None) - col = df.columns[0] - return { - int(sv_id): { - "name": soc_values_map.get(sv_id, str(sv_id)), - "value": round(float(val), 2) if val else 0.0, - } - for sv_id, val in df[col].to_dict().items() - } - - soc_values_map = await self.urban_api_client.get_social_values_info() - table_current = _extract_values_table( - cached_curr["data"]["result"], soc_values_map - ) - - project_id = (info_curr.get("project") or {}).get("project_id") - regional_id = (info_curr.get("parent_scenario") or {}).get("id") - base_id, base_updated_at = None, None - - if project_id and regional_id: - proj_scenarios = await self.urban_api_client.get_project_scenarios( - project_id, token - ) - - def _truthy_is_based(x): - v = x.get("is_based") - return ( - v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") - ) - - def _parent_id(x): - p = x.get("parent_scenario") - return p.get("id") if isinstance(p, dict) else p - - def _sid(x): - try: - return int(x.get("scenario_id")) - except Exception: - return None - - matches = [ - s - for s in proj_scenarios - if _truthy_is_based(s) - and _parent_id(s) == regional_id - and _sid(s) is not None - ] - if not matches: - only_based = [ - s - for s in proj_scenarios - if _truthy_is_based(s) and _sid(s) is not None - ] - if only_based: - only_based.sort( - key=lambda x: ( - x.get("updated_at") is not None, - x.get("updated_at"), - ), - reverse=True, - ) - matches = [only_based[0]] - if matches: - matches.sort( - key=lambda x: ( - x.get("updated_at") is not None, - x.get("updated_at"), - ), - reverse=True, - ) - base_id = _sid(matches[0]) - base_updated_at = matches[0].get("updated_at") - - table_base: Dict[int, Dict[str, Any]] = {} - - if base_id: - params_base = params.model_copy( - update={ - "scenario_id": base_id, - "proj_func_zone_source": None, - "proj_func_source_year": None, - "context_func_zone_source": None, - "context_func_source_year": None, - } - ) - params_base = await self.get_optimal_func_zone_data(params_base, token) - - params_for_hash_base = await self.build_hash_params(params_base, token) - phash_base = self.cache.params_hash(params_for_hash_base) - - if not base_updated_at: - info_base = await self.urban_api_client.get_scenario_info( - base_id, token - ) - base_updated_at = info_base.get("updated_at") - - cached_base = self.cache.load(method_name, base_id, phash_base) - if ( - cached_base - and cached_base["meta"].get("scenario_updated_at") == base_updated_at - and "result" in cached_base["data"] - ): - table_base = _extract_values_table( - cached_base["data"]["result"], soc_values_map - ) - else: - _ = await self.values_oriented_requirements( - token, params_base, persist="table_only" - ) - cached_base = self.cache.load(method_name, base_id, phash_base) - if ( - cached_base - and cached_base["meta"].get("scenario_updated_at") - == base_updated_at - and "result" in cached_base["data"] - ): - table_base = _extract_values_table( - cached_base["data"]["result"], soc_values_map - ) - - self.cache.save( - method_name, - params.scenario_id, - params_for_hash_curr, - { - **cached_curr["data"], - "values_table_current": table_current, - "values_table_base": table_base, - }, - scenario_updated_at=updated_at_curr, - ) - - return df_current, table_current, table_base diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 9c0a75e..0cacf31 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -217,6 +217,7 @@ async def get_values_oriented_requirements_layer( data: dict = cached.get("data", {}) prov = (data.get("provision") or {}).get(service_name) values_dict = data.get("result") + values_table = data.get("social_values_table") if not prov: raise http_exception( @@ -228,6 +229,7 @@ async def get_values_oriented_requirements_layer( "base_scenario_id": base_id, "geojson": prov, "values_table": values_dict, + "services_type_deficit": values_table, } ) @@ -253,11 +255,13 @@ async def get_values_oriented_requirements_table( data: dict = cached.get("data", {}) values_dict = data.get("result") + values_table = data.get("social_values_table") return JSONResponse( content={ "base_scenario_id": base_id, "values_table": values_dict, + "services_type_deficit": values_table, } ) From 8ef862f13e6e0ef622b53969c778a4a9dcfe0c8e Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 7 Oct 2025 18:15:58 +0300 Subject: [PATCH 097/161] fix(effects_logic): 1. updates for new blockssnet version and perspective addition of landuse prediction --- app/effects_api/effects_service.py | 16 ++++++++-------- app/effects_api/modules/scenario_service.py | 2 +- requirements.txt | Bin 722 -> 718 bytes 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c8adf41..45d8712 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -14,10 +14,9 @@ from blocksnet.optimization.services import ( AreaSolution, Facade, - SimpleChooser, TPEOptimizer, WeightedConstraints, - WeightedObjective, + WeightedObjective, GradientChooser, ) from blocksnet.relations import ( calculate_accessibility_matrix, @@ -376,14 +375,14 @@ async def get_services_layer(self, scenario_id: int, token: str): blocks_services["capacity"] = ( blocks_services["capacity"].fillna(0).astype(int) ) - blocks_services["objects_count"] = ( - blocks_services["objects_count"].fillna(0).astype(int) + blocks_services["count"] = ( + blocks_services["count"].fillna(0).astype(int) ) blocks = blocks.join( blocks_services.drop(columns=["geometry"]).rename( columns={ "capacity": f"capacity_{service_type}", - "objects_count": f"count_{service_type}", + "count": f"count_{service_type}", } ) ) @@ -1023,17 +1022,17 @@ async def territory_transformation_scenario_after( num_params=facade.num_params, facade=facade, weights=services_weights, - max_evals=MAX_EVALS, + max_evals=1000, ) constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) tpe_optimizer = TPEOptimizer( objective=objective, constraints=constraints, - vars_chooser=SimpleChooser(facade), + vars_chooser=GradientChooser(facade, facade.num_params, num_top=5), ) best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=MAX_RUNS, timeout=60000, initial_runs_num=1 + max_runs=1000, timeout=4*60, initial_runs_num=1 ) prov_gdfs_after = {} @@ -1195,6 +1194,7 @@ async def values_transformation( ) after_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=False) + after_blocks.to_pickle("blocks_for_prediction.pkl") if "block_id" in after_blocks.columns: after_blocks["block_id"] = after_blocks["block_id"].astype(int) if after_blocks.index.name == "block_id": diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index c33726e..aca61c7 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -89,7 +89,7 @@ async def _get_scenario_blocks( ) if water is not None and not water.empty: - water = water.to_crs(crs).explode() + water = water.to_crs(crs).explode().reset_index(drop=True) if user_roads is not None and not user_roads.empty: user_roads = user_roads.to_crs(crs).explode().reset_index(drop=True) diff --git a/requirements.txt b/requirements.txt index 8f9618e6d94d9f5d73e13dee7c44ffa4b2658b66..233f98b20291301cd0147613628dc1785b2db5d4 100644 GIT binary patch delta 14 Vcmcb_dX9C14s#-d(MIF>OaLV;1cCqn delta 18 ZcmX@ddWm&{4tokiDnl8A;YR)WOaM1m1z!LF From a5e322bcaef61874253899b58f4fd69e2dd8bf82 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 8 Oct 2025 13:26:55 +0300 Subject: [PATCH 098/161] feat(f26_logic): 1. Sorting of the properties in the output geolayer 2. Slight optimization of translating and sorting logic --- app/dependencies.py | 2 +- app/effects_api/effects_service.py | 21 +++++++++++++++------ app/effects_api/modules/scenario_service.py | 2 +- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/dependencies.py b/app/dependencies.py index e9e9a2e..c2ca21e 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -28,5 +28,5 @@ urban_api_client = UrbanAPIClient(json_api_handler) file_cache = FileCache() scenario_service = ScenarioService(urban_api_client) -effects_service = EffectsService(urban_api_client, file_cache, scenario_service) effects_utils = EffectsUtils(urban_api_client) +effects_service = EffectsService(urban_api_client, file_cache, scenario_service) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c8adf41..3dedc4e 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -30,8 +30,7 @@ from app.effects_api.modules.scenario_service import ScenarioService from app.effects_api.modules.service_type_service import ( adapt_service_types, - build_en_to_ru_map, - remap_properties_keys_in_geojson, + build_en_to_ru_map ) from ..clients.urban_api_client import UrbanAPIClient @@ -1304,12 +1303,23 @@ def _fill_cell(x): ] gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] - gdf_out = gdf_out.to_crs(crs="EPSG:4326") + gdf_out = gdf_out.to_crs("EPSG:4326") gdf_out.geometry = round_coords(gdf_out.geometry, 6) - geojson = json.loads(gdf_out.to_json()) + service_types = await self.urban_api_client.get_service_types() en2ru = await build_en_to_ru_map(service_types) - geojson = await remap_properties_keys_in_geojson(geojson, en2ru) + rename_map = {k: v for k, v in en2ru.items() if k in gdf_out.columns} + if rename_map: + gdf_out = gdf_out.rename(columns=rename_map) + + non_geom = [c for c in gdf_out.columns if c != geom_col] + non_geom_sorted = sorted(non_geom, key=lambda s: s.casefold()) + pin_first = [c for c in ["is_project"] if c in non_geom_sorted] + rest = [c for c in non_geom_sorted if c not in pin_first] + gdf_out = gdf_out[pin_first + rest + [geom_col]] + + geojson_str = gdf_out.to_json() + geojson = json.loads(geojson_str) self.cache.save( "values_transformation", @@ -1318,7 +1328,6 @@ def _fill_cell(x): geojson, scenario_updated_at=updated_at, ) - return geojson def _get_value_level(self, provisions: list[float | None]) -> float: diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index c33726e..aca61c7 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -89,7 +89,7 @@ async def _get_scenario_blocks( ) if water is not None and not water.empty: - water = water.to_crs(crs).explode() + water = water.to_crs(crs).explode().reset_index(drop=True) if user_roads is not None and not user_roads.empty: user_roads = user_roads.to_crs(crs).explode().reset_index(drop=True) From 24655fb6a64e0d71a966152224ef5d1b28dfad5b Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 15 Oct 2025 00:19:54 +0300 Subject: [PATCH 099/161] feat(landuse_prediction): 1. New version of blocksnet 2. Logic for land use prediction --- app/common/utils/geodata.py | 35 ++++ app/dependencies.py | 4 +- app/effects_api/constants/const.py | 14 +- app/effects_api/effects_service.py | 37 +++- .../modules/land_use_prediction_adapter.py | 190 ++++++++++++++++++ requirements.txt | Bin 718 -> 718 bytes 6 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 app/effects_api/modules/land_use_prediction_adapter.py diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 325ca8e..8c789ce 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -3,6 +3,7 @@ import geopandas as gpd import pandas as pd +from loguru import logger from shapely.geometry.base import BaseGeometry from shapely.wkt import dumps, loads @@ -27,6 +28,28 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: return json.loads(gdf_copy.to_json(drop_id=True)) +def safe_gdf_to_geojson( + gdf: gpd.GeoDataFrame, + *, + to_epsg: int = 4326, + round_ndigits: int = 6, + drop_cols: tuple[str, ...] = (), +) -> dict: + """Project, round, sanitize and serialize GeoDataFrame to GeoJSON. + + Steps: + - Drop unwanted columns (e.g., non-serializable). + - Project to EPSG (default 4326). + - Round geometry coordinates to given precision. + - Ensure all properties are JSON-serializable. + - Return parsed dict (FeatureCollection). + """ + logger.info("Serializing GeoDataFrame to GeoJSON (EPSG:%s, round=%d)", to_epsg, round_ndigits) + gdf2 = gdf.drop(columns=[c for c in drop_cols if c in gdf.columns]).copy() + gdf2 = gdf2.to_crs(to_epsg) + gdf2.geometry = round_coords(gdf2.geometry, round_ndigits) + return json.loads(gdf2.to_json(drop_id=True)) + def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: return gpd.GeoDataFrame.from_features(fc["features"], crs="EPSG:4326") @@ -74,3 +97,15 @@ async def get_best_functional_zones_source( return int(rows["year"].max()), s raise http_exception(404, "No available functional zone sources to choose from") + +def gdf_join_on_block_id(left: gpd.GeoDataFrame, right: pd.DataFrame, how: str = "left") -> gpd.GeoDataFrame: + """Join two frames by block_id index safely. + + - Ensures both indices are int. + - Keeps geometry from the left GeoDataFrame. + """ + gdf = left.copy() + gdf.index = gdf.index.astype(int) + r = right.copy() + r.index = r.index.astype(int) + return gdf.join(r, how=how) diff --git a/app/dependencies.py b/app/dependencies.py index e9e9a2e..d4a71f1 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -9,6 +9,7 @@ from app.common.caching.caching_service import FileCache from app.common.utils.ids_convertation import EffectsUtils from app.effects_api.effects_service import EffectsService +from app.effects_api.modules.land_use_prediction_adapter import LandUsePredictorAdapter from app.effects_api.modules.scenario_service import ScenarioService absolute_app_path = Path().absolute() @@ -28,5 +29,6 @@ urban_api_client = UrbanAPIClient(json_api_handler) file_cache = FileCache() scenario_service = ScenarioService(urban_api_client) -effects_service = EffectsService(urban_api_client, file_cache, scenario_service) +land_use_predictor = LandUsePredictorAdapter() +effects_service = EffectsService(urban_api_client, file_cache, scenario_service, land_use_predictor) effects_utils = EffectsUtils(urban_api_client) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index a3dea3d..e7762b9 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -138,4 +138,16 @@ MAX_EVALS = 25 #Maximum number of runs for optimization -MAX_RUNS = 25 \ No newline at end of file +MAX_RUNS = 25 + +PRED_VALUE_RU = { + "urban": "Жилой или смешанный (бизнес)", + "industrial": "Промышленный", + "non_urban": "Рекреация", +} + +PROB_COLS_EN_TO_RU = { + "prob_urban": "Вероятность жилого или бизнес видов использования, %", + "prob_non_urban": "Вероятность рекреационного вида использования, %", + "prob_industrial": "Вероятность промышленного вида использования, %", +} \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 45d8712..0d6de78 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -32,12 +32,14 @@ build_en_to_ru_map, remap_properties_keys_in_geojson, ) +from .modules.land_use_prediction_adapter import LandUsePredictorAdapter from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, gdf_join_on_block_id, \ + safe_gdf_to_geojson from .constants.const import ( INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES, @@ -73,12 +75,14 @@ def __init__( urban_api_client: UrbanAPIClient, cache: FileCache, scenario_service: ScenarioService, + lu_predictor: LandUsePredictorAdapter ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() self.urban_api_client = urban_api_client self.cache = cache self.scenario = scenario_service + self.lu_predictor = lu_predictor async def build_hash_params( self, @@ -1194,7 +1198,6 @@ async def values_transformation( ) after_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=False) - after_blocks.to_pickle("blocks_for_prediction.pkl") if "block_id" in after_blocks.columns: after_blocks["block_id"] = after_blocks["block_id"].astype(int) if after_blocks.index.name == "block_id": @@ -1304,11 +1307,40 @@ def _fill_cell(x): ] gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] + + try: + lu_pred = self.lu_predictor.predict(after_blocks) + logger.info(f"{lu_pred.columns}") + + keep_cols = ["pred_name", "prob_urban", "prob_non_urban", "prob_industrial"] + lu_pred = lu_pred[keep_cols].copy() + + # gdf_out = gdf_join_on_block_id(gdf_out, lu_pred.reset_index()) + gdf_out = gdf_out.join(lu_pred, how="left") + + logger.info( + "Attached land-use predictions to gdf_out, rows={}, cols={}", + len(lu_pred), keep_cols + ) + except Exception as e: + logger.exception("Failed to attach land-use predictions: {}", e) + gdf_out = gdf_out.to_crs(crs="EPSG:4326") gdf_out.geometry = round_coords(gdf_out.geometry, 6) + geojson = json.loads(gdf_out.to_json()) + service_types = await self.urban_api_client.get_service_types() en2ru = await build_en_to_ru_map(service_types) + + + # en2ru.update({ + # "pred_name": "pred_name", + # "prob_urban": "prob_urban", + # "prob_non_urban": "prob_non_urban", + # "prob_industrial": "prob_industrial", + # }) + geojson = await remap_properties_keys_in_geojson(geojson, en2ru) self.cache.save( @@ -1319,6 +1351,7 @@ def _fill_cell(x): scenario_updated_at=updated_at, ) + logger.info("Values transformed complete (with land-use predictions)") return geojson def _get_value_level(self, provisions: list[float | None]) -> float: diff --git a/app/effects_api/modules/land_use_prediction_adapter.py b/app/effects_api/modules/land_use_prediction_adapter.py new file mode 100644 index 0000000..3225c84 --- /dev/null +++ b/app/effects_api/modules/land_use_prediction_adapter.py @@ -0,0 +1,190 @@ +import logging +from typing import Optional, Sequence +import geopandas as gpd +import numpy as np +import pandas as pd + +from blocksnet.analysis.land_use.prediction import SpatialClassifier + +from app.effects_api.constants.const import PRED_VALUE_RU, PROB_COLS_EN_TO_RU + +logger = logging.getLogger(__name__) + + +class LandUsePredictorAdapter: + """Adapter around SpatialClassifier to produce tidy per-block predictions. + + Responsibilities: + - Normalize CRS to EPSG:3857 if needed (classifier commonly expects planar metric CRS). + - Run prediction once and return a DataFrame keyed by block_id. + - Keep the set of probability columns explicit and stable. + """ + + # какие вероятностные колонки ожидаем от модели + DEFAULT_PROB_COLUMNS: Sequence[str] = ( + "prob_urban", + "prob_non_urban", + "prob_industrial", + ) + + def __init__( + self, + classifier: Optional[SpatialClassifier] = None, + prob_columns: Optional[Sequence[str]] = None, + ) -> None: + """Initialize adapter with a classifier and explicit prob column names.""" + self._clf = classifier or SpatialClassifier.default() + self._prob_columns = tuple(prob_columns or self.DEFAULT_PROB_COLUMNS) + + def _ensure_block_index(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Make sure GeoDataFrame is indexed by integer block_id.""" + if "block_id" in gdf.columns: + gdf["block_id"] = gdf["block_id"].astype(int) + if gdf.index.name == "block_id": + gdf = gdf.reset_index(drop=True) + gdf = ( + gdf.drop_duplicates(subset="block_id", keep="last") + .set_index("block_id") + .sort_index() + ) + else: + gdf.index = gdf.index.astype(int) + gdf = gdf[~gdf.index.duplicated(keep="last")].sort_index() + gdf.index.name = "block_id" + return gdf + + def _normalize_pred_columns(self, df: pd.DataFrame) -> pd.DataFrame: + """Convert Enums/objects to JSON-safe primitives and enforce dtypes.""" + + if "category" in df.columns: + df = df.drop(columns=["category"]) + + if "pred_name" in df.columns: + df["pred_name"] = df["pred_name"].apply( + lambda x: getattr(x, "name", x) if x is not None else None + ).astype("string") + + for c in ("prob_urban", "prob_non_urban", "prob_industrial"): + if c in df.columns: + df[c] = pd.to_numeric(df[c], errors="coerce").astype(float) + + return df + + def predict(self, gdf: gpd.GeoDataFrame) -> pd.DataFrame: + """Run land-use prediction on blocks and return a tidy DataFrame. + + Returns DataFrame indexed by block_id with: + - pred_name (str) + - prob_urban, prob_non_urban, prob_industrial (float) + """ + if self._clf is None: + raise RuntimeError("LandUsePredictorAdapter: classifier is not provided") + + logger.info("Running land-use prediction on base blocks") + + blocks = self._ensure_block_index(gdf) + + y_pred_raw = self._clf.predict(blocks) + proba = None + classes = getattr(self._clf, "classes_", None) + + if hasattr(self._clf, "predict_proba"): + try: + proba = self._clf.predict_proba(blocks) + except Exception as _: + proba = None + + if isinstance(y_pred_raw, pd.Series): + y_pred = y_pred_raw.reindex(blocks.index) + elif isinstance(y_pred_raw, pd.DataFrame): + first_col = y_pred_raw.columns[0] if len(y_pred_raw.columns) else None + y_pred = y_pred_raw[first_col].reindex(blocks.index) if first_col else pd.Series(index=blocks.index, + dtype="object") + else: + y_pred = pd.Series(y_pred_raw, index=blocks.index) + + pred_name = y_pred.apply( + lambda x: str(getattr(x, "name", x)) if x is not None else None + ).astype("string") + + if isinstance(proba, np.ndarray) and proba.ndim == 2 and classes is not None: + proba_df = self._build_proba_df(proba, classes, blocks.index, self._prob_columns) + else: + proba_df = pd.DataFrame(index=blocks.index, columns=self._prob_columns, dtype=float) + for c in self._prob_columns: + proba_df[c] = np.nan + + out = pd.DataFrame({"pred_name": pred_name}, index=blocks.index).join(proba_df) + + out = self._normalize_pred_columns(out) + out.index.name = "block_id" + + logger.info("Land-use prediction finished, rows=%d", len(out)) + return out.sort_index() + + def _predict_land_use_for_blocks(self, after_blocks: gpd.GeoDataFrame) -> pd.DataFrame: + """Run predictor on after_blocks and return normalized predictions.""" + logger.info("Running land-use prediction on base blocks") + blocks = self._ensure_block_index(after_blocks.copy()) + preds = self.predict(blocks) + if not isinstance(preds, (pd.DataFrame, gpd.GeoDataFrame)): + raise ValueError("Predictor must return a DataFrame aligned by block_id") + + preds = preds.copy() + if preds.index.name != "block_id": + if "block_id" in preds.columns: + preds["block_id"] = preds["block_id"].astype(int) + preds = preds.set_index("block_id") + else: + raise ValueError("Predictions must be indexed by 'block_id'") + + preds = self._normalize_pred_columns(preds) + + need_cols = ["pred_name", "prob_urban", "prob_non_urban", "prob_industrial"] + for c in need_cols: + if c not in preds.columns: + preds[c] = np.nan + preds = preds[need_cols].sort_index() + + logger.info("Land-use prediction finished, rows=%d", len(preds)) + return preds + + def _normalize_class_label(self, x) -> str: + """Map raw class label to canonical name.""" + if x is None: + return None + s = str(getattr(x, "name", x)).strip().lower() + if "indust" in s: + return "industrial" + if "non" in s and "urban" in s: + return "non_urban" + if "urb" in s: + return "urban" + return s + + def _build_proba_df( + self, + proba: np.ndarray, + classes: Sequence, + index: pd.Index, + target_prob_cols: Sequence[str] = ("prob_urban", "prob_non_urban", "prob_industrial"), + ) -> pd.DataFrame: + """Build probability DataFrame aligned by block_id with target column names.""" + canonical = [self._normalize_class_label(c) for c in classes] + temp_cols = [f"prob__{c}" for c in canonical] + proba_df = pd.DataFrame(proba, index=index, columns=temp_cols) + + out = pd.DataFrame(index=index) + mapping = { + "urban": "prob_urban", + "non_urban": "prob_non_urban", + "industrial": "prob_industrial", + } + for cls_name, temp_col in zip(canonical, temp_cols): + tgt = mapping.get(cls_name) + if tgt: + out[tgt] = proba_df[temp_col] + for c in target_prob_cols: + if c not in out.columns: + out[c] = np.nan + return out[target_prob_cols] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 233f98b20291301cd0147613628dc1785b2db5d4..12fcf735ab22cf832280143d3fd93f36f08e50e3 100644 GIT binary patch delta 12 TcmX@ddX9C19;3xZ{rOA)9hC$h delta 12 TcmX@ddX9C19;4Al{rOA)9d`s7 From 8753d55e277e11d868776a1c8df5fdd4a696c403 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Wed, 15 Oct 2025 11:59:09 +0300 Subject: [PATCH 100/161] test_commit --- app/system_router/system_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/system_router/system_controller.py b/app/system_router/system_controller.py index 0177892..f43d971 100644 --- a/app/system_router/system_controller.py +++ b/app/system_router/system_controller.py @@ -7,7 +7,7 @@ LOGS_PATH = absolute_app_path / f"{config.get('LOG_NAME')}" system_router = APIRouter(prefix="/system", tags=["System"]) - +#123 # TODO use structlog instead of loguru @system_router.get("/logs") async def get_logs(): From 915572c3f20117169ecd2d2ec28e92c51a116fe3 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 16 Oct 2025 15:47:45 +0300 Subject: [PATCH 101/161] feat(landuse_prediction): 1. Finished Logic for land use prediction --- app/common/utils/geodata.py | 20 ++ app/dependencies.py | 4 +- app/effects_api/constants/const.py | 6 +- app/effects_api/effects_service.py | 72 ++++--- .../modules/land_use_prediction_adapter.py | 190 ------------------ 5 files changed, 71 insertions(+), 221 deletions(-) delete mode 100644 app/effects_api/modules/land_use_prediction_adapter.py diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 8c789ce..932ac9f 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -109,3 +109,23 @@ def gdf_join_on_block_id(left: gpd.GeoDataFrame, right: pd.DataFrame, how: str = r = right.copy() r.index = r.index.astype(int) return gdf.join(r, how=how) + + +def _ensure_block_index(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: + """Ensure index is integer 'block_id'.""" + if "block_id" in gdf.columns: + gdf = gdf.copy() + gdf["block_id"] = gdf["block_id"].astype(int) + if gdf.index.name == "block_id": + gdf = gdf.reset_index(drop=True) + gdf = ( + gdf.drop_duplicates(subset="block_id", keep="last") + .set_index("block_id") + .sort_index() + ) + else: + gdf = gdf.copy() + gdf.index = gdf.index.astype(int) + gdf = gdf[~gdf.index.duplicated(keep="last")].sort_index() + gdf.index.name = "block_id" + return gdf \ No newline at end of file diff --git a/app/dependencies.py b/app/dependencies.py index d4a71f1..e9e9a2e 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -9,7 +9,6 @@ from app.common.caching.caching_service import FileCache from app.common.utils.ids_convertation import EffectsUtils from app.effects_api.effects_service import EffectsService -from app.effects_api.modules.land_use_prediction_adapter import LandUsePredictorAdapter from app.effects_api.modules.scenario_service import ScenarioService absolute_app_path = Path().absolute() @@ -29,6 +28,5 @@ urban_api_client = UrbanAPIClient(json_api_handler) file_cache = FileCache() scenario_service = ScenarioService(urban_api_client) -land_use_predictor = LandUsePredictorAdapter() -effects_service = EffectsService(urban_api_client, file_cache, scenario_service, land_use_predictor) +effects_service = EffectsService(urban_api_client, file_cache, scenario_service) effects_utils = EffectsUtils(urban_api_client) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index e7762b9..c811794 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -135,10 +135,10 @@ WATER_ID = 4 #Maximum number of function evaluations -MAX_EVALS = 25 +MAX_EVALS = 1000 #Maximum number of runs for optimization -MAX_RUNS = 25 +MAX_RUNS = 1000 PRED_VALUE_RU = { "urban": "Жилой или смешанный (бизнес)", @@ -150,4 +150,4 @@ "prob_urban": "Вероятность жилого или бизнес видов использования, %", "prob_non_urban": "Вероятность рекреационного вида использования, %", "prob_industrial": "Вероятность промышленного вида использования, %", -} \ No newline at end of file +} diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 0d6de78..73dce03 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -5,6 +5,7 @@ import numpy as np import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators +from blocksnet.analysis.land_use.prediction import SpatialClassifier from blocksnet.analysis.provision import competitive_provision, provision_strong_total from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use @@ -32,19 +33,19 @@ build_en_to_ru_map, remap_properties_keys_in_geojson, ) -from .modules.land_use_prediction_adapter import LandUsePredictorAdapter from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, gdf_join_on_block_id, \ - safe_gdf_to_geojson +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, _ensure_block_index from .constants.const import ( INFRASTRUCTURES_WEIGHTS, LAND_USE_RULES, MAX_EVALS, MAX_RUNS, + PRED_VALUE_RU, + PROB_COLS_EN_TO_RU, ) from .dto.development_dto import ( ContextDevelopmentDTO, @@ -75,14 +76,12 @@ def __init__( urban_api_client: UrbanAPIClient, cache: FileCache, scenario_service: ScenarioService, - lu_predictor: LandUsePredictorAdapter ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() self.urban_api_client = urban_api_client self.cache = cache self.scenario = scenario_service - self.lu_predictor = lu_predictor async def build_hash_params( self, @@ -1026,7 +1025,7 @@ async def territory_transformation_scenario_after( num_params=facade.num_params, facade=facade, weights=services_weights, - max_evals=1000, + max_evals=MAX_EVALS, ) constraints = WeightedConstraints(num_params=facade.num_params, facade=facade) tpe_optimizer = TPEOptimizer( @@ -1036,7 +1035,7 @@ async def territory_transformation_scenario_after( ) best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=1000, timeout=4*60, initial_runs_num=1 + max_runs=MAX_RUNS, timeout=4*60, initial_runs_num=1 ) prov_gdfs_after = {} @@ -1309,21 +1308,52 @@ def _fill_cell(x): gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] try: - lu_pred = self.lu_predictor.predict(after_blocks) - logger.info(f"{lu_pred.columns}") + logger.info("Running land-use prediction on 'after_blocks'") + + ab = after_blocks[after_blocks.geometry.notna() & ~after_blocks.geometry.is_empty].copy() + ab.geometry = ab.geometry.buffer(0) + + try: + utm_crs = ab.estimate_utm_crs() + ab = ab.to_crs(utm_crs) + except Exception: + ab = ab.to_crs("EPSG:3857") + + clf = SpatialClassifier.default() + lu = clf.run(ab) + + lu = lu.drop(columns=["category"], errors="ignore") keep_cols = ["pred_name", "prob_urban", "prob_non_urban", "prob_industrial"] - lu_pred = lu_pred[keep_cols].copy() + for c in keep_cols: + if c not in lu.columns: + lu[c] = np.nan + lu = lu[keep_cols] + + lu = _ensure_block_index(lu) + gdf_out = _ensure_block_index(gdf_out) + gdf_out = gdf_out.join(lu, how="left") + + logger.info("Attached land-use predictions to gdf_out (cols: {})", keep_cols) + + if "pred_name" in gdf_out.columns: + gdf_out["Предсказанный вид использования"] = ( + gdf_out["pred_name"] + .str.lower() + .map(PRED_VALUE_RU) + .fillna(gdf_out["pred_name"]) + ) + gdf_out = gdf_out.drop(columns=["pred_name"]) - # gdf_out = gdf_join_on_block_id(gdf_out, lu_pred.reset_index()) - gdf_out = gdf_out.join(lu_pred, how="left") + prob_cols = [c for c in ["prob_urban", "prob_non_urban", "prob_industrial"] if c in gdf_out.columns] + for col in prob_cols: + gdf_out[col] = gdf_out[col].astype(float).round(1) + + rename_map = {k: v for k, v in PROB_COLS_EN_TO_RU.items() if k in gdf_out.columns} + gdf_out = gdf_out.rename(columns=rename_map) - logger.info( - "Attached land-use predictions to gdf_out, rows={}, cols={}", - len(lu_pred), keep_cols - ) except Exception as e: - logger.exception("Failed to attach land-use predictions: {}", e) + raise http_exception(500, "Failed to attach land-use predictions: {}", e) gdf_out = gdf_out.to_crs(crs="EPSG:4326") gdf_out.geometry = round_coords(gdf_out.geometry, 6) @@ -1333,14 +1363,6 @@ def _fill_cell(x): service_types = await self.urban_api_client.get_service_types() en2ru = await build_en_to_ru_map(service_types) - - # en2ru.update({ - # "pred_name": "pred_name", - # "prob_urban": "prob_urban", - # "prob_non_urban": "prob_non_urban", - # "prob_industrial": "prob_industrial", - # }) - geojson = await remap_properties_keys_in_geojson(geojson, en2ru) self.cache.save( diff --git a/app/effects_api/modules/land_use_prediction_adapter.py b/app/effects_api/modules/land_use_prediction_adapter.py deleted file mode 100644 index 3225c84..0000000 --- a/app/effects_api/modules/land_use_prediction_adapter.py +++ /dev/null @@ -1,190 +0,0 @@ -import logging -from typing import Optional, Sequence -import geopandas as gpd -import numpy as np -import pandas as pd - -from blocksnet.analysis.land_use.prediction import SpatialClassifier - -from app.effects_api.constants.const import PRED_VALUE_RU, PROB_COLS_EN_TO_RU - -logger = logging.getLogger(__name__) - - -class LandUsePredictorAdapter: - """Adapter around SpatialClassifier to produce tidy per-block predictions. - - Responsibilities: - - Normalize CRS to EPSG:3857 if needed (classifier commonly expects planar metric CRS). - - Run prediction once and return a DataFrame keyed by block_id. - - Keep the set of probability columns explicit and stable. - """ - - # какие вероятностные колонки ожидаем от модели - DEFAULT_PROB_COLUMNS: Sequence[str] = ( - "prob_urban", - "prob_non_urban", - "prob_industrial", - ) - - def __init__( - self, - classifier: Optional[SpatialClassifier] = None, - prob_columns: Optional[Sequence[str]] = None, - ) -> None: - """Initialize adapter with a classifier and explicit prob column names.""" - self._clf = classifier or SpatialClassifier.default() - self._prob_columns = tuple(prob_columns or self.DEFAULT_PROB_COLUMNS) - - def _ensure_block_index(self, gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: - """Make sure GeoDataFrame is indexed by integer block_id.""" - if "block_id" in gdf.columns: - gdf["block_id"] = gdf["block_id"].astype(int) - if gdf.index.name == "block_id": - gdf = gdf.reset_index(drop=True) - gdf = ( - gdf.drop_duplicates(subset="block_id", keep="last") - .set_index("block_id") - .sort_index() - ) - else: - gdf.index = gdf.index.astype(int) - gdf = gdf[~gdf.index.duplicated(keep="last")].sort_index() - gdf.index.name = "block_id" - return gdf - - def _normalize_pred_columns(self, df: pd.DataFrame) -> pd.DataFrame: - """Convert Enums/objects to JSON-safe primitives and enforce dtypes.""" - - if "category" in df.columns: - df = df.drop(columns=["category"]) - - if "pred_name" in df.columns: - df["pred_name"] = df["pred_name"].apply( - lambda x: getattr(x, "name", x) if x is not None else None - ).astype("string") - - for c in ("prob_urban", "prob_non_urban", "prob_industrial"): - if c in df.columns: - df[c] = pd.to_numeric(df[c], errors="coerce").astype(float) - - return df - - def predict(self, gdf: gpd.GeoDataFrame) -> pd.DataFrame: - """Run land-use prediction on blocks and return a tidy DataFrame. - - Returns DataFrame indexed by block_id with: - - pred_name (str) - - prob_urban, prob_non_urban, prob_industrial (float) - """ - if self._clf is None: - raise RuntimeError("LandUsePredictorAdapter: classifier is not provided") - - logger.info("Running land-use prediction on base blocks") - - blocks = self._ensure_block_index(gdf) - - y_pred_raw = self._clf.predict(blocks) - proba = None - classes = getattr(self._clf, "classes_", None) - - if hasattr(self._clf, "predict_proba"): - try: - proba = self._clf.predict_proba(blocks) - except Exception as _: - proba = None - - if isinstance(y_pred_raw, pd.Series): - y_pred = y_pred_raw.reindex(blocks.index) - elif isinstance(y_pred_raw, pd.DataFrame): - first_col = y_pred_raw.columns[0] if len(y_pred_raw.columns) else None - y_pred = y_pred_raw[first_col].reindex(blocks.index) if first_col else pd.Series(index=blocks.index, - dtype="object") - else: - y_pred = pd.Series(y_pred_raw, index=blocks.index) - - pred_name = y_pred.apply( - lambda x: str(getattr(x, "name", x)) if x is not None else None - ).astype("string") - - if isinstance(proba, np.ndarray) and proba.ndim == 2 and classes is not None: - proba_df = self._build_proba_df(proba, classes, blocks.index, self._prob_columns) - else: - proba_df = pd.DataFrame(index=blocks.index, columns=self._prob_columns, dtype=float) - for c in self._prob_columns: - proba_df[c] = np.nan - - out = pd.DataFrame({"pred_name": pred_name}, index=blocks.index).join(proba_df) - - out = self._normalize_pred_columns(out) - out.index.name = "block_id" - - logger.info("Land-use prediction finished, rows=%d", len(out)) - return out.sort_index() - - def _predict_land_use_for_blocks(self, after_blocks: gpd.GeoDataFrame) -> pd.DataFrame: - """Run predictor on after_blocks and return normalized predictions.""" - logger.info("Running land-use prediction on base blocks") - blocks = self._ensure_block_index(after_blocks.copy()) - preds = self.predict(blocks) - if not isinstance(preds, (pd.DataFrame, gpd.GeoDataFrame)): - raise ValueError("Predictor must return a DataFrame aligned by block_id") - - preds = preds.copy() - if preds.index.name != "block_id": - if "block_id" in preds.columns: - preds["block_id"] = preds["block_id"].astype(int) - preds = preds.set_index("block_id") - else: - raise ValueError("Predictions must be indexed by 'block_id'") - - preds = self._normalize_pred_columns(preds) - - need_cols = ["pred_name", "prob_urban", "prob_non_urban", "prob_industrial"] - for c in need_cols: - if c not in preds.columns: - preds[c] = np.nan - preds = preds[need_cols].sort_index() - - logger.info("Land-use prediction finished, rows=%d", len(preds)) - return preds - - def _normalize_class_label(self, x) -> str: - """Map raw class label to canonical name.""" - if x is None: - return None - s = str(getattr(x, "name", x)).strip().lower() - if "indust" in s: - return "industrial" - if "non" in s and "urban" in s: - return "non_urban" - if "urb" in s: - return "urban" - return s - - def _build_proba_df( - self, - proba: np.ndarray, - classes: Sequence, - index: pd.Index, - target_prob_cols: Sequence[str] = ("prob_urban", "prob_non_urban", "prob_industrial"), - ) -> pd.DataFrame: - """Build probability DataFrame aligned by block_id with target column names.""" - canonical = [self._normalize_class_label(c) for c in classes] - temp_cols = [f"prob__{c}" for c in canonical] - proba_df = pd.DataFrame(proba, index=index, columns=temp_cols) - - out = pd.DataFrame(index=index) - mapping = { - "urban": "prob_urban", - "non_urban": "prob_non_urban", - "industrial": "prob_industrial", - } - for cls_name, temp_col in zip(canonical, temp_cols): - tgt = mapping.get(cls_name) - if tgt: - out[tgt] = proba_df[temp_col] - for c in target_prob_cols: - if c not in out.columns: - out[c] = np.nan - return out[target_prob_cols] \ No newline at end of file From b7a2271538fb377cbb1db6a5fa1d6f01acac8675 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 16 Oct 2025 18:47:23 +0300 Subject: [PATCH 102/161] fix(requirements): 1. Fixed requirements.txt --- requirements.txt | Bin 722 -> 722 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 8f9618e6d94d9f5d73e13dee7c44ffa4b2658b66..d511d2a1f3b7a612bd71a9ecb9f780e1849af058 100644 GIT binary patch delta 40 ocmcb_dWm&{m~0wDDMJnr#xm3~*fJP0=rI^TaUz4oM&tQR0KoPLb^rhX delta 40 qcmcb_dWm&{m~1XX4nr(M9fK`{A%h-+0Tib&q%xE-7;ZG4&jbLvI|y?C From 2d0090ad582eaa580010652ec930c14ae7899396 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 16 Oct 2025 18:58:23 +0300 Subject: [PATCH 103/161] fix(requirements): 1. Fixed requirements.txt --- requirements.txt | Bin 718 -> 722 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/requirements.txt b/requirements.txt index 12fcf735ab22cf832280143d3fd93f36f08e50e3..d511d2a1f3b7a612bd71a9ecb9f780e1849af058 100644 GIT binary patch delta 16 XcmX@ddWm&{7;73sDMQXi@$F0iE;FrDa9vTEh From 0a89608e55b3d9ac52c2fbfe41f8aa0927200be7 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 20 Oct 2025 17:49:06 +0300 Subject: [PATCH 104/161] feat(f_22): new f 22 --- app/effects_api/constants/const.py | 24 ++++++++++++++++++++++++ requirements.txt | Bin 722 -> 722 bytes 2 files changed, 24 insertions(+) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index c811794..77d368a 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -1,3 +1,5 @@ +from blocksnet.analysis.indicators.socio_economic import GeneralIndicator, DemographicIndicator, TransportIndicator, \ + EngineeringIndicator, SocialCountIndicator, SocialProvisionIndicator, SocialIndicator from blocksnet.enums import LandUse # UrbanDB to Blocksnet land use types mapping @@ -151,3 +153,25 @@ "prob_non_urban": "Вероятность рекреационного вида использования, %", "prob_industrial": "Вероятность промышленного вида использования, %", } + +SOCIAL_INDICATORS_MAPPING = { + SocialIndicator.EXTRACURRICULAR: [23, 24], + SocialIndicator.AMBULANCE: [39, 40], + SocialIndicator.SPECIAL_MEDICAL: [41], + SocialIndicator.PREVENTIVE_MEDICAL: [42], + SocialIndicator.GYM: [68], + SocialIndicator.ORPHANAGE: [46], + SocialIndicator.SOCIAL_SERVICE_CENTER: [43], + SocialIndicator.CULTURAL_CENTER: [49], + SocialIndicator.CONCERT_HALL: [53], + SocialIndicator.ICE_ARENA: [60], + SocialIndicator.ECO_TRAIL: [72], + SocialIndicator.FIRE_STATION: [79], + SocialIndicator.TOURIST_BASE: [112], +} + +INDICATORS_MAPPING = { + GeneralIndicator.AREA : 1, + GeneralIndicator.URBANIZATION : 123, + SocialCountIndicator.BANK : 246, +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index d511d2a1f3b7a612bd71a9ecb9f780e1849af058..bc651874f5a0c64f4c8e5a50508c005c2d004846 100644 GIT binary patch delta 12 Tcmcb_dWm&{5u@cs Date: Wed, 22 Oct 2025 22:34:11 +0300 Subject: [PATCH 105/161] fat(f_22): 1. Working logic for F 22 --- app/clients/urban_api_client.py | 6 + app/common/utils/ids_convertation.py | 43 +++ app/dependencies.py | 2 +- app/effects_api/constants/const.py | 272 +++++++++++++----- app/effects_api/effects_controller.py | 9 + app/effects_api/effects_service.py | 195 ++++++++++++- app/effects_api/modules/context_service.py | 12 + .../modules/service_type_service.py | 268 ++++++++++++++++- app/main.py | 2 +- 9 files changed, 732 insertions(+), 77 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index ae3b8c1..d3da34e 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -401,3 +401,9 @@ async def get_project_scenarios( async def get_social_values_info(self) -> dict[int, str]: res = await self.json_handler.get("/api/v1/social_values") return {item["soc_value_id"]: item["name"] for item in res} + + async def get_territory_normatives(self, territory_id: int): + res = await self.json_handler.get(f'/api/v1/territory/{territory_id}/normatives', params={'last_only': True}) + df = pd.DataFrame(res) + df['service_type_id'] = df['service_type'].apply(lambda st: st['id']) + return df.set_index('service_type_id', drop=True) diff --git a/app/common/utils/ids_convertation.py b/app/common/utils/ids_convertation.py index 0d63073..8ae2e09 100644 --- a/app/common/utils/ids_convertation.py +++ b/app/common/utils/ids_convertation.py @@ -1,5 +1,10 @@ +import re from typing import Any, Dict, Optional +import pandas as pd +from blocksnet.enums import LandUse +from loguru import logger + from app.clients.urban_api_client import UrbanAPIClient @@ -55,3 +60,41 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: reverse=True, ) return self._sid(matches[0]) or scenario_id + + def coerce_land_use_enum(self, df: pd.DataFrame, col: str = "land_use") -> pd.DataFrame: + """ + Normalize 'land_use' column to LandUse enum: + - Accept LandUse enum → keep + - Accept 'LandUse.NAME' → strip prefix, use NAME + - Accept 'name' values → use by value ('residential', ...) + - Accept 'NAME' values → use by name ('RESIDENTIAL', ...) + - None/NaN → keep None + Unknown values → None + """ + if col not in df.columns: + return df + + def _to_enum(v): + if v is None or (isinstance(v, float) and pd.isna(v)): + return None + if isinstance(v, LandUse): + return v + if isinstance(v, str): + s = v.strip() + m = re.match(r"^(?:LandUse\.)?([A-Za-z_]+)$", s) + if m: + key = m.group(1) + try: + return LandUse[key.upper()] + except KeyError: + pass + try: + return LandUse(key.lower()) + except ValueError: + logger.warning("Unknown land_use value: %r -> set to None", v) + return None + logger.warning("Unsupported land_use type: %r -> set to None", type(v).__name__) + return None + + df[col] = df[col].map(_to_enum) + return df diff --git a/app/dependencies.py b/app/dependencies.py index c2ca21e..e67478f 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -29,4 +29,4 @@ file_cache = FileCache() scenario_service = ScenarioService(urban_api_client) effects_utils = EffectsUtils(urban_api_client) -effects_service = EffectsService(urban_api_client, file_cache, scenario_service) +effects_service = EffectsService(urban_api_client, file_cache, scenario_service, effects_utils) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index 77d368a..fde85b7 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -1,5 +1,6 @@ from blocksnet.analysis.indicators.socio_economic import GeneralIndicator, DemographicIndicator, TransportIndicator, \ - EngineeringIndicator, SocialCountIndicator, SocialProvisionIndicator, SocialIndicator + EngineeringIndicator, SocialCountIndicator, SocialProvisionIndicator, SocialIndicator, EconomicIndicator, \ + EcologicalIndicator, SettlementIndicator from blocksnet.enums import LandUse # UrbanDB to Blocksnet land use types mapping @@ -20,70 +21,96 @@ # TODO add map autogeneration SERVICE_TYPES_MAPPING = { - # basic - 1: "park", - 21: "kindergarten", - 22: "school", - 28: "polyclinic", - 34: "pharmacy", - 61: "cafe", - 66: "pitch", - 68: None, # спортивный зал - 74: "playground", - 78: "police", - # additional - 30: None, # стоматология - 35: "hospital", - 50: "museum", - 56: "cinema", - 57: "mall", - 59: "stadium", - 62: "restaurant", - 63: "bar", - 77: None, # скейт парк - 79: None, # пожарная станция - 80: "train_station", - 89: "supermarket", - 99: None, # пункт выдачи - 100: "bank", - 107: "veterinary", - 143: "sanatorium", - # comfort - 5: "beach", - 27: "university", - 36: None, # роддом - 48: "library", - 51: "theatre", - 91: "market", - 93: None, # одежда и обувь - 94: None, # бытовая техника - 95: None, # книжный магазин - 96: None, # детские товары - 97: None, # спортивный магазин - 108: None, # зоомагазин - 110: "hotel", - 114: "religion", # религиозный объект - # others - 26: None, # ССУЗ - 32: None, # женская консультация - 39: None, # скорая помощь - 40: None, # травматология - 45: "recruitment", - 47: "multifunctional_center", - 55: "zoo", - 65: "bakery", - 67: "swimming_pool", - 75: None, # парк аттракционов - 81: "train_building", - 82: "aeroway_terminal", # аэропорт?? - 86: "bus_station", - 88: "subway_entrance", - 102: "lawyer", - 103: "notary", - 109: "dog_park", - 111: "hostel", - 112: None, # база отдыха - 113: None, # памятник + 1: 'park', + 5 : 'beach', + 21: 'kindergarten', + 22: 'school', + 23 : None, # доп образование + 24 : None, # доп образование + 26 : 'college', + 27 : 'university', + 28 : 'polyclinic', + 29 : None, # детская поликлиника + 30 : None, # стоматология + 31 : None, # фельдшерско-акушерский пункт + 32 : None, # женская консультация + 34 : 'pharmacy', + 35 : 'hospital', + 36 : None, # роддом + 37 : None, # детская больница + 38 : None, # хоспис + 39 : None, # скорая помощь + 40 : None, # травматология + 41 : None, # морг + 42 : None, # диспансер + 43 : None, # центры соц обслуживания + 44 : 'social_facility', # дом престарелых + 45 : 'recruitment', + 46 : None, # детский дом + 47 : 'multifunctional_center', + 48 : 'library', + 49 : None, # дворцы культуры + 50 : 'museum', + 51 : 'theatre', + 53 : None, # концертный зал + 55 : 'zoo', + 56 : 'cinema', + 57 : 'mall', + 59 : 'stadium', + 60 : None, # ледовая арена + 61 : 'cafe', + 62 : 'restaurant', + 63 : 'bar', + 64 : 'cafe', + 65 : 'bakery', + 66 : 'pitch', + 67 : 'swimming_pool', + 68 : None, # спортивный зал + 69 : None, # каток + 70 : None, # футбольное поле + 72 : None, # эко тропа + 74 : 'playground', + 75 : None, # парк аттракционов + 77 : None, # скейт парк + 78 : 'police', + 79 : None, # пожарная станция + 80 : 'train_station', + 81 : 'train_building', + 82 : 'aeroway_terminal', + 84 : 'fuel', + 86 : 'bus_station', + 88 : 'subway_entrance', + 89 : 'supermarket', + 91 : 'market', + 93 : None, # одежда и обувь + 94 : None, # бытовая техника + 95 : None, # книжный магазин + 96 : None, # детские товары + 97 : None, # спортивный магазин + 98 : 'post', + 99 : None, # пункт выдачи + 100 : 'bank', + 102 : 'lawyer', + 103 : 'notary', + 107 : 'veterinary', + 108 : None, # зоомагазин + 109 : 'dog_park', + 110 : 'hotel', + 111 : 'hostel', + 112 : None, # база отдыха + 113 : None, # памятник + 114 : 'religion', # религиозный объект + # электростанции -- start + 118 : 'substation', # Атомная электростанция + 119 : 'substation', # Гидро-электростанция + 120 : 'substation', # Тепловая электростанция + # электростанции -- end + 124 : 'water_works', + # водоочистные сооружения -- start + 126 : 'wastewater_plant', # Сооружения для очистки воды + 128 : 'wastewater_plant', # Водоочистные сооружения + # водоочистные сооружения -- end + 143 : 'sanatorium', } # Rules for agregating building properties from UrbanDB API @@ -171,7 +198,116 @@ } INDICATORS_MAPPING = { - GeneralIndicator.AREA : 1, - GeneralIndicator.URBANIZATION : 123, - SocialCountIndicator.BANK : 246, + # общие + GeneralIndicator.AREA : 4, + GeneralIndicator.URBANIZATION : 16, + # демография + DemographicIndicator.POPULATION: 1, + DemographicIndicator.DENSITY: 37, + # транспорт + TransportIndicator.ROAD_NETWORK_DENSITY: 60, + TransportIndicator.SETTLEMENTS_CONNECTIVITY: 59, + TransportIndicator.ROAD_NETWORK_LENGTH: 65, + TransportIndicator.FUEL_STATIONS_COUNT: 71, + TransportIndicator.AVERAGE_FUEL_STATION_ACCESSIBILITY: 72, + TransportIndicator.RAILWAY_STOPS_COUNT: 75, + TransportIndicator.AVERAGE_RAILWAY_STOP_ACCESSIBILITY: 76, + TransportIndicator.AIRPORTS_COUNT: 78, + TransportIndicator.AVERAGE_AIRPORT_ACCESSIBILITY: None, # TODO Средняя доступность аэропортов (без разделения на международные и местные) + # инженерная инфраструктура + EngineeringIndicator.INFRASTRUCTURE_OBJECT: 88, + EngineeringIndicator.SUBSTATION: 89, + EngineeringIndicator.WATER_WORKS: 90, + EngineeringIndicator.WASTEWATER_PLANT: 91, + EngineeringIndicator.RESERVOIR: 92, + EngineeringIndicator.GAS_DISTRIBUTION: 93, + # социальная инфраструктура + # образование + SocialCountIndicator.KINDERGARTEN: 309, + SocialProvisionIndicator.KINDERGARTEN: 207, # Обеспеченность детскими садами + SocialCountIndicator.SCHOOL: 338, + SocialProvisionIndicator.SCHOOL: 208, # Обеспеченность школами + SocialCountIndicator.COLLEGE: 310, + SocialProvisionIndicator.COLLEGE: None, # FIXME Обеспеченность образовательными учреждениями СПО (нет их) + SocialCountIndicator.UNIVERSITY: 311, + SocialProvisionIndicator.UNIVERSITY: 350, + SocialCountIndicator.EXTRACURRICULAR: None, # FIXME Организации дополнительного образования детей (нет их) + SocialProvisionIndicator.EXTRACURRICULAR: None, # FIXME Обеспеченность организациями дополнительного образования детей (нет их) + # здравоохранение + SocialCountIndicator.HOSPITAL: 341, + SocialProvisionIndicator.HOSPITAL: 361, # Обеспеченность больницами + SocialCountIndicator.POLYCLINIC: 342, + SocialProvisionIndicator.POLYCLINIC: 362, # Обеспеченность поликлиниками + SocialCountIndicator.AMBULANCE: 343, + SocialProvisionIndicator.AMBULANCE: None, # FIXME Обеспеченность объектами скорой медицинской помощи + SocialCountIndicator.SANATORIUM: 312, + SocialProvisionIndicator.SANATORIUM: None, # FIXME Обеспеченность объектами санаторного назначения + SocialCountIndicator.SPECIAL_MEDICAL: None, # FIXME Медицинские учреждения особого типа + SocialProvisionIndicator.SPECIAL_MEDICAL: None, # FIXME Обеспеченность медицинскими учреждениями особого типа + SocialCountIndicator.PREVENTIVE_MEDICAL: 346, + SocialProvisionIndicator.PREVENTIVE_MEDICAL: None, # FIXME Обеспеченность лечебно-профилактическими медицинскими учреждениями + SocialCountIndicator.PHARMACY: 345, + SocialProvisionIndicator.PHARMACY: 213, # Обеспеченность аптеками + # спорт + SocialCountIndicator.GYM: 313, + SocialProvisionIndicator.GYM: 243, # Обеспеченность спортзалами ОП / фитнес-центрами + SocialCountIndicator.SWIMMING_POOL: 314, + SocialProvisionIndicator.SWIMMING_POOL: 245, # Обеспеченность ФОК / бассейнами + SocialCountIndicator.PITCH: 340, + SocialProvisionIndicator.PITCH: 357, + SocialCountIndicator.STADIUM: 315, + SocialProvisionIndicator.STADIUM: 356, + # социальная помощь + SocialCountIndicator.ORPHANAGE: 316, + SocialProvisionIndicator.ORPHANAGE: None, # FIXME Обеспеченность детскими домами-интернатами + SocialCountIndicator.SOCIAL_FACILITY: 317, + SocialProvisionIndicator.SOCIAL_FACILITY: None, # FIXME Обеспеченность домами престарелых + SocialCountIndicator.SOCIAL_SERVICE_CENTER: 318, + SocialProvisionIndicator.SOCIAL_SERVICE_CENTER: None, # FIXME Обеспеченность центрами социального обслуживания + # услуги + SocialCountIndicator.POST: 319, + SocialProvisionIndicator.POST: 247, # Обеспеченность пунктами доставки / почтовыми отделениями + SocialCountIndicator.BANK: 320, + SocialProvisionIndicator.BANK: 250, # Обеспеченность отделениями банков + SocialCountIndicator.MULTIFUNCTIONAL_CENTER: 321, + SocialProvisionIndicator.MULTIFUNCTIONAL_CENTER: 351, + # культура и отдых + SocialCountIndicator.LIBRARY : 322, + SocialProvisionIndicator.LIBRARY : 232, # Обеспеченность медиатеками / библиотеками + SocialCountIndicator.MUSEUM : 323, + SocialProvisionIndicator.MUSEUM : 352, + SocialCountIndicator.THEATRE : 324, + SocialProvisionIndicator.THEATRE : 353, + SocialCountIndicator.CULTURAL_CENTER : 325, + SocialProvisionIndicator.CULTURAL_CENTER : 231, # Обеспеченность комьюнити-центрами / домами культуры + SocialCountIndicator.CINEMA : 326, + SocialProvisionIndicator.CINEMA : 354, + SocialCountIndicator.CONCERT_HALL : 327, + SocialProvisionIndicator.CONCERT_HALL : None, # FIXME Обеспеченность концертными залами + # SocialCountIndicator.STADIUM : 315, ПОВТОР + # SocialProvisionIndicator.STADIUM : 356, ПОВТОР + SocialCountIndicator.ICE_ARENA : 328, + SocialProvisionIndicator.ICE_ARENA : None, # FIXME Обеспеченность ледовыми аренами + SocialCountIndicator.MALL : 329, + SocialProvisionIndicator.MALL : 355, + SocialCountIndicator.PARK : 330, + SocialProvisionIndicator.PARK : 238, # Обеспеченность парками + SocialCountIndicator.BEACH : 331, + SocialProvisionIndicator.BEACH : None, # FIXME Обеспеченность пляжами + SocialCountIndicator.ECO_TRAIL : 332, + SocialProvisionIndicator.ECO_TRAIL : None, # FIXME Обеспеченность экологическими тропами + # безопасность + SocialCountIndicator.FIRE_STATION: 333, + SocialProvisionIndicator.FIRE_STATION: 260, # Обеспеченность пожарными депо + SocialCountIndicator.POLICE: 334, + SocialProvisionIndicator.POLICE: 258, # Обеспеченность пунктами полиции + # туризм + SocialCountIndicator.HOTEL: 335, + SocialProvisionIndicator.HOTEL: 358, + SocialCountIndicator.HOSTEL: 336, + SocialProvisionIndicator.HOSTEL: 359, + SocialCountIndicator.TOURIST_BASE: 337, + SocialProvisionIndicator.TOURIST_BASE: 360, + SocialCountIndicator.CATERING: 344, + SocialProvisionIndicator.CATERING: 226 # Обеспеченность кафе / кофейнями } \ No newline at end of file diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 52c176e..ffaa406 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -91,3 +91,12 @@ async def get_socio_economic_prediction( # token: str = Depends(verify_token), # ): # return await effects_service.values_oriented_requirements(token, params) + +@f_22_router.get( + "/scenario_f_22" +) +async def get_socio_economic_prediction_new( + params: Annotated[SocioEconomicByScenarioDTO, Depends(SocioEconomicByScenarioDTO)], + token: str = Depends(verify_token), +): + return await effects_service.evaluate_social_economical_metrics(params, token) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 73dce03..0226fca 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -5,10 +5,12 @@ import numpy as np import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators +from blocksnet.analysis.indicators.socio_economic import calculate_general_indicators, calculate_demographic_indicators, \ + calculate_transport_indicators, calculate_engineering_indicators, calculate_social_indicators from blocksnet.analysis.land_use.prediction import SpatialClassifier from blocksnet.analysis.provision import competitive_provision, provision_strong_total from blocksnet.blocks.aggregation import aggregate_objects -from blocksnet.blocks.assignment import assign_land_use +from blocksnet.blocks.assignment import assign_land_use, assign_objects from blocksnet.config import service_types_config from blocksnet.enums import LandUse from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor @@ -23,7 +25,7 @@ calculate_accessibility_matrix, generate_adjacency_graph, get_accessibility_context, - get_accessibility_graph, + get_accessibility_graph, calculate_distance_matrix, ) from loguru import logger @@ -31,7 +33,7 @@ from app.effects_api.modules.service_type_service import ( adapt_service_types, build_en_to_ru_map, - remap_properties_keys_in_geojson, + remap_properties_keys_in_geojson, adapt_social_service_types_df, generate_blocksnet_columns, ensure_missing_id_and_name_columns, ) from ..clients.urban_api_client import UrbanAPIClient @@ -45,7 +47,7 @@ MAX_EVALS, MAX_RUNS, PRED_VALUE_RU, - PROB_COLS_EN_TO_RU, + PROB_COLS_EN_TO_RU, SOCIAL_INDICATORS_MAPPING, ROADS_ID, INDICATORS_MAPPING, ) from .dto.development_dto import ( ContextDevelopmentDTO, @@ -61,13 +63,14 @@ get_context_blocks, get_context_buildings, get_context_functional_zones, - get_context_services, + get_context_services, get_context_territories, ) from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import ( SocioEconomicResponseSchema, SocioEconomicSchema, ) +from ..common.utils.ids_convertation import EffectsUtils class EffectsService: @@ -76,12 +79,14 @@ def __init__( urban_api_client: UrbanAPIClient, cache: FileCache, scenario_service: ScenarioService, + effects_utils: EffectsUtils ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() self.urban_api_client = urban_api_client self.cache = cache self.scenario = scenario_service + self.effects_utils = effects_utils async def build_hash_params( self, @@ -1633,3 +1638,183 @@ def _result_to_df(payload: Any) -> pd.DataFrame: ) return result_df + + async def evaluate_social_economical_metrics( + self, + params: SocioEconomicByScenarioDTO, + token: str + + ): + method_name = "territory_transformation" + + # info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) + # updated_at = info["updated_at"] + # project_id = info["project"]["project_id"] + # base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) + # + # params = await self.get_optimal_func_zone_data(params, token) + # + # params_for_hash = await self.build_hash_params(params, token) + # phash = self.cache.params_hash(params_for_hash) + # + # force = getattr(params, "force", False) + # cached = ( + # None if force else self.cache.load(method_name, params.scenario_id, phash) + # ) + # if ( + # cached + # and cached["meta"]["scenario_updated_at"] == updated_at + # and "before" in cached["data"] + # ): + # return { + # n: fc_to_gdf(fc) + # for n, fc in cached["data"]["before"].items() + # if is_fc(fc) + # } + # + # logger.info("Cache stale, missing or forced: calculating BEFORE") + + project_id = ( + await self.urban_api_client.get_scenario_info(params.scenario_id, token) + )["project"]["project_id"] + territory_id = ( + await self.urban_api_client.get_all_project_info(project_id, token) + )["territory"]["id"] + normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ + 'radius_availability_meters', + 'time_availability_minutes', + 'services_per_1000_normative', + 'services_capacity_per_1000_normative', + ]].copy() + context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) + + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[ + ~service_types["infrastructure_type"].isna() + ].copy() + service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING) + service_types = service_types.join(normatives) + + + params = await self.get_optimal_func_zone_data(params, token) + + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token + ) + + scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token + ) + + before_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( + drop=True + ) + + # НЕ глобальный fillna! только сервисные колонки заполняем нулями + svc_cols = [c for c in before_blocks.columns if c.startswith(("count_", "capacity_"))] + if svc_cols: + before_blocks[svc_cols] = ( + before_blocks[svc_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype("int64") + ) + + # Приводим land_use к enum LandUse + before_blocks = self.effects_utils.coerce_land_use_enum(before_blocks) + + # before_blocks, _ = await self.aggregate_blocks_layer_scenario( + # params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token + # ) + + # before_blocks["count_fuel"] = 0 + + context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) + assign_gdf = assign_objects(before_blocks, context_territories_gdf.rename(columns={'parent': 'name'})) + before_blocks['parent'] = assign_gdf['name'].astype(int) + + before_blocks = generate_blocksnet_columns(before_blocks, service_types) + before_blocks = ensure_missing_id_and_name_columns(before_blocks) + + before_blocks.to_pickle("before_blocks_with_context.pkl") + + service_types = service_types[~service_types['infrastructure_type'].isna()].copy() + service_types = service_types[~service_types['blocksnet'].isna()].copy() + roads_gdf = await self.urban_api_client.get_physical_objects_scenario( + params.scenario_id, token=token, physical_object_function_id=ROADS_ID) + + roads_gdf = roads_gdf.to_crs(before_blocks.crs) + roads_gdf = roads_gdf.overlay(before_blocks) + + try: + graph = get_accessibility_graph(before_blocks, "drive") + except Exception as e: + raise http_exception( + 500, "Error generating territory graph", _detail=str(e) + ) + acc_mx = calculate_accessibility_matrix(before_blocks, graph) + dist_mx = calculate_distance_matrix(before_blocks) + + general_indicators = calculate_general_indicators(before_blocks) + demographic_indicators = calculate_demographic_indicators(before_blocks) + transport_indicators = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) + engineering_indicators = calculate_engineering_indicators(before_blocks) + sc_indicators, sp_indicators = calculate_social_indicators(before_blocks, acc_mx, dist_mx, service_types) + + indicators_df = pd.concat([ + general_indicators, + demographic_indicators, + transport_indicators, + engineering_indicators, + sc_indicators, + sp_indicators + ]) + + result = [] + for indicator in indicators_df.index: + indicator_id = INDICATORS_MAPPING.get(indicator) + for territory_id in indicators_df.columns: + if territory_id == 'total': + continue + value = indicators_df.loc[indicator, territory_id] + result.append({ + 'territory_id': int(territory_id), + 'indicator_id': indicator_id, + 'value': value + }) + + long_df = ( + indicators_df + .reset_index() + .rename(columns={"index": "indicator"}) + .melt(id_vars=["indicator"], var_name="territory_id", value_name="value") + ) + + long_df = long_df[long_df["territory_id"] != "total"].copy() + + long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) + + def _clean_number(v): + if v is None or (isinstance(v, float) and np.isnan(v)): + return None + try: + if isinstance(v, (np.floating, float, np.integer, int)): + if not np.isfinite(float(v)): + return None + except Exception: + pass + if isinstance(v, (np.integer,)): + return int(v) + if isinstance(v, (np.floating,)): + return float(v) + return v + + long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(_clean_number) + long_df["indicator_id"] = long_df["indicator_id"].apply(_clean_number) + long_df["value"] = long_df["value"].apply(_clean_number) + + long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()] + long_df = long_df.fillna(value=0) + long_df.to_pickle(" long_df.pkl") + + result = long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") + + return result \ No newline at end of file diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 925b1eb..7eaa7f2 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -127,3 +127,15 @@ async def get_context_services( gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + + +async def get_context_territories(project_id : int, token: str, client: UrbanAPIClient) -> gpd.GeoDataFrame: + project = await client.get_all_project_info(project_id, token) + context_ids = project['properties']['context'] + data = [{ + 'parent': territory_id, + 'geometry': await client.get_territory_geometry(territory_id) + } for territory_id in context_ids] + gdf = gpd.GeoDataFrame(data=data, crs=4326) + return gdf + diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 27005d8..061ed2d 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,8 +1,12 @@ import asyncio -from typing import Dict, List, Optional +import re +from typing import Dict, List, Optional, Mapping, Iterable, cast import pandas as pd +from blocksnet.analysis.indicators.socio_economic import SocialIndicator from blocksnet.config import service_types_config +import geopandas as gpd +from loguru import logger from app.clients.urban_api_client import UrbanAPIClient from app.common.caching.caching_service import FileCache @@ -64,8 +68,10 @@ async def adapt_service_types( await _warmup_social_values(list(df.index), client) df["social_values"] = [_SOCIAL_VALUES_BY_ST.get(st_id) for st_id in df.index] + df['blocksnet'] = df.apply(lambda s: SERVICE_TYPES_MAPPING.get(s.name), axis=1) - return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] + # return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] + return df def _map_services(names: list[str]) -> list[dict]: @@ -147,3 +153,261 @@ async def remap_properties_keys_in_geojson( props[new_k] = props[old_k] del props[old_k] return geojson + + +def adapt_social_service_types_df( + service_types_df: pd.DataFrame, + mapping: Mapping["SocialIndicator", Iterable[int]], +) -> pd.DataFrame: + """ + Attach 'indicator' column to social service types using SOCIAL_INDICATORS_MAPPING + and normalize naming convention. + + Parameters + ---------- + service_types_df : pd.DataFrame + DataFrame where index represents service_type_id. + mapping : Mapping[SocialIndicator, list[int]] + Mapping from SocialIndicator enum to service_type IDs. + + Returns + ------- + pd.DataFrame + Adapted DataFrame with added 'indicator' column and renamed columns. + """ + df = service_types_df.copy() + + id_to_indicator = { + st_id: indicator + for indicator, ids in mapping.items() + for st_id in ids + } + + df["indicator"] = df.index.map(id_to_indicator) + + df = df.rename(columns={ + 'radius_availability_meters': 'meters', + 'time_availability_minutes': 'minutes', + 'services_per_1000_normative': 'count', + 'services_capacity_per_1000_normative': 'capacity', + }) + + return df + + +# def generate_blocksnet_columns(blocks_gdf: gpd.GeoDataFrame, service_types_df: pd.DataFrame) -> gpd.GeoDataFrame: +# st_df = service_types_df[~service_types_df.blocksnet.isna()].copy() +# st_df['service_type_id'] = st_df.index +# agg_df = st_df.groupby('blocksnet').agg({'service_type_id': lambda s: list(s)}) +# new_columns = {} +# for st_name, row in agg_df.iterrows(): +# st_ids = row['service_type_id'] +# for prefix in ['count', 'capacity']: +# sum_df = blocks_gdf[[f'{prefix}_{st_id}' for st_id in st_ids]].sum(axis=1) +# new_columns[f'{prefix}_{st_name}'] = sum_df +# new_columns_df = pd.DataFrame.from_dict(new_columns) +# +# df = pd.concat([blocks_gdf, new_columns_df], axis=1) +# return cast(gpd.GeoDataFrame, df) + +_NUM_SUFFIX_RE = re.compile(r"^\d+$") + +def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], dict[str, int]]: + """Build lookups from service type names to ids.""" + # index must be service_type_id + if service_types_df.index.name is None: + service_types_df = service_types_df.copy() + service_types_df.index.name = "service_type_id" + + id_series = pd.to_numeric(service_types_df.index.to_series(), errors="coerce").dropna().astype("int64") + + name_to_id: dict[str, int] = {} + blocksnet_to_id: dict[str, int] = {} + + # prefer unique mappings; if duplicates exist, last one wins (and we warn) + for sid, row in service_types_df.loc[id_series.index].iterrows(): + try: + sid_int = int(sid) + except Exception: + continue + + nm = row.get("name") + if isinstance(nm, str) and nm: + prev = name_to_id.get(nm) + if prev is not None and prev != sid_int: + logger.warning("Duplicate mapping for name '%s': %s -> %s (last wins)", nm, prev, sid_int) + name_to_id[nm] = sid_int + + bn = row.get("blocksnet") + if isinstance(bn, str) and bn: + prev = blocksnet_to_id.get(bn) + if prev is not None and prev != sid_int: + logger.warning("Duplicate mapping for blocksnet '%s': %s -> %s (last wins)", bn, prev, sid_int) + blocksnet_to_id[bn] = sid_int + + return name_to_id, blocksnet_to_id + + +def _rename_non_id_columns_to_ids( + df: pd.DataFrame, + *, + name_to_id: dict[str, int], + blocksnet_to_id: dict[str, int], + prefixes: Iterable[str], +) -> pd.DataFrame: + """ + Rename columns like 'count_kindergarten' -> 'count_21' using provided lookups. + Leaves 'count_21' (already id) as-is. Unknown names are kept and warned. + """ + rename_map: dict[str, str] = {} + + for col in df.columns: + for prefix in prefixes: + pref = f"{prefix}_" + if not col.startswith(pref): + continue + suffix = col[len(pref):] + + # already numeric id + if _NUM_SUFFIX_RE.match(suffix): + break # nothing to do + + # try blocksnet, then name + sid = blocksnet_to_id.get(suffix) or name_to_id.get(suffix) + if sid is not None: + rename_map[col] = f"{pref}{sid}" + else: + logger.warning("No service_id mapping found for column '%s'", col) + break # handled this prefix + + if rename_map: + df = df.rename(columns=rename_map) + return df + +def ensure_missing_id_and_name_columns( + blocks_gdf: gpd.GeoDataFrame, + *, + count_prefix: str = "count", + capacity_prefix: str = "capacity", +) -> gpd.GeoDataFrame: + """ + Ensure per-block columns exist for every service in SERVICE_TYPES_MAPPING: + - {count_prefix}_{id} + - {capacity_prefix}_{id} + - {count_prefix}_{name} + - {capacity_prefix}_{name} + Missing columns are created and filled with zeros. + + Parameters + ---------- + blocks_gdf : GeoDataFrame + Blocks data. + service_type_mapping : dict[int -> str or None] + Mapping of service_type_id to service_name. + count_prefix, capacity_prefix : str + Column prefixes. + + Returns + ------- + GeoDataFrame + Updated dataframe containing all required columns. + """ + # 1. Собираем ID и имена + ids = sorted(int(sid) for sid in SERVICE_TYPES_MAPPING.keys()) + names = [name for name in SERVICE_TYPES_MAPPING.values() if isinstance(name, str) and name.strip()] + + # 2. Формируем список колонок, которые должны быть + required_cols = [] + for sid in ids: + required_cols.append(f"{count_prefix}_{sid}") + required_cols.append(f"{capacity_prefix}_{sid}") + for name in names: + required_cols.append(f"{count_prefix}_{name}") + required_cols.append(f"{capacity_prefix}_{name}") + + # 3. Определяем, чего не хватает + missing = [c for c in required_cols if c not in blocks_gdf.columns] + if missing: + logger.info(f"Creating missing service columns (zeros): {missing}") + add = {} + for col in missing: + if col.startswith(f"{count_prefix}_"): + add[col] = pd.Series(0, index=blocks_gdf.index, dtype="int64") + else: + add[col] = pd.Series(0.0, index=blocks_gdf.index, dtype="float64") + + blocks_gdf = blocks_gdf.join(pd.DataFrame(add, index=blocks_gdf.index)) + + return blocks_gdf + +def generate_blocksnet_columns( + blocks_gdf: gpd.GeoDataFrame, + service_types_df: pd.DataFrame, + *, + count_prefix: str = "count", + capacity_prefix: str = "capacity", + strict: bool = False, +) -> gpd.GeoDataFrame: + """ + Build notebook-like aggregated columns by 'blocksnet' groups: + + 1) Normalize per-service columns in `blocks_gdf`: + - Accept either '_' OR '_'. + - Non-numeric suffixes are renamed to numeric ids using `service_types_df`. + + 2) Aggregate by `blocksnet`: + - sum of count_ -> count_ + - sum of capacity_ -> capacity_ + + Robust behavior: + - service_type_id taken from index or 'service_type_id' column + - ids cast to int + - sum only existing columns; missing -> warning (or raise if strict=True) + """ + st_df = service_types_df[service_types_df["blocksnet"].notna()].copy() + + if "service_type_id" in st_df.columns: + ids_series = st_df["service_type_id"] + else: + ids_series = st_df.index.to_series(name="service_type_id") + + ids_series = pd.to_numeric(ids_series, errors="coerce").astype("Int64") + st_df = st_df.assign(service_type_id=ids_series).dropna(subset=["service_type_id"]) + st_df["service_type_id"] = st_df["service_type_id"].astype("int64") + + name_to_id, blocksnet_to_id = _build_name_maps(st_df) + blocks_gdf = _rename_non_id_columns_to_ids( + blocks_gdf, + name_to_id=name_to_id, + blocksnet_to_id=blocksnet_to_id, + prefixes=(count_prefix, capacity_prefix), + ) + + grouped = ( + st_df.groupby("blocksnet")["service_type_id"] + .apply(lambda s: sorted(set(int(x) for x in s))) + .to_dict() + ) + + new_columns: dict[str, pd.Series] = {} + for bnet, st_ids in grouped.items(): + for prefix in (count_prefix, capacity_prefix): + expected = [f"{prefix}_{sid}" for sid in st_ids] + existing = [c for c in expected if c in blocks_gdf.columns] + missing = [c for c in expected if c not in blocks_gdf.columns] + + if missing: + msg = f"Missing columns for '{bnet}' [{prefix}]: {missing}" + if strict: + raise KeyError(msg) + logger.warning(msg) + + series = ( + blocks_gdf[existing].fillna(0).sum(axis=1) + if existing + else pd.Series(0, index=blocks_gdf.index, dtype="float64") + ) + new_columns[f"{prefix}_{bnet}"] = series + + out = pd.concat([blocks_gdf, pd.DataFrame(new_columns, index=blocks_gdf.index)], axis=1) + return cast(gpd.GeoDataFrame, out) \ No newline at end of file diff --git a/app/main.py b/app/main.py index 7b3fcba..5cd2741 100644 --- a/app/main.py +++ b/app/main.py @@ -32,7 +32,7 @@ allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) -app.add_middleware(ExceptionHandlerMiddleware) +# app.add_middleware(ExceptionHandlerMiddleware) @app.get("/", include_in_schema=False) From ebbae67c01d8f92a0c63a78b0dbc217d1e661804 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 24 Oct 2025 17:58:49 +0300 Subject: [PATCH 106/161] feat(f_22): tmp_logic --- app/common/utils/ids_convertation.py | 16 +- app/effects_api/effects_controller.py | 4 +- app/effects_api/effects_service.py | 478 +++++++++++++++++--------- 3 files changed, 334 insertions(+), 164 deletions(-) diff --git a/app/common/utils/ids_convertation.py b/app/common/utils/ids_convertation.py index 8ae2e09..58ab5e4 100644 --- a/app/common/utils/ids_convertation.py +++ b/app/common/utils/ids_convertation.py @@ -16,14 +16,14 @@ def __init__( self.__name__ = "EffectsUtils" self.urban_api_client = urban_api_client - def _truthy_is_based(self, v: Any) -> bool: + def truthy_is_based(self, v: Any) -> bool: return v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") - def _parent_id(self, s: Dict[str, Any]) -> Optional[int]: + def parent_id(self, s: Dict[str, Any]) -> Optional[int]: p = s.get("parent_scenario") return p.get("id") if isinstance(p, dict) else p - def _sid(self, s: Dict[str, Any]) -> Optional[int]: + def sid(self, s: Dict[str, Any]) -> Optional[int]: try: return int(s.get("scenario_id")) except Exception: @@ -41,15 +41,15 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: matches = [ s for s in scenarios - if self._truthy_is_based(s.get("is_based")) - and self._parent_id(s) == regional_id - and self._sid(s) is not None + if self.truthy_is_based(s.get("is_based")) + and self.parent_id(s) == regional_id + and self.sid(s) is not None ] if not matches: only_based = [ s for s in scenarios - if self._truthy_is_based(s.get("is_based")) and self._sid(s) is not None + if self.truthy_is_based(s.get("is_based")) and self.sid(s) is not None ] if not only_based: return scenario_id @@ -59,7 +59,7 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True, ) - return self._sid(matches[0]) or scenario_id + return self.sid(matches[0]) or scenario_id def coerce_land_use_enum(self, df: pd.DataFrame, col: str = "land_use") -> pd.DataFrame: """ diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index ffaa406..2129b9e 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -12,7 +12,7 @@ ContextDevelopmentDTO, DevelopmentDTO, ) -from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO +from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO, SocioEconomicByProjectComputedDTO from .dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO from .dto.transformation_effects_dto import TerritoryTransformationDTO from .schemas.development_response_schema import DevelopmentResponseSchema @@ -96,7 +96,7 @@ async def get_socio_economic_prediction( "/scenario_f_22" ) async def get_socio_economic_prediction_new( - params: Annotated[SocioEconomicByScenarioDTO, Depends(SocioEconomicByScenarioDTO)], + params: Annotated[SocioEconomicByProjectDTO, Depends(SocioEconomicByProjectDTO)], token: str = Depends(verify_token), ): return await effects_service.evaluate_social_economical_metrics(params, token) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 0226fca..e232e53 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -47,7 +47,7 @@ MAX_EVALS, MAX_RUNS, PRED_VALUE_RU, - PROB_COLS_EN_TO_RU, SOCIAL_INDICATORS_MAPPING, ROADS_ID, INDICATORS_MAPPING, + PROB_COLS_EN_TO_RU, SOCIAL_INDICATORS_MAPPING, ROADS_ID, INDICATORS_MAPPING, SERVICE_TYPES_MAPPING, ) from .dto.development_dto import ( ContextDevelopmentDTO, @@ -1409,34 +1409,34 @@ async def values_oriented_requirements( project_id, token ) - def _truthy_is_based(x): - v = x.get("is_based") - return ( - v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") - ) - - def _parent_id(x): - p = x.get("parent_scenario") - return p.get("id") if isinstance(p, dict) else p - - def _sid(x): - try: - return int(x.get("scenario_id")) - except Exception: - return None + # def _truthy_is_based(x): + # v = x.get("is_based") + # return ( + # v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") + # ) + # + # def _parent_id(x): + # p = x.get("parent_scenario") + # return p.get("id") if isinstance(p, dict) else p + # + # def _sid(x): + # try: + # return int(x.get("scenario_id")) + # except Exception: + # return None matches = [ s for s in proj_scenarios - if _truthy_is_based(s) - and _parent_id(s) == regional_id - and _sid(s) is not None + if self.effects_utils.truthy_is_based(s) + and self.effects_utils.parent_id(s) == regional_id + and self.effects_utils.sid(s) is not None ] if not matches: only_based = [ s for s in proj_scenarios - if _truthy_is_based(s) and _sid(s) is not None + if self.effects_utils.truthy_is_based(s) and self.effects_utils.sid(s) is not None ] if only_based: only_based.sort( @@ -1455,7 +1455,7 @@ def _sid(x): ), reverse=True, ) - base_id = _sid(matches[0]) + base_id = self.effects_utils.sid(matches[0]) if base_id is None: base_id = params.scenario_id @@ -1639,169 +1639,283 @@ def _result_to_df(payload: Any) -> pd.DataFrame: return result_df - async def evaluate_social_economical_metrics( - self, - params: SocioEconomicByScenarioDTO, - token: str - - ): - method_name = "territory_transformation" - - # info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - # updated_at = info["updated_at"] - # project_id = info["project"]["project_id"] - # base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) - # - # params = await self.get_optimal_func_zone_data(params, token) - # - # params_for_hash = await self.build_hash_params(params, token) - # phash = self.cache.params_hash(params_for_hash) - # - # force = getattr(params, "force", False) - # cached = ( - # None if force else self.cache.load(method_name, params.scenario_id, phash) - # ) - # if ( - # cached - # and cached["meta"]["scenario_updated_at"] == updated_at - # and "before" in cached["data"] - # ): - # return { - # n: fc_to_gdf(fc) - # for n, fc in cached["data"]["before"].items() - # if is_fc(fc) - # } - # - # logger.info("Cache stale, missing or forced: calculating BEFORE") - - project_id = ( - await self.urban_api_client.get_scenario_info(params.scenario_id, token) - )["project"]["project_id"] - territory_id = ( - await self.urban_api_client.get_all_project_info(project_id, token) - )["territory"]["id"] - normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ - 'radius_availability_meters', - 'time_availability_minutes', - 'services_per_1000_normative', - 'services_capacity_per_1000_normative', - ]].copy() - context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) - - service_types = await self.urban_api_client.get_service_types() - service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[ - ~service_types["infrastructure_type"].isna() - ].copy() - service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING) - service_types = service_types.join(normatives) - - - params = await self.get_optimal_func_zone_data(params, token) - - context_blocks, _ = await self.aggregate_blocks_layer_context( - params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token - ) + # async def evaluate_social_economical_metrics( + # self, + # params: SocioEconomicByScenarioDTO | SocioEconomicByProjectDTO, + # token: str + # + # ): + # method_name = "territory_transformation" + # + # # info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) + # # updated_at = info["updated_at"] + # # project_id = info["project"]["project_id"] + # # base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) + # # + # # params = await self.get_optimal_func_zone_data(params, token) + # # + # # params_for_hash = await self.build_hash_params(params, token) + # # phash = self.cache.params_hash(params_for_hash) + # # + # # force = getattr(params, "force", False) + # # cached = ( + # # None if force else self.cache.load(method_name, params.scenario_id, phash) + # # ) + # # if ( + # # cached + # # and cached["meta"]["scenario_updated_at"] == updated_at + # # and "before" in cached["data"] + # # ): + # # return { + # # n: fc_to_gdf(fc) + # # for n, fc in cached["data"]["before"].items() + # # if is_fc(fc) + # # } + # # + # # logger.info("Cache stale, missing or forced: calculating BEFORE") + # if params.scenario_id: + # project_id = ( + # await self.urban_api_client.get_scenario_info(params.scenario_id, token) + # )["project"]["project_id"] + # territory_id = ( + # await self.urban_api_client.get_all_project_info(project_id, token) + # )["territory"]["id"] + # else: + # territory_id = ( + # await self.urban_api_client.get_all_project_info(params.project_id, token) + # )["territory"]["id"] + # project_id = params.project_id + # + # normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ + # 'radius_availability_meters', + # 'time_availability_minutes', + # 'services_per_1000_normative', + # 'services_capacity_per_1000_normative', + # ]].copy() + # + # if params.regional_scenario_id and params.project_id: + # project_info = await self.urban_api_client.get_all_project_info( + # project_id, token + # ) + # proj_scenarios = await self.urban_api_client.get_project_scenarios( + # project_id, token + # ) + # + # context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) + # + # service_types = await self.urban_api_client.get_service_types() + # service_types = await adapt_service_types(service_types, self.urban_api_client) + # service_types = service_types[ + # ~service_types["infrastructure_type"].isna() + # ].copy() + # service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING) + # service_types = service_types.join(normatives) + # + # + # params = await self.get_optimal_func_zone_data(params, token) + # + # context_blocks, _ = await self.aggregate_blocks_layer_context( + # params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token + # ) + # + # scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + # params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token + # ) + # + # before_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( + # drop=True + # ) + # + # svc_cols = [c for c in before_blocks.columns if c.startswith(("count_", "capacity_"))] + # if svc_cols: + # before_blocks[svc_cols] = ( + # before_blocks[svc_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype("int64") + # ) + # + # before_blocks = self.effects_utils.coerce_land_use_enum(before_blocks) + # + # # before_blocks, _ = await self.aggregate_blocks_layer_scenario( + # # params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token + # # ) + # + # # before_blocks["count_fuel"] = 0 + # + # context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) + # assign_gdf = assign_objects(before_blocks, context_territories_gdf.rename(columns={'parent': 'name'})) + # before_blocks['parent'] = assign_gdf['name'].astype(int) + # + # before_blocks = generate_blocksnet_columns(before_blocks, service_types) + # before_blocks = ensure_missing_id_and_name_columns(before_blocks) + # + # before_blocks.to_pickle("before_blocks_with_context.pkl") + # + # service_types = service_types[~service_types['infrastructure_type'].isna()].copy() + # service_types = service_types[~service_types['blocksnet'].isna()].copy() + # roads_gdf = await self.urban_api_client.get_physical_objects_scenario( + # params.scenario_id, token=token, physical_object_function_id=ROADS_ID) + # + # roads_gdf = roads_gdf.to_crs(before_blocks.crs) + # roads_gdf = roads_gdf.overlay(before_blocks) + # + # try: + # graph = get_accessibility_graph(before_blocks, "drive") + # except Exception as e: + # raise http_exception( + # 500, "Error generating territory graph", _detail=str(e) + # ) + # acc_mx = calculate_accessibility_matrix(before_blocks, graph) + # dist_mx = calculate_distance_matrix(before_blocks) + # + # general_indicators = calculate_general_indicators(before_blocks) + # demographic_indicators = calculate_demographic_indicators(before_blocks) + # transport_indicators = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) + # engineering_indicators = calculate_engineering_indicators(before_blocks) + # sc_indicators, sp_indicators = calculate_social_indicators(before_blocks, acc_mx, dist_mx, service_types) + # + # indicators_df = pd.concat([ + # general_indicators, + # demographic_indicators, + # transport_indicators, + # engineering_indicators, + # sc_indicators, + # sp_indicators + # ]) + # + # result = [] + # for indicator in indicators_df.index: + # indicator_id = INDICATORS_MAPPING.get(indicator) + # for territory_id in indicators_df.columns: + # if territory_id == 'total': + # continue + # value = indicators_df.loc[indicator, territory_id] + # result.append({ + # 'territory_id': int(territory_id), + # 'indicator_id': indicator_id, + # 'value': value + # }) + # + # long_df = ( + # indicators_df + # .reset_index() + # .rename(columns={"index": "indicator"}) + # .melt(id_vars=["indicator"], var_name="territory_id", value_name="value") + # ) + # + # long_df = long_df[long_df["territory_id"] != "total"].copy() + # + # long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) + # + # def _clean_number(v): + # if v is None or (isinstance(v, float) and np.isnan(v)): + # return None + # try: + # if isinstance(v, (np.floating, float, np.integer, int)): + # if not np.isfinite(float(v)): + # return None + # except Exception: + # pass + # if isinstance(v, (np.integer,)): + # return int(v) + # if isinstance(v, (np.floating,)): + # return float(v) + # return v + # + # long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(_clean_number) + # long_df["indicator_id"] = long_df["indicator_id"].apply(_clean_number) + # long_df["value"] = long_df["value"].apply(_clean_number) + # + # long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()] + # long_df = long_df.fillna(value=0) + # long_df.to_pickle(" long_df.pkl") + # + # result = long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") + # + # return result + + async def _compute_for_single_scenario( + self, + scenario_id: int, + context_blocks: gpd.GeoDataFrame, + context_territories_gdf: gpd.GeoDataFrame, + service_types_df: pd.DataFrame, + proj_func_zone_source: Optional[str], + proj_func_source_year: Optional[int], + token: str, + ) -> list[dict]: + """ + Compute indicators for ONE scenario with shared context. + Returns JSON-serializable list of records: [{territory_id, indicator_id, value}, ...] + """ scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token + scenario_id, proj_func_zone_source, proj_func_source_year, token ) - before_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( - drop=True - ) + # 2) glue context + scenario (no global fillna!) + before_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=True) - # НЕ глобальный fillna! только сервисные колонки заполняем нулями + # service columns → ints, NaN→0 svc_cols = [c for c in before_blocks.columns if c.startswith(("count_", "capacity_"))] if svc_cols: before_blocks[svc_cols] = ( before_blocks[svc_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype("int64") ) - # Приводим land_use к enum LandUse - before_blocks = self.effects_utils.coerce_land_use_enum(before_blocks) - - # before_blocks, _ = await self.aggregate_blocks_layer_scenario( - # params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token - # ) - - # before_blocks["count_fuel"] = 0 + # land_use → enum + # before_blocks = _coerce_land_use_enum(before_blocks) + # parent territories context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) - assign_gdf = assign_objects(before_blocks, context_territories_gdf.rename(columns={'parent': 'name'})) - before_blocks['parent'] = assign_gdf['name'].astype(int) + assigned = assign_objects(before_blocks, context_territories_gdf.rename(columns={"parent": "name"})) + before_blocks["parent"] = assigned["name"].astype(int) - before_blocks = generate_blocksnet_columns(before_blocks, service_types) + # aggregated blocksnet columns + ensure missing (id & name) + before_blocks = generate_blocksnet_columns(before_blocks, service_types_df) before_blocks = ensure_missing_id_and_name_columns(before_blocks) - before_blocks.to_pickle("before_blocks_with_context.pkl") - - service_types = service_types[~service_types['infrastructure_type'].isna()].copy() - service_types = service_types[~service_types['blocksnet'].isna()].copy() + # 3) roads overlay for transport roads_gdf = await self.urban_api_client.get_physical_objects_scenario( - params.scenario_id, token=token, physical_object_function_id=ROADS_ID) - - roads_gdf = roads_gdf.to_crs(before_blocks.crs) - roads_gdf = roads_gdf.overlay(before_blocks) + scenario_id, token=token, physical_object_function_id=ROADS_ID + ) + roads_gdf = roads_gdf.to_crs(before_blocks.crs).overlay(before_blocks) - try: - graph = get_accessibility_graph(before_blocks, "drive") - except Exception as e: - raise http_exception( - 500, "Error generating territory graph", _detail=str(e) - ) + # 4) graph + matrices + graph = get_accessibility_graph(before_blocks, "drive") acc_mx = calculate_accessibility_matrix(before_blocks, graph) dist_mx = calculate_distance_matrix(before_blocks) - general_indicators = calculate_general_indicators(before_blocks) - demographic_indicators = calculate_demographic_indicators(before_blocks) - transport_indicators = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) - engineering_indicators = calculate_engineering_indicators(before_blocks) - sc_indicators, sp_indicators = calculate_social_indicators(before_blocks, acc_mx, dist_mx, service_types) - - indicators_df = pd.concat([ - general_indicators, - demographic_indicators, - transport_indicators, - engineering_indicators, - sc_indicators, - sp_indicators - ]) - - result = [] - for indicator in indicators_df.index: - indicator_id = INDICATORS_MAPPING.get(indicator) - for territory_id in indicators_df.columns: - if territory_id == 'total': - continue - value = indicators_df.loc[indicator, territory_id] - result.append({ - 'territory_id': int(territory_id), - 'indicator_id': indicator_id, - 'value': value - }) + # 5) indicators + st_for_social = service_types_df[ + service_types_df["infrastructure_type"].notna() + & service_types_df["blocksnet"].notna() + ].copy() + + general = calculate_general_indicators(before_blocks) + demo = calculate_demographic_indicators(before_blocks) + transp = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) + eng = calculate_engineering_indicators(before_blocks) + sc, sp = calculate_social_indicators(before_blocks, acc_mx, dist_mx, st_for_social) + indicators_df = pd.concat([general, demo, transp, eng, sc, sp]) + + # 6) flatten to JSON-safe long_df = ( - indicators_df - .reset_index() + indicators_df.reset_index() .rename(columns={"index": "indicator"}) .melt(id_vars=["indicator"], var_name="territory_id", value_name="value") ) - long_df = long_df[long_df["territory_id"] != "total"].copy() - long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) def _clean_number(v): if v is None or (isinstance(v, float) and np.isnan(v)): return None try: - if isinstance(v, (np.floating, float, np.integer, int)): - if not np.isfinite(float(v)): - return None + if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite(float(v)): + return None except Exception: pass - if isinstance(v, (np.integer,)): + if isinstance(v, (np.integer,)): # numpy int -> python int return int(v) if isinstance(v, (np.floating,)): return float(v) @@ -1810,11 +1924,67 @@ def _clean_number(v): long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(_clean_number) long_df["indicator_id"] = long_df["indicator_id"].apply(_clean_number) long_df["value"] = long_df["value"].apply(_clean_number) + long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()].fillna(0) - long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()] - long_df = long_df.fillna(value=0) - long_df.to_pickle(" long_df.pkl") + return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") - result = long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") + async def evaluate_social_economical_metrics( + self, + # params: SocioEconomicByScenarioDTO, + params: SocioEconomicByProjectDTO, + token: str, + ): + """ + Multi-scenario mode with shared context: + - Build context once for the project + - For each scenario_id: glue (context ⊕ scenario), compute indicators by parent territories + - Return {scenario_id: [{territory_id, indicator_id, value}, ...]} + """ + # 0) Resolve project and context once + # project_id = (await self.urban_api_client.get_scenario_info(params.scenario_id, token))["project"]["project_id"] + territory_id = (await self.urban_api_client.get_all_project_info(params.project_id, token))["territory"]["id"] + params = await self.get_optimal_func_zone_data(params, token) + # norms + service types (once) + normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ + "radius_availability_meters", + "time_availability_minutes", + "services_per_1000_normative", + "services_capacity_per_1000_normative", + ]].copy() + + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[service_types["infrastructure_type"].notna()].copy() + service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) + + # shared context once + context_blocks, _ = await self.aggregate_blocks_layer_context( + params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token + ) + + # parent territories layer once + context_territories_gdf = await get_context_territories(params.project_id, token, self.urban_api_client) + + # 1) list of scenarios to compute + scenarios = [int(params.scenario_id)] + if getattr(params, "project_id", None) and getattr(params, "regional_scenario_id", None): + all_sc = await self.urban_api_client.get_project_scenarios(int(params.project_id), token) + parent_id = int(params.regional_scenario_id) + scenarios = [int(s["scenario_id"]) for s in all_sc if + (s.get("parent_scenario") or {}).get("id") == parent_id] + + # 2) per-scenario compute with shared context + results: Dict[int, list[dict]] = {} + for sid in scenarios: + records = await self._compute_for_single_scenario( + sid, + context_blocks=context_blocks, + context_territories_gdf=context_territories_gdf, + service_types_df=service_types, + proj_func_zone_source=params.proj_func_zone_source, + proj_func_source_year=params.proj_func_source_year, + token=token, + ) + results[sid] = records - return result \ No newline at end of file + return results \ No newline at end of file From 75c8393e0b4ab7bdb889ebf220516bdf97b170be Mon Sep 17 00:00:00 2001 From: voronapxl Date: Mon, 27 Oct 2025 15:15:10 +0300 Subject: [PATCH 107/161] fat(f_22): 1. Additional calculations for project scenarios added - each scenario is now calculated --- app/clients/urban_api_client.py | 8 +- .../{ids_convertation.py => effects_utils.py} | 49 +- app/dependencies.py | 2 +- app/effects_api/effects_service.py | 596 +++--------------- app/effects_api/modules/scenario_service.py | 15 +- .../modules/service_type_service.py | 38 +- app/effects_api/tasks_controller.py | 1 - 7 files changed, 136 insertions(+), 573 deletions(-) rename app/common/utils/{ids_convertation.py => effects_utils.py} (59%) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index d3da34e..c9ae971 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -154,16 +154,12 @@ async def get_physical_objects_scenario( async def get_services_scenario( self, scenario_id: int, token: str, **kwargs: Any - ) -> gpd.GeoDataFrame: - res = await self.json_handler.get( + ) -> dict: + return await self.json_handler.get( f"/api/v1/scenarios/{scenario_id}/services_with_geometry", headers={"Authorization": f"Bearer {token}"}, params=kwargs, ) - features = res["features"] - return gpd.GeoDataFrame.from_features(features, crs=4326).set_index( - "service_id" - ) async def get_optimal_func_zone_request_data( self, diff --git a/app/common/utils/ids_convertation.py b/app/common/utils/effects_utils.py similarity index 59% rename from app/common/utils/ids_convertation.py rename to app/common/utils/effects_utils.py index 58ab5e4..6b5a010 100644 --- a/app/common/utils/ids_convertation.py +++ b/app/common/utils/effects_utils.py @@ -1,6 +1,7 @@ import re from typing import Any, Dict, Optional +import numpy as np import pandas as pd from blocksnet.enums import LandUse from loguru import logger @@ -61,40 +62,16 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: ) return self.sid(matches[0]) or scenario_id - def coerce_land_use_enum(self, df: pd.DataFrame, col: str = "land_use") -> pd.DataFrame: - """ - Normalize 'land_use' column to LandUse enum: - - Accept LandUse enum → keep - - Accept 'LandUse.NAME' → strip prefix, use NAME - - Accept 'name' values → use by value ('residential', ...) - - Accept 'NAME' values → use by name ('RESIDENTIAL', ...) - - None/NaN → keep None - Unknown values → None - """ - if col not in df.columns: - return df - - def _to_enum(v): - if v is None or (isinstance(v, float) and pd.isna(v)): - return None - if isinstance(v, LandUse): - return v - if isinstance(v, str): - s = v.strip() - m = re.match(r"^(?:LandUse\.)?([A-Za-z_]+)$", s) - if m: - key = m.group(1) - try: - return LandUse[key.upper()] - except KeyError: - pass - try: - return LandUse(key.lower()) - except ValueError: - logger.warning("Unknown land_use value: %r -> set to None", v) - return None - logger.warning("Unsupported land_use type: %r -> set to None", type(v).__name__) + def clean_number(self, v): + if v is None or (isinstance(v, float) and np.isnan(v)): return None - - df[col] = df[col].map(_to_enum) - return df + try: + if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite(float(v)): + return None + except Exception: + pass + if isinstance(v, (np.integer,)): + return int(v) + if isinstance(v, (np.floating,)): + return float(v) + return v diff --git a/app/dependencies.py b/app/dependencies.py index e67478f..54e1dd8 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -7,7 +7,7 @@ from app.clients.urban_api_client import UrbanAPIClient from app.common.api_handlers.json_api_handler import JSONAPIHandler from app.common.caching.caching_service import FileCache -from app.common.utils.ids_convertation import EffectsUtils +from app.common.utils.effects_utils import EffectsUtils from app.effects_api.effects_service import EffectsService from app.effects_api.modules.scenario_service import ScenarioService diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index e232e53..635aaa2 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -4,7 +4,6 @@ import geopandas as gpd import numpy as np import pandas as pd -from blocksnet.analysis.indicators import calculate_development_indicators from blocksnet.analysis.indicators.socio_economic import calculate_general_indicators, calculate_demographic_indicators, \ calculate_transport_indicators, calculate_engineering_indicators, calculate_social_indicators from blocksnet.analysis.land_use.prediction import SpatialClassifier @@ -13,7 +12,7 @@ from blocksnet.blocks.assignment import assign_land_use, assign_objects from blocksnet.config import service_types_config from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor +from blocksnet.machine_learning.regression import SocialRegressor from blocksnet.optimization.services import ( AreaSolution, Facade, @@ -23,7 +22,6 @@ ) from blocksnet.relations import ( calculate_accessibility_matrix, - generate_adjacency_graph, get_accessibility_context, get_accessibility_graph, calculate_distance_matrix, ) @@ -70,7 +68,7 @@ SocioEconomicResponseSchema, SocioEconomicSchema, ) -from ..common.utils.ids_convertation import EffectsUtils +from ..common.utils.effects_utils import EffectsUtils class EffectsService: @@ -399,85 +397,6 @@ async def get_services_layer(self, scenario_id: int, token: str): ) return blocks - @staticmethod - async def run_development_parameters( - blocks_gdf: gpd.GeoDataFrame, - ) -> pd.DataFrame: - """ - Compute core *development* indicators (FSI, GSI, MXI, etc.) for each - block and derive population estimates. - - The routine: - 1. Clips every land-use share to [0, 1]. - 2. Generates an adjacency graph (10 m tolerance). - 3. Uses DensityRegressor to predict density indices. - 4. Converts indices into built-area, footprint, living area, etc. - 5. Estimates population by living_area // 20. - - Params: - blocks_gdf : gpd.GeoDataFrame - Block layer already containing per-land-use **shares** - (0 ≤ share ≤ 1) and `site_area`. - - Returns: - pd.DataFrame with added columns: - `build_floor_area`, `footprint_area`, `living_area`, - `non_living_area`, `population`, plus the original density indices. - """ - for lu in LandUse: - blocks_gdf[lu.value] = blocks_gdf[lu.value].apply(lambda v: min(v, 1)) - - adjacency_graph = generate_adjacency_graph(blocks_gdf, 10) - dr = DensityRegressor() - - density_df = dr.evaluate(blocks_gdf, adjacency_graph) - density_df.loc[density_df["fsi"] < 0, "fsi"] = 0 - - density_df.loc[density_df["gsi"] < 0, "gsi"] = 0 - density_df.loc[density_df["gsi"] > 1, "gsi"] = 1 - - density_df.loc[density_df["mxi"] < 0, "mxi"] = 0 - density_df.loc[density_df["mxi"] > 1, "mxi"] = 1 - - density_df.loc[blocks_gdf["residential"] == 0, "mxi"] = 0 - density_df["site_area"] = blocks_gdf["site_area"] - - development_df = calculate_development_indicators(density_df) - development_df["population"] = development_df["living_area"] // 20 - - return development_df - - async def run_social_reg_prediction( - self, - blocks: gpd.GeoDataFrame, - data_input: pd.DataFrame, - ): - """ - Function runs social regression from blocksnet - Args: - blocks (gpd.GeoDataFrame): Block layer already containing per-land-use **shares** - data_input (pd.DataFrame): Data to run regression on - Returns: - SocioEconomicSchema: SocioEconomicSchema from schemas to return result generation - """ - - data_input["latitude"] = blocks.geometry.union_all().centroid.x - data_input["longitude"] = blocks.geometry.union_all().centroid.y - data_input["buildings_count"] = data_input["count_buildings"] - y_pred, pi_lower, pi_upper = self.bn_social_regressor.evaluate(data_input) - iloc = 0 - result_data = { - "pred": y_pred.apply(round).astype(int).iloc[iloc].to_dict(), - "lower": pi_lower.iloc[iloc].to_dict(), - "upper": pi_upper.iloc[iloc].to_dict(), - } - result_df = pd.DataFrame.from_dict(result_data) - result_df["is_interval"] = (result_df["pred"] <= result_df["upper"]) & ( - result_df["pred"] >= result_df["lower"] - ) - res = result_df.to_dict(orient="index") - return SocioEconomicSchema(socio_economic_prediction=res) - async def evaluate_master_plan_by_project( self, params: SocioEconomicByProjectDTO, token: str ) -> SocioEconomicResponseSchema: @@ -602,150 +521,6 @@ async def evaluate_master_plan_by_project( params_data=computed_params, ) - async def evaluate_master_plan_by_scenario( - self, params: SocioEconomicByScenarioDTO, token: str - ) -> SocioEconomicResponseSchema: - sid = params.scenario_id - logger.info(f"[Effects] legacy mode: scenario_id={sid}") - - project_id = await self.urban_api_client.get_project_id(sid, token) - project_info = await self.urban_api_client.get_all_project_info( - project_id, token - ) - context_territories = project_info.get("properties", {}).get("context") or [] - params = await self.get_optimal_func_zone_data(params, token) - - context_blocks, _ = await self.aggregate_blocks_layer_context( - sid, params.context_func_zone_source, params.context_func_source_year, token - ) - - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - sid, params.proj_func_zone_source, params.proj_func_source_year, token - ) - - scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - - blocks = gpd.GeoDataFrame( - pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs, - ) - - landuse_cols = [ - "residential", - "business", - "recreation", - "industrial", - "transport", - "special", - "agriculture", - ] - blocks[landuse_cols] = blocks[landuse_cols].clip(upper=1) - development_df = await self.run_development_parameters(blocks) - - add_cols = [ - "build_floor_area", - "footprint_area", - "living_area", - "non_living_area", - "population", - ] - blocks[add_cols] = development_df[add_cols].values - - for lu in LandUse: - blocks[lu.value] = blocks[lu.value] * blocks["site_area"] - - main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] - main_input = pd.DataFrame(main_data) - main_res: SocioEconomicSchema = await self.run_social_reg_prediction( - blocks, main_input - ) - - split_results: Optional[Dict[int, SocioEconomicSchema]] = None - if params.split and context_territories: - split_results = {} - for tid in context_territories: - territory = gpd.GeoDataFrame( - geometry=[await self.urban_api_client.get_territory_geometry(tid)], - crs=4326, - ) - ter_blocks = ( - blocks.sjoin( - territory.to_crs(territory.estimate_utm_crs()), how="left" - ) - .dropna(subset="index_right") - .drop(columns="index_right") - ) - ter_data = [ - ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict() - ] - ter_input = pd.DataFrame(ter_data) - split_results[tid] = await self.run_social_reg_prediction( - ter_blocks, ter_input - ) - - return SocioEconomicResponseSchema( - socio_economic_prediction={sid: main_res.socio_economic_prediction}, - split_prediction=split_results or None, - params_data=params, - ) - - async def calc_project_development( - self, token: str, params: DevelopmentDTO - ) -> DevelopmentResponseSchema: - """ - Function calculates development only for project with blocksnet - Args: - token (str): User token to access data from Urban API - params (DevelopmentDTO): development request params - Returns: - DevelopmentResponseSchema: Response schema with development indicators - """ - - params = await self.get_optimal_func_zone_data(params, token) - blocks, buildings = await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, - ) - res = await self.run_development_parameters(blocks) - res = res.to_dict(orient="list") - res.update({"params_data": params.model_dump()}) - return DevelopmentResponseSchema(**res) - - async def calc_context_development( - self, token: str, params: ContextDevelopmentDTO - ) -> DevelopmentResponseSchema: - """ - Function calculates development for context with project with blocksnet - Args: - token (str): User token to access data from Urban API - params (DevelopmentDTO): - Returns: - DevelopmentResponseSchema: Response schema with development indicators - """ - - params = await self.get_optimal_func_zone_data(params, token) - context_blocks, context_buildings = await self.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, - ) - scenario_blocks, scenario_buildings = ( - await self.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, - ) - ) - blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) - res = await self.run_development_parameters(blocks) - res = res.to_dict(orient="list") - res.update({"params_data": params.model_dump()}) - return DevelopmentResponseSchema(**res) - async def _get_accessibility_context( self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float ) -> list[int]: @@ -1409,22 +1184,6 @@ async def values_oriented_requirements( project_id, token ) - # def _truthy_is_based(x): - # v = x.get("is_based") - # return ( - # v is True or v == 1 or (isinstance(v, str) and v.lower() == "true") - # ) - # - # def _parent_id(x): - # p = x.get("parent_scenario") - # return p.get("id") if isinstance(p, dict) else p - # - # def _sid(x): - # try: - # return int(x.get("scenario_id")) - # except Exception: - # return None - matches = [ s for s in proj_scenarios @@ -1639,252 +1398,104 @@ def _result_to_df(payload: Any) -> pd.DataFrame: return result_df - # async def evaluate_social_economical_metrics( - # self, - # params: SocioEconomicByScenarioDTO | SocioEconomicByProjectDTO, - # token: str - # - # ): - # method_name = "territory_transformation" - # - # # info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - # # updated_at = info["updated_at"] - # # project_id = info["project"]["project_id"] - # # base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) - # # - # # params = await self.get_optimal_func_zone_data(params, token) - # # - # # params_for_hash = await self.build_hash_params(params, token) - # # phash = self.cache.params_hash(params_for_hash) - # # - # # force = getattr(params, "force", False) - # # cached = ( - # # None if force else self.cache.load(method_name, params.scenario_id, phash) - # # ) - # # if ( - # # cached - # # and cached["meta"]["scenario_updated_at"] == updated_at - # # and "before" in cached["data"] - # # ): - # # return { - # # n: fc_to_gdf(fc) - # # for n, fc in cached["data"]["before"].items() - # # if is_fc(fc) - # # } - # # - # # logger.info("Cache stale, missing or forced: calculating BEFORE") - # if params.scenario_id: - # project_id = ( - # await self.urban_api_client.get_scenario_info(params.scenario_id, token) - # )["project"]["project_id"] - # territory_id = ( - # await self.urban_api_client.get_all_project_info(project_id, token) - # )["territory"]["id"] - # else: - # territory_id = ( - # await self.urban_api_client.get_all_project_info(params.project_id, token) - # )["territory"]["id"] - # project_id = params.project_id - # - # normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ - # 'radius_availability_meters', - # 'time_availability_minutes', - # 'services_per_1000_normative', - # 'services_capacity_per_1000_normative', - # ]].copy() - # - # if params.regional_scenario_id and params.project_id: - # project_info = await self.urban_api_client.get_all_project_info( - # project_id, token - # ) - # proj_scenarios = await self.urban_api_client.get_project_scenarios( - # project_id, token - # ) - # - # context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) - # - # service_types = await self.urban_api_client.get_service_types() - # service_types = await adapt_service_types(service_types, self.urban_api_client) - # service_types = service_types[ - # ~service_types["infrastructure_type"].isna() - # ].copy() - # service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING) - # service_types = service_types.join(normatives) - # - # - # params = await self.get_optimal_func_zone_data(params, token) - # - # context_blocks, _ = await self.aggregate_blocks_layer_context( - # params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token - # ) - # - # scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - # params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token - # ) - # - # before_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( - # drop=True - # ) - # - # svc_cols = [c for c in before_blocks.columns if c.startswith(("count_", "capacity_"))] - # if svc_cols: - # before_blocks[svc_cols] = ( - # before_blocks[svc_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype("int64") - # ) - # - # before_blocks = self.effects_utils.coerce_land_use_enum(before_blocks) - # - # # before_blocks, _ = await self.aggregate_blocks_layer_scenario( - # # params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, token - # # ) - # - # # before_blocks["count_fuel"] = 0 - # - # context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) - # assign_gdf = assign_objects(before_blocks, context_territories_gdf.rename(columns={'parent': 'name'})) - # before_blocks['parent'] = assign_gdf['name'].astype(int) - # - # before_blocks = generate_blocksnet_columns(before_blocks, service_types) - # before_blocks = ensure_missing_id_and_name_columns(before_blocks) - # - # before_blocks.to_pickle("before_blocks_with_context.pkl") - # - # service_types = service_types[~service_types['infrastructure_type'].isna()].copy() - # service_types = service_types[~service_types['blocksnet'].isna()].copy() - # roads_gdf = await self.urban_api_client.get_physical_objects_scenario( - # params.scenario_id, token=token, physical_object_function_id=ROADS_ID) - # - # roads_gdf = roads_gdf.to_crs(before_blocks.crs) - # roads_gdf = roads_gdf.overlay(before_blocks) - # - # try: - # graph = get_accessibility_graph(before_blocks, "drive") - # except Exception as e: - # raise http_exception( - # 500, "Error generating territory graph", _detail=str(e) - # ) - # acc_mx = calculate_accessibility_matrix(before_blocks, graph) - # dist_mx = calculate_distance_matrix(before_blocks) - # - # general_indicators = calculate_general_indicators(before_blocks) - # demographic_indicators = calculate_demographic_indicators(before_blocks) - # transport_indicators = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) - # engineering_indicators = calculate_engineering_indicators(before_blocks) - # sc_indicators, sp_indicators = calculate_social_indicators(before_blocks, acc_mx, dist_mx, service_types) - # - # indicators_df = pd.concat([ - # general_indicators, - # demographic_indicators, - # transport_indicators, - # engineering_indicators, - # sc_indicators, - # sp_indicators - # ]) - # - # result = [] - # for indicator in indicators_df.index: - # indicator_id = INDICATORS_MAPPING.get(indicator) - # for territory_id in indicators_df.columns: - # if territory_id == 'total': - # continue - # value = indicators_df.loc[indicator, territory_id] - # result.append({ - # 'territory_id': int(territory_id), - # 'indicator_id': indicator_id, - # 'value': value - # }) - # - # long_df = ( - # indicators_df - # .reset_index() - # .rename(columns={"index": "indicator"}) - # .melt(id_vars=["indicator"], var_name="territory_id", value_name="value") - # ) - # - # long_df = long_df[long_df["territory_id"] != "total"].copy() - # - # long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) - # - # def _clean_number(v): - # if v is None or (isinstance(v, float) and np.isnan(v)): - # return None - # try: - # if isinstance(v, (np.floating, float, np.integer, int)): - # if not np.isfinite(float(v)): - # return None - # except Exception: - # pass - # if isinstance(v, (np.integer,)): - # return int(v) - # if isinstance(v, (np.floating,)): - # return float(v) - # return v - # - # long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(_clean_number) - # long_df["indicator_id"] = long_df["indicator_id"].apply(_clean_number) - # long_df["value"] = long_df["value"].apply(_clean_number) - # - # long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()] - # long_df = long_df.fillna(value=0) - # long_df.to_pickle(" long_df.pkl") - # - # result = long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") - # - # return result + async def _get_project_scenarios_by_parent( + self, project_id: int, regional_scenario_id: int, token: str + ) -> list[int]: + """Return scenario_ids for a given project that have the given regional_scenario_id as parent.""" + all_sc = await self.urban_api_client.get_project_scenarios(project_id, token) + return [ + int(s["scenario_id"]) + for s in all_sc + if (s.get("parent_scenario") or {}).get("id") == int(regional_scenario_id) + ] + + async def _build_shared_context( + self, project_id: int, token: str + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, pd.DataFrame]: + """ + Build once per project: + - context blocks (base scenario + sources) + - context territories layer + - prepared service_types_df (adapted + joined with normatives) + """ + territory_id = (await self.urban_api_client.get_all_project_info(project_id, token))["territory"]["id"] + base_sid = await self.urban_api_client.get_base_scenario_id(project_id) + ctx_src, ctx_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=base_sid, source=None, year=None, project=False + ) + + normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ + "radius_availability_meters", + "time_availability_minutes", + "services_per_1000_normative", + "services_capacity_per_1000_normative", + ]].copy() + + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[service_types["infrastructure_type"].notna()].copy() + service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) + + context_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) + context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) + + return context_blocks, context_territories_gdf, service_types async def _compute_for_single_scenario( self, scenario_id: int, + *, context_blocks: gpd.GeoDataFrame, context_territories_gdf: gpd.GeoDataFrame, service_types_df: pd.DataFrame, - proj_func_zone_source: Optional[str], - proj_func_source_year: Optional[int], + proj_src: str, + proj_year: int, token: str, + only_parent_ids: set[int] | None = None, ) -> list[dict]: """ Compute indicators for ONE scenario with shared context. Returns JSON-serializable list of records: [{territory_id, indicator_id, value}, ...] """ + logger.info(f"Computing indicators for scenario_id={scenario_id}") scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - scenario_id, proj_func_zone_source, proj_func_source_year, token + scenario_id, proj_src, proj_year, token ) - - # 2) glue context + scenario (no global fillna!) before_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=True) - # service columns → ints, NaN→0 svc_cols = [c for c in before_blocks.columns if c.startswith(("count_", "capacity_"))] if svc_cols: before_blocks[svc_cols] = ( before_blocks[svc_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype("int64") ) - # land_use → enum - # before_blocks = _coerce_land_use_enum(before_blocks) - - # parent territories context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) assigned = assign_objects(before_blocks, context_territories_gdf.rename(columns={"parent": "name"})) before_blocks["parent"] = assigned["name"].astype(int) - # aggregated blocksnet columns + ensure missing (id & name) + if only_parent_ids: + before_blocks = before_blocks[before_blocks["parent"].isin(only_parent_ids)].copy() + before_blocks = generate_blocksnet_columns(before_blocks, service_types_df) before_blocks = ensure_missing_id_and_name_columns(before_blocks) + if "population" in before_blocks.columns: + s = pd.to_numeric(before_blocks["population"], errors="coerce").fillna(0) + if pd.api.types.is_float_dtype(s): + s = s.round() + before_blocks["population"] = s.astype("int64") + else: + before_blocks["population"] = 0 - # 3) roads overlay for transport roads_gdf = await self.urban_api_client.get_physical_objects_scenario( scenario_id, token=token, physical_object_function_id=ROADS_ID ) roads_gdf = roads_gdf.to_crs(before_blocks.crs).overlay(before_blocks) - # 4) graph + matrices graph = get_accessibility_graph(before_blocks, "drive") acc_mx = calculate_accessibility_matrix(before_blocks, graph) dist_mx = calculate_distance_matrix(before_blocks) - # 5) indicators st_for_social = service_types_df[ service_types_df["infrastructure_type"].notna() & service_types_df["blocksnet"].notna() @@ -1898,7 +1509,6 @@ async def _compute_for_single_scenario( indicators_df = pd.concat([general, demo, transp, eng, sc, sp]) - # 6) flatten to JSON-safe long_df = ( indicators_df.reset_index() .rename(columns={"index": "indicator"}) @@ -1907,44 +1517,37 @@ async def _compute_for_single_scenario( long_df = long_df[long_df["territory_id"] != "total"].copy() long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) - def _clean_number(v): - if v is None or (isinstance(v, float) and np.isnan(v)): - return None - try: - if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite(float(v)): - return None - except Exception: - pass - if isinstance(v, (np.integer,)): # numpy int -> python int - return int(v) - if isinstance(v, (np.floating,)): - return float(v) - return v - - long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(_clean_number) - long_df["indicator_id"] = long_df["indicator_id"].apply(_clean_number) - long_df["value"] = long_df["value"].apply(_clean_number) + + long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(self.effects_utils.clean_number) + long_df["indicator_id"] = long_df["indicator_id"].apply(self.effects_utils.clean_number) + long_df["value"] = long_df["value"].apply(self.effects_utils.clean_number) long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()].fillna(0) return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") async def evaluate_social_economical_metrics( self, - # params: SocioEconomicByScenarioDTO, params: SocioEconomicByProjectDTO, token: str, ): """ - Multi-scenario mode with shared context: - - Build context once for the project - - For each scenario_id: glue (context ⊕ scenario), compute indicators by parent territories - - Return {scenario_id: [{territory_id, indicator_id, value}, ...]} + Project-level multi-scenario calculation with a shared context. + Return: {scenario_id: [{territory_id, indicator_id, value}, ...]} """ - # 0) Resolve project and context once - # project_id = (await self.urban_api_client.get_scenario_info(params.scenario_id, token))["project"]["project_id"] - territory_id = (await self.urban_api_client.get_all_project_info(params.project_id, token))["territory"]["id"] - params = await self.get_optimal_func_zone_data(params, token) - # norms + service types (once) + + project_id = int(params.project_id) + parent_id = int(params.regional_scenario_id) + + base_sid = await self.urban_api_client.get_base_scenario_id(project_id) + ctx_src, ctx_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=base_sid, source=None, year=None, project=False + ) + context_blocks, _ = await self.aggregate_blocks_layer_context( + base_sid, ctx_src, ctx_year, token + ) + context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) + + territory_id = (await self.urban_api_client.get_all_project_info(project_id, token))["territory"]["id"] normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ "radius_availability_meters", "time_availability_minutes", @@ -1957,34 +1560,37 @@ async def evaluate_social_economical_metrics( service_types = service_types[service_types["infrastructure_type"].notna()].copy() service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) - # shared context once - context_blocks, _ = await self.aggregate_blocks_layer_context( - params.scenario_id, params.context_func_zone_source, params.context_func_source_year, token + scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + target = [ + s for s in scenarios + if (s.get("parent_scenario") or {}).get("id") == parent_id + ] + logger.info( + f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})" ) - # parent territories layer once - context_territories_gdf = await get_context_territories(params.project_id, token, self.urban_api_client) + only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None - # 1) list of scenarios to compute - scenarios = [int(params.scenario_id)] - if getattr(params, "project_id", None) and getattr(params, "regional_scenario_id", None): - all_sc = await self.urban_api_client.get_project_scenarios(int(params.project_id), token) - parent_id = int(params.regional_scenario_id) - scenarios = [int(s["scenario_id"]) for s in all_sc if - (s.get("parent_scenario") or {}).get("id") == parent_id] + results: dict[int, list[dict]] = {} + + for s in target: + sid = int(s["scenario_id"]) + proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=None, year=None, project=True + ) - # 2) per-scenario compute with shared context - results: Dict[int, list[dict]] = {} - for sid in scenarios: records = await self._compute_for_single_scenario( sid, context_blocks=context_blocks, context_territories_gdf=context_territories_gdf, service_types_df=service_types, - proj_func_zone_source=params.proj_func_zone_source, - proj_func_source_year=params.proj_func_source_year, + proj_src=proj_src, + proj_year=proj_year, token=token, + only_parent_ids=only_parent_ids, ) results[sid] = records - return results \ No newline at end of file + return results + + diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index aca61c7..18f7f23 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -165,14 +165,23 @@ async def get_scenario_services( self, scenario_id: int, service_types: pd.DataFrame, token: str ): try: - gdf = await self.client.get_services_scenario( + res = await self.client.get_services_scenario( scenario_id, centers_only=True, token=token ) + features = res.get("features") or [] + + if not features: + logger.info(f"Scenario {scenario_id}: no services (features=[]) -> returning empty dict") + return {} + + gdf = gpd.GeoDataFrame.from_features(features, crs="EPSG:4326").set_index("service_id", drop=False) gdf = gdf.to_crs(gdf.estimate_utm_crs()) + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) - return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + return {st: impute_services(g, st) for st, g in gdfs.items()} + except Exception as e: - logger.exception(e) + logger.exception(f"Failed to fetch/process services for scenario {scenario_id}: {str(e)}") raise http_exception( 404, f"No services found for scenario {scenario_id}", diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 061ed2d..ef122ad 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -10,7 +10,7 @@ from app.clients.urban_api_client import UrbanAPIClient from app.common.caching.caching_service import FileCache -from app.common.utils.ids_convertation import EffectsUtils +from app.common.utils.effects_utils import EffectsUtils from app.effects_api.constants.const import SERVICE_TYPES_MAPPING _SOCIAL_VALUES_BY_ST: Dict[int, Optional[List[int]]] = {} @@ -19,6 +19,7 @@ name: sid for sid, name in SERVICE_TYPES_MAPPING.items() } _VALID_SERVICE_NAMES: set[str] = set(_SERVICE_NAME_TO_ID.keys()) +_NUM_SUFFIX_RE = re.compile(r"^\d+$") for st_id, st_name in SERVICE_TYPES_MAPPING.items(): if st_name is None: @@ -127,7 +128,7 @@ async def build_en_to_ru_map(service_types_df: pd.DataFrame) -> dict[str, str]: continue if st_id in service_types_df.index: ru_name = service_types_df.loc[st_id, "name"] - if isinstance(ru_name, pd.Series): # на всякий + if isinstance(ru_name, pd.Series): ru_name = ru_name.iloc[0] if isinstance(ru_name, str) and ru_name.strip(): russian_names_dict[en_key] = ru_name @@ -194,27 +195,8 @@ def adapt_social_service_types_df( return df - -# def generate_blocksnet_columns(blocks_gdf: gpd.GeoDataFrame, service_types_df: pd.DataFrame) -> gpd.GeoDataFrame: -# st_df = service_types_df[~service_types_df.blocksnet.isna()].copy() -# st_df['service_type_id'] = st_df.index -# agg_df = st_df.groupby('blocksnet').agg({'service_type_id': lambda s: list(s)}) -# new_columns = {} -# for st_name, row in agg_df.iterrows(): -# st_ids = row['service_type_id'] -# for prefix in ['count', 'capacity']: -# sum_df = blocks_gdf[[f'{prefix}_{st_id}' for st_id in st_ids]].sum(axis=1) -# new_columns[f'{prefix}_{st_name}'] = sum_df -# new_columns_df = pd.DataFrame.from_dict(new_columns) -# -# df = pd.concat([blocks_gdf, new_columns_df], axis=1) -# return cast(gpd.GeoDataFrame, df) - -_NUM_SUFFIX_RE = re.compile(r"^\d+$") - def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], dict[str, int]]: """Build lookups from service type names to ids.""" - # index must be service_type_id if service_types_df.index.name is None: service_types_df = service_types_df.copy() service_types_df.index.name = "service_type_id" @@ -224,7 +206,6 @@ def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], di name_to_id: dict[str, int] = {} blocksnet_to_id: dict[str, int] = {} - # prefer unique mappings; if duplicates exist, last one wins (and we warn) for sid, row in service_types_df.loc[id_series.index].iterrows(): try: sid_int = int(sid) @@ -235,14 +216,14 @@ def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], di if isinstance(nm, str) and nm: prev = name_to_id.get(nm) if prev is not None and prev != sid_int: - logger.warning("Duplicate mapping for name '%s': %s -> %s (last wins)", nm, prev, sid_int) + logger.warning(f"Duplicate mapping for name {nm}: {prev} -> {sid_int} (last wins)", nm, prev, sid_int) name_to_id[nm] = sid_int bn = row.get("blocksnet") if isinstance(bn, str) and bn: prev = blocksnet_to_id.get(bn) if prev is not None and prev != sid_int: - logger.warning("Duplicate mapping for blocksnet '%s': %s -> %s (last wins)", bn, prev, sid_int) + logger.warning(f"Duplicate mapping for blocksnet {bn}: {prev} -> {sid_int} (last wins)") blocksnet_to_id[bn] = sid_int return name_to_id, blocksnet_to_id @@ -268,17 +249,15 @@ def _rename_non_id_columns_to_ids( continue suffix = col[len(pref):] - # already numeric id if _NUM_SUFFIX_RE.match(suffix): - break # nothing to do + break - # try blocksnet, then name sid = blocksnet_to_id.get(suffix) or name_to_id.get(suffix) if sid is not None: rename_map[col] = f"{pref}{sid}" else: logger.warning("No service_id mapping found for column '%s'", col) - break # handled this prefix + break if rename_map: df = df.rename(columns=rename_map) @@ -312,11 +291,9 @@ def ensure_missing_id_and_name_columns( GeoDataFrame Updated dataframe containing all required columns. """ - # 1. Собираем ID и имена ids = sorted(int(sid) for sid in SERVICE_TYPES_MAPPING.keys()) names = [name for name in SERVICE_TYPES_MAPPING.values() if isinstance(name, str) and name.strip()] - # 2. Формируем список колонок, которые должны быть required_cols = [] for sid in ids: required_cols.append(f"{count_prefix}_{sid}") @@ -325,7 +302,6 @@ def ensure_missing_id_and_name_columns( required_cols.append(f"{count_prefix}_{name}") required_cols.append(f"{capacity_prefix}_{name}") - # 3. Определяем, чего не хватает missing = [c for c in required_cols if c not in blocks_gdf.columns] if missing: logger.info(f"Creating missing service columns (zeros): {missing}") diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 0cacf31..ce12211 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -14,7 +14,6 @@ ) from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.ids_convertation import EffectsUtils from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client from .dto.development_dto import ContextDevelopmentDTO from .modules.service_type_service import get_services_with_ids_from_layer From 503eaace79a1674e90802a43a29911ac0db1c07b Mon Sep 17 00:00:00 2001 From: voronapxl Date: Mon, 27 Oct 2025 21:09:56 +0300 Subject: [PATCH 108/161] feat(effects_logic): 1. Refactor effects_service.py 2. Caching logic for context layer in pkl, parquet 3. Context service is now a class and some of the effects_service.py has moved into it 4.Scenario service is now a class and some of the effects_service.py has moved into it --- app/common/caching/caching_service.py | 76 ++- app/common/utils/effects_utils.py | 38 +- app/common/utils/geodata.py | 3 +- app/dependencies.py | 4 +- app/effects_api/effects_service.py | 565 +++--------------- app/effects_api/modules/context_service.py | 452 ++++++++++---- app/effects_api/modules/scenario_service.py | 100 +++- .../modules/service_type_service.py | 5 +- app/effects_api/modules/services_service.py | 50 ++ app/effects_api/modules/task_service.py | 1 + app/system_router/system_controller.py | 1 - 11 files changed, 661 insertions(+), 634 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index e7c34ec..1e6bb32 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -3,7 +3,9 @@ import re from datetime import datetime, timedelta from pathlib import Path -from typing import Any +from typing import Any, Literal +import geopandas as gpd +import pandas as pd _CACHE_DIR = Path().absolute() / "__effects_cache__" _CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -116,3 +118,75 @@ def parse_task_id(self, task_id: str): scenario_id = int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw return method, scenario_id, phash + + def _artifact_path( + self, + method: str, + owner_id: int, + phash: str, + name: str, + ext: Literal["parquet", "pkl"], + ) -> Path: + """Build path for a heavy artifact near JSON cache directory.""" + fname = f"artifact__{_safe(method)}__{owner_id}__{phash}__{_safe(name)}.{ext}" + return _CACHE_DIR / fname + + def save_df_artifact( + self, + df: pd.DataFrame, + method: str, + owner_id: int, + params: dict[str, Any], + name: str, + fmt: Literal["parquet", "pkl"] = "parquet", + ) -> Path: + """ + Save a pandas DataFrame as a heavy artifact. + fmt='parquet' (default) is compact and fast; fmt='pkl' as a fallback. + """ + phash = self.params_hash(params) + path = self._artifact_path(method, owner_id, phash, name, "parquet" if fmt == "parquet" else "pkl") + + if fmt == "parquet": + df.to_parquet(path, index=True) + else: + df.to_pickle(path) + + return path + + def load_df_artifact(self, path: Path) -> pd.DataFrame: + """Load a pandas DataFrame artifact by file extension.""" + ext = path.suffix.lower() + if ext == ".parquet": + return pd.read_parquet(path) + elif ext == ".pkl": + return pd.read_pickle(path) + raise ValueError(f"Unsupported artifact extension: {ext}") + def save_gdf_artifact( + self, + gdf: gpd.GeoDataFrame, + method: str, + owner_id: int, + params: dict[str, Any], + name: str, + fmt: Literal["parquet", "pkl"] = "parquet", + ) -> Path: + phash = self.params_hash(params) + ext = "parquet" if fmt == "parquet" else "pkl" + path = self._artifact_path(method, owner_id, phash, name, ext) + + if fmt == "parquet": + gdf.to_parquet(path, index=True) + else: + gdf.to_pickle(path) + + return path + + def load_gdf_artifact(self, path: Path) -> "gpd.GeoDataFrame": + """Load a GeoDataFrame artifact by file extension.""" + ext = path.suffix.lower() + if ext == ".parquet": + return gpd.read_parquet(path) + elif ext == ".pkl": + return pd.read_pickle(path) + raise ValueError(f"Unsupported artifact extension: {ext}") diff --git a/app/common/utils/effects_utils.py b/app/common/utils/effects_utils.py index 6b5a010..8c87499 100644 --- a/app/common/utils/effects_utils.py +++ b/app/common/utils/effects_utils.py @@ -3,8 +3,8 @@ import numpy as np import pandas as pd -from blocksnet.enums import LandUse -from loguru import logger +from blocksnet.optimization.services import AreaSolution, Facade +import geopandas as gpd from app.clients.urban_api_client import UrbanAPIClient @@ -75,3 +75,37 @@ def clean_number(self, v): if isinstance(v, (np.floating,)): return float(v) return v + + def build_facade( + self, + after_blocks: gpd.GeoDataFrame, + acc_mx: pd.DataFrame, + service_types: pd.DataFrame, + ) -> Facade: + blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] + blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() + + var_adapter = AreaSolution(blocks_lus) + + facade = Facade( + blocks_lu=blocks_lus, + blocks_df=after_blocks, + accessibility_matrix=acc_mx, + var_adapter=var_adapter, + ) + + for st_id, row in service_types.iterrows(): + st_name = row["name"] + st_weight = row["infrastructure_weight"] + st_column = f"capacity_{st_name}" + + if st_column in after_blocks.columns: + df = after_blocks.rename(columns={st_column: "capacity"})[ + ["capacity"] + ].fillna(0) + else: + df = after_blocks[[]].copy() + df["capacity"] = 0 + facade.add_service_type(st_name, st_weight, df) + + return facade diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 932ac9f..fd9f975 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -30,7 +30,6 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: def safe_gdf_to_geojson( gdf: gpd.GeoDataFrame, - *, to_epsg: int = 4326, round_ndigits: int = 6, drop_cols: tuple[str, ...] = (), @@ -44,7 +43,7 @@ def safe_gdf_to_geojson( - Ensure all properties are JSON-serializable. - Return parsed dict (FeatureCollection). """ - logger.info("Serializing GeoDataFrame to GeoJSON (EPSG:%s, round=%d)", to_epsg, round_ndigits) + logger.info(f"Serializing GeoDataFrame to GeoJSON (EPSG:{to_epsg}, round={round_ndigits})") gdf2 = gdf.drop(columns=[c for c in drop_cols if c in gdf.columns]).copy() gdf2 = gdf2.to_crs(to_epsg) gdf2.geometry = round_coords(gdf2.geometry, round_ndigits) diff --git a/app/dependencies.py b/app/dependencies.py index 54e1dd8..1a96d2f 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -9,6 +9,7 @@ from app.common.caching.caching_service import FileCache from app.common.utils.effects_utils import EffectsUtils from app.effects_api.effects_service import EffectsService +from app.effects_api.modules.context_service import ContextService from app.effects_api.modules.scenario_service import ScenarioService absolute_app_path = Path().absolute() @@ -29,4 +30,5 @@ file_cache = FileCache() scenario_service = ScenarioService(urban_api_client) effects_utils = EffectsUtils(urban_api_client) -effects_service = EffectsService(urban_api_client, file_cache, scenario_service, effects_utils) +context_service = ContextService(urban_api_client, file_cache) +effects_service = EffectsService(urban_api_client, file_cache, scenario_service, context_service, effects_utils) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 635aaa2..04cc664 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -4,26 +4,23 @@ import geopandas as gpd import numpy as np import pandas as pd +from blocksnet.analysis.indicators import calculate_development_indicators from blocksnet.analysis.indicators.socio_economic import calculate_general_indicators, calculate_demographic_indicators, \ calculate_transport_indicators, calculate_engineering_indicators, calculate_social_indicators from blocksnet.analysis.land_use.prediction import SpatialClassifier from blocksnet.analysis.provision import competitive_provision, provision_strong_total -from blocksnet.blocks.aggregation import aggregate_objects -from blocksnet.blocks.assignment import assign_land_use, assign_objects +from blocksnet.blocks.assignment import assign_objects from blocksnet.config import service_types_config from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import SocialRegressor +from blocksnet.machine_learning.regression import SocialRegressor, DensityRegressor from blocksnet.optimization.services import ( - AreaSolution, - Facade, TPEOptimizer, WeightedConstraints, WeightedObjective, GradientChooser, ) from blocksnet.relations import ( calculate_accessibility_matrix, - get_accessibility_context, - get_accessibility_graph, calculate_distance_matrix, + get_accessibility_graph, calculate_distance_matrix, generate_adjacency_graph, ) from loguru import logger @@ -31,43 +28,27 @@ from app.effects_api.modules.service_type_service import ( adapt_service_types, build_en_to_ru_map, - remap_properties_keys_in_geojson, adapt_social_service_types_df, generate_blocksnet_columns, ensure_missing_id_and_name_columns, + remap_properties_keys_in_geojson, generate_blocksnet_columns, ensure_missing_id_and_name_columns, ) - +from .modules.context_service import ContextService from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache -from ..common.dto.models import SourceYear from ..common.exceptions.http_exception_wrapper import http_exception from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, _ensure_block_index from .constants.const import ( INFRASTRUCTURES_WEIGHTS, - LAND_USE_RULES, MAX_EVALS, MAX_RUNS, PRED_VALUE_RU, - PROB_COLS_EN_TO_RU, SOCIAL_INDICATORS_MAPPING, ROADS_ID, INDICATORS_MAPPING, SERVICE_TYPES_MAPPING, -) + PROB_COLS_EN_TO_RU, ROADS_ID, INDICATORS_MAPPING, ) from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, ) from .dto.socio_economic_project_dto import ( - SocioEconomicByProjectComputedDTO, SocioEconomicByProjectDTO, ) -from .dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO from .dto.transformation_effects_dto import TerritoryTransformationDTO -from .modules.context_service import ( - get_context_blocks, - get_context_buildings, - get_context_functional_zones, - get_context_services, get_context_territories, -) -from .schemas.development_response_schema import DevelopmentResponseSchema -from .schemas.socio_economic_response_schema import ( - SocioEconomicResponseSchema, - SocioEconomicSchema, -) from ..common.utils.effects_utils import EffectsUtils @@ -77,6 +58,7 @@ def __init__( urban_api_client: UrbanAPIClient, cache: FileCache, scenario_service: ScenarioService, + context_service: ContextService, effects_utils: EffectsUtils ): self.__name__ = "EffectsService" @@ -84,6 +66,7 @@ def __init__( self.urban_api_client = urban_api_client self.cache = cache self.scenario = scenario_service + self.context = context_service self.effects_utils = effects_utils async def build_hash_params( @@ -154,389 +137,12 @@ async def get_optimal_func_zone_data( return params return params - async def load_blocks_scenario( - self, scenario_id: int, token: str - ) -> gpd.GeoDataFrame: - gdf = await self.scenario.get_scenario_blocks(scenario_id, token) - gdf["site_area"] = gdf.area - return gdf - - async def assign_land_use_to_blocks_scenario( - self, - blocks: gpd.GeoDataFrame, - scenario_id: int, - source: str | None, - year: int | None, - token: str, - ) -> gpd.GeoDataFrame: - fzones = await self.scenario.get_scenario_functional_zones( - scenario_id, token, source, year - ) - fzones = fzones.to_crs(blocks.crs) - lu = assign_land_use(blocks, fzones, LAND_USE_RULES) - return blocks.join(lu.drop(columns=["geometry"])) - - async def enrich_with_buildings_scenario( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - buildings = await self.scenario.get_scenario_buildings(scenario_id, token) - if buildings is None: - blocks["count_buildings"] = 0 - return blocks, None - - buildings = buildings.to_crs(blocks.crs) - blocks_bld, _ = aggregate_objects(blocks, buildings) - - blocks = blocks.join( - blocks_bld.drop(columns=["geometry"]).rename( - columns={"count": "count_buildings"} - ) - ) - blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) - if "is_living" not in blocks.columns: - blocks["is_living"] = None - return blocks, buildings - - async def enrich_with_services_scenario( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str - ) -> gpd.GeoDataFrame: - stypes = await self.urban_api_client.get_service_types() - stypes = await adapt_service_types(stypes, self.urban_api_client) - sdict = await self.scenario.get_scenario_services(scenario_id, stypes, token) - - for stype, services in sdict.items(): - services = services.to_crs(blocks.crs) - b_srv, _ = aggregate_objects(blocks, services) - b_srv[["capacity", "count"]] = ( - b_srv[["capacity", "count"]].fillna(0).astype(int) - ) - blocks = blocks.join( - b_srv.drop(columns=["geometry"]).rename( - columns={"capacity": f"capacity_{stype}", "count": f"count_{stype}"} - ) - ) - return blocks - - async def aggregate_blocks_layer_scenario( - self, - scenario_id: int, - source: str | None = None, - year: int | None = None, - token: str | None = None, - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - - logger.info(f"[Scenario {scenario_id}] load blocks") - blocks = await self.load_blocks_scenario(scenario_id, token) - - logger.info("Assigning land-use for scenario") - blocks = await self.assign_land_use_to_blocks_scenario( - blocks, scenario_id, source, year, token - ) - - logger.info("Aggregating buildings for scenario") - blocks, buildings = await self.enrich_with_buildings_scenario( - blocks, scenario_id, token - ) - - logger.info("Aggregating services for scenario") - blocks = await self.enrich_with_services_scenario(blocks, scenario_id, token) - - blocks["is_project"] = True - logger.success(f"[scenario {scenario_id}] blocks layer ready") - - return blocks, buildings - - async def load_context_blocks( - self, scenario_id: int, token: str - ) -> tuple[gpd.GeoDataFrame, int]: - project_id = await self.urban_api_client.get_project_id(scenario_id, token) - blocks = await get_context_blocks( - project_id, scenario_id, token, self.urban_api_client - ) - blocks["site_area"] = blocks.area - return blocks, project_id - - async def assign_land_use_context( - self, - blocks: gpd.GeoDataFrame, - scenario_id: int, - source: str | None, - year: int | None, - token: str, - ) -> gpd.GeoDataFrame: - fzones = await get_context_functional_zones( - scenario_id, source, year, token, self.urban_api_client - ) - fzones = fzones.to_crs(blocks.crs) - lu = assign_land_use(blocks, fzones, LAND_USE_RULES) - return blocks.join(lu.drop(columns=["geometry"])) - - async def enrich_with_context_buildings( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - - buildings = await get_context_buildings( - scenario_id, token, self.urban_api_client - ) - if buildings is None: - blocks["count_buildings"] = 0 - blocks["is_living"] = None - return blocks, None - - buildings = buildings.to_crs(blocks.crs) - agg, _ = aggregate_objects(blocks, buildings) - - blocks = blocks.join( - agg.drop(columns=["geometry"]).rename(columns={"count": "count_buildings"}) - ) - blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) - if "is_living" not in blocks.columns: - blocks["is_living"] = None - - return blocks, buildings - - async def enrich_with_context_services( - self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str - ) -> gpd.GeoDataFrame: - - stypes = await self.urban_api_client.get_service_types() - stypes = await adapt_service_types(stypes, self.urban_api_client) - sdict = await get_context_services( - scenario_id, stypes, token, self.urban_api_client - ) - - for stype, services in sdict.items(): - services = services.to_crs(blocks.crs) - b_srv, _ = aggregate_objects(blocks, services) - b_srv[["capacity", "count"]] = ( - b_srv[["capacity", "count"]].fillna(0).astype(int) - ) - - blocks = blocks.join( - b_srv.drop(columns=["geometry"]).rename( - columns={ - "capacity": f"capacity_{stype}", - "count": f"count_{stype}", - } - ) - ) - return blocks - - async def aggregate_blocks_layer_context( - self, - scenario_id: int, - source: str | None = None, - year: int | None = None, - token: str | None = None, - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: - - logger.info(f"[Context {scenario_id}] load blocks") - blocks, project_id = await self.load_context_blocks(scenario_id, token) - - logger.info("Assigning land-use for context") - blocks = await self.assign_land_use_context( - blocks, scenario_id, source, year, token - ) - - logger.info("Aggregating buildings for context") - blocks, buildings = await self.enrich_with_context_buildings( - blocks, scenario_id, token - ) - - logger.info("Aggregating services for context") - blocks = await self.enrich_with_context_services(blocks, scenario_id, token) - - logger.success(f"[Context {scenario_id}] blocks layer ready") - return blocks, buildings - - async def get_services_layer(self, scenario_id: int, token: str): - """ - Fetch every service layer for a scenario, aggregate counts/capacities - into the scenario blocks and return the resulting block layer. - - Params: - scenario_id : int - Scenario whose services are queried and aggregated. - - Returns: - gpd.GeoDataFrame - Scenario block layer with additional columns - `capacity_` and `count_` for each - detected service category. - """ - blocks = await self.scenario.get_scenario_blocks(scenario_id, token) - blocks_crs = blocks.crs - logger.info( - f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}" - ) - service_types = await self.urban_api_client.get_service_types() - logger.info(f"{service_types}") - services_dict = await self.scenario.get_scenario_services( - scenario_id, service_types, token - ) - - for service_type, services in services_dict.items(): - services = services.to_crs(blocks_crs) - blocks_services, _ = aggregate_objects(blocks, services) - blocks_services["capacity"] = ( - blocks_services["capacity"].fillna(0).astype(int) - ) - blocks_services["count"] = ( - blocks_services["count"].fillna(0).astype(int) - ) - blocks = blocks.join( - blocks_services.drop(columns=["geometry"]).rename( - columns={ - "capacity": f"capacity_{service_type}", - "count": f"count_{service_type}", - } - ) - ) - logger.info( - f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}" - ) - return blocks - - async def evaluate_master_plan_by_project( - self, params: SocioEconomicByProjectDTO, token: str - ) -> SocioEconomicResponseSchema: - logger.info( - f"[Effects] project mode: project_id={params.project_id}, regional_scenario_id={params.regional_scenario_id}" - ) - - project_info = await self.urban_api_client.get_all_project_info( - params.project_id, token - ) - context_territories = project_info.get("properties", {}).get("context") or [] - - base_sid = await self.urban_api_client.get_base_scenario_id(params.project_id) - ctx_src, ctx_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=base_sid, source=None, year=None, project=False - ) - ) - context_blocks, _ = await self.aggregate_blocks_layer_context( - base_sid, ctx_src, ctx_year, token - ) - - context_split: Optional[Dict[int, SocioEconomicSchema]] = None - if params.split and context_territories: - context_split = {} - for tid in context_territories: - territory = gpd.GeoDataFrame( - geometry=[await self.urban_api_client.get_territory_geometry(tid)], - crs=4326, - ) - ter_blocks = ( - context_blocks.sjoin( - territory.to_crs(territory.estimate_utm_crs()), how="left" - ) - .dropna(subset="index_right") - .drop(columns="index_right") - ) - ter_data = [ - ter_blocks.drop(columns=["land_use", "geometry"]).sum().to_dict() - ] - ter_input = pd.DataFrame(ter_data) - context_split[tid] = await self.run_social_reg_prediction( - ter_blocks, ter_input - ) - - scenarios = await self.urban_api_client.get_project_scenarios( - params.project_id, token - ) - target = [ - s - for s in scenarios - if (s.get("parent_scenario") or {}).get("id") == params.regional_scenario_id - ] - logger.info( - f"[Effects] matched {len(target)} scenarios in project {params.project_id} (parent={params.regional_scenario_id})" - ) - - landuse_cols = [ - "residential", - "business", - "recreation", - "industrial", - "transport", - "special", - "agriculture", - ] - results_by_scenario: Dict[int, Dict[str, Any]] = {} - project_sources: Dict[int, SourceYear] = {} - - for s in target: - sid = s["scenario_id"] - proj_src, proj_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=None, year=None, project=True - ) - ) - project_sources[sid] = SourceYear(source=proj_src, year=proj_year) - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( - sid, proj_src, proj_year, token - ) - scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - - blocks = gpd.GeoDataFrame( - pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs, - ) - blocks[landuse_cols] = blocks[landuse_cols].clip(upper=1) - development_df = await self.run_development_parameters(blocks) - - add_cols = [ - "build_floor_area", - "footprint_area", - "living_area", - "non_living_area", - "population", - ] - blocks[add_cols] = development_df[add_cols].values - - for lu in LandUse: - blocks[lu.value] = blocks[lu.value] * blocks["site_area"] - - main_data = [blocks.drop(columns=["land_use", "geometry"]).sum().to_dict()] - main_input = pd.DataFrame(main_data) - main_res: SocioEconomicSchema = await self.run_social_reg_prediction( - blocks, main_input - ) - - results_by_scenario[sid] = main_res.socio_economic_prediction - - computed_params = SocioEconomicByProjectComputedDTO( - project_id=params.project_id, - regional_scenario_id=params.regional_scenario_id, - split=params.split, - context_func_zone_source=ctx_src, - context_func_source_year=ctx_year, - project_sources=project_sources, - ) - - return SocioEconomicResponseSchema( - socio_economic_prediction=results_by_scenario, - split_prediction=context_split or None, - params_data=computed_params, - ) - - async def _get_accessibility_context( - self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float - ) -> list[int]: - blocks["population"] = blocks["population"].fillna(0) - project_blocks = blocks.copy() - context_blocks = get_accessibility_context( - acc_mx, project_blocks, accessibility, out=False, keep=True - ) - return list(context_blocks.index) - async def _assess_provision( self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, service_type: str ) -> gpd.GeoDataFrame: _, demand, accessibility = service_types_config[service_type].values() blocks["is_project"] = blocks["is_project"].fillna(False).astype(bool) - context_ids = await self._get_accessibility_context( + context_ids = await self.context.get_accessibility_context( blocks, acc_mx, accessibility ) capacity_column = f"capacity_{service_type}" @@ -616,7 +222,7 @@ async def territory_transformation_scenario_before( ) base_scenario_blocks, base_scenario_buildings = ( - await self.aggregate_blocks_layer_scenario( + await self.scenario.aggregate_blocks_layer_scenario( base_scenario_id, base_src, base_year, token ) ) @@ -671,39 +277,55 @@ async def territory_transformation_scenario_before( return prov_gdfs_before - def _build_facade( - self, - after_blocks: gpd.GeoDataFrame, - acc_mx: pd.DataFrame, - service_types: pd.DataFrame, - ) -> Facade: - blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] - blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() - - var_adapter = AreaSolution(blocks_lus) - - facade = Facade( - blocks_lu=blocks_lus, - blocks_df=after_blocks, - accessibility_matrix=acc_mx, - var_adapter=var_adapter, - ) - for st_id, row in service_types.iterrows(): - st_name = row["name"] - st_weight = row["infrastructure_weight"] - st_column = f"capacity_{st_name}" - if st_column in after_blocks.columns: - df = after_blocks.rename(columns={st_column: "capacity"})[ - ["capacity"] - ].fillna(0) - else: - df = after_blocks[[]].copy() - df["capacity"] = 0 - facade.add_service_type(st_name, st_weight, df) + @staticmethod + async def run_development_parameters( + blocks_gdf: gpd.GeoDataFrame, + ) -> pd.DataFrame: + """ + Compute core *development* indicators (FSI, GSI, MXI, etc.) for each + block and derive population estimates. + + The routine: + 1. Clips every land-use share to [0, 1]. + 2. Generates an adjacency graph (10 m tolerance). + 3. Uses DensityRegressor to predict density indices. + 4. Converts indices into built-area, footprint, living area, etc. + 5. Estimates population by living_area // 20. + + Params: + blocks_gdf : gpd.GeoDataFrame + Block layer already containing per-land-use **shares** + (0 ≤ share ≤ 1) and `site_area`. + + Returns: + pd.DataFrame with added columns: + `build_floor_area`, `footprint_area`, `living_area`, + `non_living_area`, `population`, plus the original density indices. + """ + for lu in LandUse: + blocks_gdf[lu.value] = blocks_gdf[lu.value].apply(lambda v: min(v, 1)) + + adjacency_graph = generate_adjacency_graph(blocks_gdf, 10) + dr = DensityRegressor() + + density_df = dr.evaluate(blocks_gdf, adjacency_graph) + density_df.loc[density_df["fsi"] < 0, "fsi"] = 0 + + density_df.loc[density_df["gsi"] < 0, "gsi"] = 0 + density_df.loc[density_df["gsi"] > 1, "gsi"] = 1 + + density_df.loc[density_df["mxi"] < 0, "mxi"] = 0 + density_df.loc[density_df["mxi"] > 1, "mxi"] = 1 - return facade + density_df.loc[blocks_gdf["residential"] == 0, "mxi"] = 0 + density_df["site_area"] = blocks_gdf["site_area"] + + development_df = calculate_development_indicators(density_df) + development_df["population"] = development_df["living_area"] // 20 + + return development_df async def territory_transformation_scenario_after( self, @@ -755,7 +377,7 @@ async def territory_transformation_scenario_after( ~service_types["infrastructure_type"].isna() ].copy() - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, @@ -795,7 +417,7 @@ async def territory_transformation_scenario_after( after_blocks["population"] = pd.to_numeric( after_blocks["population"], errors="coerce" ).fillna(0) - facade = self._build_facade(after_blocks, acc_mx, service_types) + facade = self.effects_utils.build_facade(after_blocks, acc_mx, service_types) services_weights = service_types.set_index("name")[ "infrastructure_weight" @@ -887,7 +509,7 @@ async def territory_transformation( is_based = info["is_based"] updated_at = info["updated_at"] - context_blocks, _ = await self.aggregate_blocks_layer_context( + context_blocks, _ = await self.context.aggregate_blocks_layer_context( params.scenario_id, params.context_func_zone_source, params.context_func_source_year, @@ -936,7 +558,7 @@ async def values_transformation( info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] - context_blocks, _ = await self.aggregate_blocks_layer_context( + context_blocks, _ = await self.context.aggregate_blocks_layer_context( params.scenario_id, params.context_func_zone_source, params.context_func_source_year, @@ -969,7 +591,7 @@ async def values_transformation( best_x = opt_cached["data"]["best_x"] - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( params.scenario_id, params.proj_func_zone_source, params.proj_func_source_year, @@ -1019,7 +641,7 @@ async def values_transformation( * service_types["infrastructure_weight"] ) - facade = self._build_facade(after_blocks, acc_mx, service_types) + facade = self.effects_utils.build_facade(after_blocks, acc_mx, service_types) test_blocks: gpd.GeoDataFrame = after_blocks.loc[ list(facade._blocks_lu.keys()) ].copy() @@ -1259,14 +881,14 @@ def _result_to_df(payload: Any) -> pd.DataFrame: ): return _result_to_df(cached_base["data"]["result"]) - context_blocks, _ = await self.aggregate_blocks_layer_context( + context_blocks, _ = await self.context.aggregate_blocks_layer_context( params.scenario_id, params_base.context_func_zone_source, params_base.context_func_source_year, token, ) - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( params_base.scenario_id, params_base.proj_func_zone_source, params_base.proj_func_source_year, @@ -1409,42 +1031,9 @@ async def _get_project_scenarios_by_parent( if (s.get("parent_scenario") or {}).get("id") == int(regional_scenario_id) ] - async def _build_shared_context( - self, project_id: int, token: str - ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, pd.DataFrame]: - """ - Build once per project: - - context blocks (base scenario + sources) - - context territories layer - - prepared service_types_df (adapted + joined with normatives) - """ - territory_id = (await self.urban_api_client.get_all_project_info(project_id, token))["territory"]["id"] - base_sid = await self.urban_api_client.get_base_scenario_id(project_id) - ctx_src, ctx_year = await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=base_sid, source=None, year=None, project=False - ) - - normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ - "radius_availability_meters", - "time_availability_minutes", - "services_per_1000_normative", - "services_capacity_per_1000_normative", - ]].copy() - - service_types = await self.urban_api_client.get_service_types() - service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[service_types["infrastructure_type"].notna()].copy() - service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) - - context_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) - context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) - - return context_blocks, context_territories_gdf, service_types - async def _compute_for_single_scenario( self, scenario_id: int, - *, context_blocks: gpd.GeoDataFrame, context_territories_gdf: gpd.GeoDataFrame, service_types_df: pd.DataFrame, @@ -1459,7 +1048,7 @@ async def _compute_for_single_scenario( """ logger.info(f"Computing indicators for scenario_id={scenario_id}") - scenario_blocks, _ = await self.aggregate_blocks_layer_scenario( + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( scenario_id, proj_src, proj_year, token ) before_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=True) @@ -1538,27 +1127,7 @@ async def evaluate_social_economical_metrics( project_id = int(params.project_id) parent_id = int(params.regional_scenario_id) - base_sid = await self.urban_api_client.get_base_scenario_id(project_id) - ctx_src, ctx_year = await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=base_sid, source=None, year=None, project=False - ) - context_blocks, _ = await self.aggregate_blocks_layer_context( - base_sid, ctx_src, ctx_year, token - ) - context_territories_gdf = await get_context_territories(project_id, token, self.urban_api_client) - - territory_id = (await self.urban_api_client.get_all_project_info(project_id, token))["territory"]["id"] - normatives = (await self.urban_api_client.get_territory_normatives(territory_id))[[ - "radius_availability_meters", - "time_availability_minutes", - "services_per_1000_normative", - "services_capacity_per_1000_normative", - ]].copy() - - service_types = await self.urban_api_client.get_service_types() - service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[service_types["infrastructure_type"].notna()].copy() - service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) + context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, token) scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) target = [ diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 7eaa7f2..0f19266 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,141 +1,347 @@ import asyncio +from pathlib import Path +from typing import Tuple, Dict import geopandas as gpd import pandas as pd +from blocksnet.relations import get_accessibility_context +from loguru import logger + +from blocksnet.blocks.aggregation import aggregate_objects +from blocksnet.blocks.assignment import assign_land_use from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services from app.clients.urban_api_client import UrbanAPIClient -from app.common.exceptions.http_exception_wrapper import http_exception +from app.common.caching.caching_service import FileCache from app.common.utils.geodata import get_best_functional_zones_source -from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID +from app.effects_api.constants.const import ( + LIVING_BUILDINGS_ID, + ROADS_ID, + WATER_ID, + LAND_USE_RULES, SOCIAL_INDICATORS_MAPPING, +) from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.scenario_service import close_gaps +from app.effects_api.modules.service_type_service import adapt_service_types, adapt_social_service_types_df from app.effects_api.modules.services_service import adapt_services + _SOURCES_PRIORITY = ["PZZ", "OSM", "User"] -async def _get_project_boundaries( - project_id: int, token: str, client: UrbanAPIClient -) -> gpd.GeoDataFrame: - geom = await client.get_project_geometry(project_id, token=token) - return gpd.GeoDataFrame(geometry=[geom], crs=4326) - - -async def _get_context_boundaries( - project_id: int, token: str, client: UrbanAPIClient -) -> gpd.GeoDataFrame: - project = await client.get_project(project_id, token) - context_ids = project["properties"]["context"] - geometries = [ - await client.get_territory_geometry(territory_id) - for territory_id in context_ids - ] - return gpd.GeoDataFrame(geometry=geometries, crs=4326) - - -async def _get_context_roads(scenario_id: int, token: str, client: UrbanAPIClient): - gdf = await client.get_physical_objects( - scenario_id, token, physical_object_function_id=ROADS_ID - ) - return gdf[["geometry"]].reset_index(drop=True) - - -async def _get_context_water(scenario_id: int, token: str, client: UrbanAPIClient): - gdf = await client.get_physical_objects( - scenario_id, token=token, physical_object_function_id=WATER_ID - ) - return gdf[["geometry"]].reset_index(drop=True) - - -async def _get_context_blocks( - scenario_id: int, boundaries: gpd.GeoDataFrame, token: str, client: UrbanAPIClient -) -> gpd.GeoDataFrame: - crs = boundaries.crs - boundaries.geometry = boundaries.buffer(-1) - - water, roads = await asyncio.gather( - _get_context_water(scenario_id, token, client), - _get_context_roads(scenario_id, token, client), - ) - - water = water.to_crs(crs) - roads = roads.to_crs(crs) - roads.geometry = close_gaps(roads, 1) - - lines, polygons = preprocess_urban_objects(roads, None, water) - blocks = cut_urban_blocks(boundaries, lines, polygons) - return blocks - - -async def get_context_blocks( - project_id: int, scenario_id: int, token: str, client: UrbanAPIClient -) -> gpd.GeoDataFrame: - project_boundaries, context_boundaries = await asyncio.gather( - _get_project_boundaries(project_id, token, client), - _get_context_boundaries(project_id, token, client), - ) - - crs = context_boundaries.estimate_utm_crs() - context_boundaries = context_boundaries.to_crs(crs) - project_boundaries = project_boundaries.to_crs(crs) - - context_boundaries = context_boundaries.overlay( - project_boundaries, how="difference" - ) - return await _get_context_blocks(scenario_id, context_boundaries, token, client) - - -async def get_context_functional_zones( - scenario_id: int, - source: str | None, - year: int | None, - token: str, - client: UrbanAPIClient, -) -> gpd.GeoDataFrame: - sources_df = await client.get_functional_zones_sources(scenario_id, token) - year, source = await get_best_functional_zones_source(sources_df, source, year) - functional_zones = await client.get_functional_zones( - scenario_id, year, source, token - ) - functional_zones = functional_zones.loc[ - functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) - ].reset_index(drop=True) - return adapt_functional_zones(functional_zones) - - -async def get_context_buildings(scenario_id: int, token: str, client: UrbanAPIClient): - gdf = await client.get_physical_objects( - scenario_id, - token, - physical_object_type_id=LIVING_BUILDINGS_ID, - centers_only=True, - ) - if gdf is None or gdf.empty: - raise http_exception(404, "No living buildings found for given scenario") - gdf = adapt_buildings(gdf.reset_index(drop=True)) - crs = gdf.estimate_utm_crs() - return impute_buildings(gdf.to_crs(crs)).to_crs(4326) - - -async def get_context_services( - scenario_id: int, service_types: pd.DataFrame, token: str, client: UrbanAPIClient -): - gdf = await client.get_services(scenario_id, token, centers_only=True) - gdf = gdf.to_crs(gdf.estimate_utm_crs()) - gdfs = adapt_services(gdf.reset_index(drop=True), service_types) - return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} - - -async def get_context_territories(project_id : int, token: str, client: UrbanAPIClient) -> gpd.GeoDataFrame: - project = await client.get_all_project_info(project_id, token) - context_ids = project['properties']['context'] - data = [{ - 'parent': territory_id, - 'geometry': await client.get_territory_geometry(territory_id) - } for territory_id in context_ids] - gdf = gpd.GeoDataFrame(data=data, crs=4326) - return gdf +class ContextService: + """Context layer orchestration (blocks, buildings, services, fzones).""" + + def __init__(self, urban_api_client: UrbanAPIClient, cache: FileCache + ): + self.client = urban_api_client + self.cache = cache + + async def _get_project_boundaries(self, project_id: int, token: str) -> gpd.GeoDataFrame: + """Return project boundary polygon as GeoDataFrame (EPSG:4326).""" + geom = await self.client.get_project_geometry(project_id, token=token) + return gpd.GeoDataFrame(geometry=[geom], crs=4326) + + async def _get_context_boundaries(self, project_id: int, token: str) -> gpd.GeoDataFrame: + """Return union of context territories as GeoDataFrame (EPSG:4326).""" + project = await self.client.get_project(project_id, token) + context_ids = project["properties"]["context"] + geometries = [await self.client.get_territory_geometry(tid) for tid in context_ids] + return gpd.GeoDataFrame(geometry=geometries, crs=4326) + + async def _get_context_roads(self, scenario_id: int, token: str) -> gpd.GeoDataFrame: + """Return roads geometry for context cut (only geometry column).""" + gdf = await self.client.get_physical_objects( + scenario_id, token, physical_object_function_id=ROADS_ID + ) + return gdf[["geometry"]].reset_index(drop=True) + + async def _get_context_water(self, scenario_id: int, token: str) -> gpd.GeoDataFrame: + """Return water geometry for context cut (only geometry column).""" + gdf = await self.client.get_physical_objects( + scenario_id, token=token, physical_object_function_id=WATER_ID + ) + return gdf[["geometry"]].reset_index(drop=True) + + async def _get_context_blocks( + self, + scenario_id: int, + boundaries: gpd.GeoDataFrame, + token: str, + ) -> gpd.GeoDataFrame: + """Construct context blocks by cutting boundaries with roads/water.""" + crs = boundaries.crs + boundaries.geometry = boundaries.buffer(-1) + + water, roads = await asyncio.gather( + self._get_context_water(scenario_id, token), + self._get_context_roads(scenario_id, token), + ) + + water = water.to_crs(crs) + roads = roads.to_crs(crs) + roads.geometry = close_gaps(roads, 1) + + lines, polygons = preprocess_urban_objects(roads, None, water) + blocks = cut_urban_blocks(boundaries, lines, polygons) + return blocks + + async def get_context_blocks( + self, project_id: int, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: + """ + Build context blocks (outside project boundary but inside context territories). + """ + project_boundaries, context_boundaries = await asyncio.gather( + self._get_project_boundaries(project_id, token), + self._get_context_boundaries(project_id, token), + ) + + crs = context_boundaries.estimate_utm_crs() + context_boundaries = context_boundaries.to_crs(crs) + project_boundaries = project_boundaries.to_crs(crs) + + context_boundaries = context_boundaries.overlay(project_boundaries, how="difference") + return await self._get_context_blocks(scenario_id, context_boundaries, token) + + async def get_context_functional_zones( + self, + scenario_id: int, + source: str | None, + year: int | None, + token: str, + ) -> gpd.GeoDataFrame: + """ + Fetch + adapt functional zones for context by best source/year if not given. + """ + sources_df = await self.client.get_functional_zones_sources(scenario_id, token) + year, source = await get_best_functional_zones_source(sources_df, source, year) + functional_zones = await self.client.get_functional_zones(scenario_id, year, source, token) + functional_zones = functional_zones.loc[ + functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) + ].reset_index(drop=True) + return adapt_functional_zones(functional_zones) + + async def get_context_buildings(self, scenario_id: int, token: str) -> gpd.GeoDataFrame | None: + """ + Fetch, adapt and impute living buildings for context. + Returns EPSG:4326 GeoDataFrame or None if not found. + """ + gdf = await self.client.get_physical_objects( + scenario_id, + token, + physical_object_type_id=LIVING_BUILDINGS_ID, + centers_only=True, + ) + if gdf is None or gdf.empty: + return None + + gdf = adapt_buildings(gdf.reset_index(drop=True)) + crs = gdf.estimate_utm_crs() + return impute_buildings(gdf.to_crs(crs)).to_crs(4326) + + async def get_context_services( + self, scenario_id: int, service_types: pd.DataFrame, token: str + ) -> Dict[str, gpd.GeoDataFrame]: + """ + Fetch and adapt services by service type (dict of GeoDataFrames). + """ + gdf = await self.client.get_services(scenario_id, token, centers_only=True) + gdf = gdf.to_crs(gdf.estimate_utm_crs()) + gdfs = adapt_services(gdf.reset_index(drop=True), service_types) + return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} + + async def get_context_territories(self, project_id: int, token: str) -> gpd.GeoDataFrame: + """ + Return context territories as polygons with column 'parent' = territory_id (EPSG:4326). + """ + project = await self.client.get_all_project_info(project_id, token) + context_ids = project["properties"]["context"] + data = [ + {"parent": territory_id, "geometry": await self.client.get_territory_geometry(territory_id)} + for territory_id in context_ids + ] + return gpd.GeoDataFrame(data=data, crs=4326) + + async def load_context_blocks(self, scenario_id: int, token: str) -> Tuple[gpd.GeoDataFrame, int]: + """ + Load raw context blocks and compute site_area. + """ + project_id = await self.client.get_project_id(scenario_id, token) + blocks = await self.get_context_blocks(project_id, scenario_id, token) + blocks["site_area"] = blocks.area + return blocks, project_id + + async def assign_land_use_context( + self, + blocks: gpd.GeoDataFrame, + scenario_id: int, + source: str | None, + year: int | None, + token: str, + ) -> gpd.GeoDataFrame: + """ + Assign land use to blocks via functional zones and LAND_USE_RULES. + """ + fzones = await self.get_context_functional_zones(scenario_id, source, year, token) + fzones = fzones.to_crs(blocks.crs) + lu = assign_land_use(blocks, fzones, LAND_USE_RULES) + return blocks.join(lu.drop(columns=["geometry"])) + + async def enrich_with_context_buildings( + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + ) -> Tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: + """ + Aggregate living buildings on blocks (count_buildings), keep 'is_living' column. + """ + buildings = await self.get_context_buildings(scenario_id, token) + if buildings is None: + blocks["count_buildings"] = 0 + blocks["is_living"] = None + return blocks, None + + buildings = buildings.to_crs(blocks.crs) + agg, _ = aggregate_objects(blocks, buildings) + + blocks = blocks.join( + agg.drop(columns=["geometry"]).rename(columns={"count": "count_buildings"}) + ) + blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) + if "is_living" not in blocks.columns: + blocks["is_living"] = None + + return blocks, buildings + + async def enrich_with_context_services( + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: + """ + Aggregate services on blocks: add capacity_{st} / count_{st} columns. + """ + stypes = await self.client.get_service_types() + stypes = await adapt_service_types(stypes, self.client) + + sdict = await self.get_context_services(scenario_id, stypes, token) + if not sdict: + logger.info(f"No context services to aggregate for scenario_id={scenario_id}") + return blocks + + for stype, services in sdict.items(): + services = services.to_crs(blocks.crs) + b_srv, _ = aggregate_objects(blocks, services) + b_srv[["capacity", "count"]] = b_srv[["capacity", "count"]].fillna(0).astype(int) + + blocks = blocks.join( + b_srv.drop(columns=["geometry"]).rename( + columns={"capacity": f"capacity_{stype}", "count": f"count_{stype}"} + ) + ) + return blocks + + async def aggregate_blocks_layer_context( + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, + ) -> Tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: + """ + Build full context blocks layer: + 1) load blocks + 2) assign land use + 3) enrich with buildings + 4) enrich with services + """ + logger.info(f"[Context {scenario_id}] load blocks") + blocks, _project_id = await self.load_context_blocks(scenario_id, token) + + logger.info("Assigning land-use for context") + blocks = await self.assign_land_use_context(blocks, scenario_id, source, year, token) + + logger.info("Aggregating buildings for context") + blocks, buildings = await self.enrich_with_context_buildings(blocks, scenario_id, token) + + logger.info("Aggregating services for context") + blocks = await self.enrich_with_context_services(blocks, scenario_id, token) + + logger.success(f"[Context {scenario_id}] blocks layer ready", scenario_id) + return blocks, buildings + + + async def get_accessibility_context( + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float + ) -> list[int]: + blocks["population"] = blocks["population"].fillna(0) + project_blocks = blocks.copy() + context_blocks = get_accessibility_context( + acc_mx, project_blocks, accessibility, out=False, keep=True + ) + return list(context_blocks.index) + + async def get_shared_context( + self, + project_id: int, + token: str, + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, pd.DataFrame]: + """ + Get cached context (blocks, territories, service_types) by project_id, + or build and cache if missing. JSON cache stores only paths to artifacts. + """ + method = "shared_context" + params = {"project_id": int(project_id)} + phash = self.cache.params_hash(params) + + cached = self.cache.load(method, project_id, phash) + if cached: + logger.info("Shared context cache hit for project_id=%d", project_id) + data = cached["data"] + ctx_blocks = self.cache.load_gdf_artifact(Path(data["context_blocks_path"])) + ctx_territories = self.cache.load_gdf_artifact(Path(data["context_territories_path"])) + service_types = self.cache.load_df_artifact(Path(data["service_types_path"])) + return ctx_blocks, ctx_territories, service_types + + logger.info("Shared context cache miss for project_id=%d — building", project_id) + + territory_id = (await self.client.get_all_project_info(project_id, token))["territory"]["id"] + base_sid = await self.client.get_base_scenario_id(project_id) + ctx_src, ctx_year = await self.client.get_optimal_func_zone_request_data( + token=token, data_id=base_sid, source=None, year=None, project=False + ) + + normatives = (await self.client.get_territory_normatives(territory_id))[[ + "radius_availability_meters", + "time_availability_minutes", + "services_per_1000_normative", + "services_capacity_per_1000_normative", + ]].copy() + + service_types = await self.client.get_service_types() + service_types = await adapt_service_types(service_types, self.client) + service_types = service_types[service_types["infrastructure_type"].notna()].copy() + service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) + + ctx_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) + ctx_territories = await self.get_context_territories(project_id, token) + + ctx_blocks_path = self.cache.save_gdf_artifact( + ctx_blocks, method=method, owner_id=project_id, params=params, name="context_blocks", fmt="pkl" + ) + ctx_territories_path = self.cache.save_gdf_artifact( + ctx_territories, method=method, owner_id=project_id, params=params, name="context_territories", + fmt="parquet" + ) + service_types_path = self.cache.save_df_artifact( + service_types, method=method, owner_id=project_id, params=params, name="service_types", fmt="parquet" + ) + self.cache.save( + method, project_id, params, + { + "context_blocks_path": str(ctx_blocks_path), + "context_territories_path": str(ctx_territories_path), + "service_types_path": str(service_types_path), + }, + ) + return ctx_blocks, ctx_territories, service_types diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 18f7f23..b9ff2c3 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -4,15 +4,18 @@ import numpy as np import pandas as pd import shapely +from blocksnet.blocks.aggregation import aggregate_objects +from blocksnet.blocks.assignment import assign_land_use from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services from loguru import logger from app.clients.urban_api_client import UrbanAPIClient from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID +from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID, LAND_USE_RULES from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones +from app.effects_api.modules.service_type_service import adapt_service_types from app.effects_api.modules.services_service import adapt_services SOURCES_PRIORITY = ["PZZ", "OSM", "User"] @@ -98,7 +101,8 @@ async def _get_scenario_blocks( user_roads.geometry = close_gaps(user_roads, 1) roads = user_roads.explode(column="geometry") else: - raise http_exception(404, "No objects found for polygons cutting") + roads = None + water = None lines, polygons = preprocess_urban_objects(roads, None, water) blocks = cut_urban_blocks(boundaries, lines, polygons) @@ -188,3 +192,95 @@ async def get_scenario_services( _input={"scenario_id": scenario_id}, _detail={"error": repr(e)}, ) from e + + async def load_blocks_scenario( + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: + gdf = await self.get_scenario_blocks(scenario_id, token) + gdf["site_area"] = gdf.area + return gdf + + async def assign_land_use_to_blocks_scenario( + self, + blocks: gpd.GeoDataFrame, + scenario_id: int, + source: str | None, + year: int | None, + token: str, + ) -> gpd.GeoDataFrame: + fzones = await self.get_scenario_functional_zones( + scenario_id, token, source, year + ) + fzones = fzones.to_crs(blocks.crs) + lu = assign_land_use(blocks, fzones, LAND_USE_RULES) + return blocks.join(lu.drop(columns=["geometry"])) + + async def enrich_with_buildings_scenario( + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: + buildings = await self.get_scenario_buildings(scenario_id, token) + if buildings is None: + blocks["count_buildings"] = 0 + return blocks, None + + buildings = buildings.to_crs(blocks.crs) + blocks_bld, _ = aggregate_objects(blocks, buildings) + + blocks = blocks.join( + blocks_bld.drop(columns=["geometry"]).rename( + columns={"count": "count_buildings"} + ) + ) + blocks["count_buildings"] = blocks["count_buildings"].fillna(0).astype(int) + if "is_living" not in blocks.columns: + blocks["is_living"] = None + return blocks, buildings + + async def enrich_with_services_scenario( + self, blocks: gpd.GeoDataFrame, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: + stypes = await self.client.get_service_types() + stypes = await adapt_service_types(stypes, self.client) + sdict = await self.get_scenario_services(scenario_id, stypes, token) + + for stype, services in sdict.items(): + services = services.to_crs(blocks.crs) + b_srv, _ = aggregate_objects(blocks, services) + b_srv[["capacity", "count"]] = ( + b_srv[["capacity", "count"]].fillna(0).astype(int) + ) + blocks = blocks.join( + b_srv.drop(columns=["geometry"]).rename( + columns={"capacity": f"capacity_{stype}", "count": f"count_{stype}"} + ) + ) + return blocks + + async def aggregate_blocks_layer_scenario( + self, + scenario_id: int, + source: str | None = None, + year: int | None = None, + token: str | None = None, + ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame | None]: + + logger.info(f"[Scenario {scenario_id}] load blocks") + blocks = await self.load_blocks_scenario(scenario_id, token) + + logger.info("Assigning land-use for scenario") + blocks = await self.assign_land_use_to_blocks_scenario( + blocks, scenario_id, source, year, token + ) + + logger.info("Aggregating buildings for scenario") + blocks, buildings = await self.enrich_with_buildings_scenario( + blocks, scenario_id, token + ) + + logger.info("Aggregating services for scenario") + blocks = await self.enrich_with_services_scenario(blocks, scenario_id, token) + + blocks["is_project"] = True + logger.success(f"[scenario {scenario_id}] blocks layer ready") + + return blocks, buildings diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index ef122ad..5957527 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -231,7 +231,6 @@ def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], di def _rename_non_id_columns_to_ids( df: pd.DataFrame, - *, name_to_id: dict[str, int], blocksnet_to_id: dict[str, int], prefixes: Iterable[str], @@ -256,7 +255,7 @@ def _rename_non_id_columns_to_ids( if sid is not None: rename_map[col] = f"{pref}{sid}" else: - logger.warning("No service_id mapping found for column '%s'", col) + logger.warning(f"No service_id mapping found for column '{col}") break if rename_map: @@ -265,7 +264,6 @@ def _rename_non_id_columns_to_ids( def ensure_missing_id_and_name_columns( blocks_gdf: gpd.GeoDataFrame, - *, count_prefix: str = "count", capacity_prefix: str = "capacity", ) -> gpd.GeoDataFrame: @@ -319,7 +317,6 @@ def ensure_missing_id_and_name_columns( def generate_blocksnet_columns( blocks_gdf: gpd.GeoDataFrame, service_types_df: pd.DataFrame, - *, count_prefix: str = "count", capacity_prefix: str = "capacity", strict: bool = False, diff --git a/app/effects_api/modules/services_service.py b/app/effects_api/modules/services_service.py index 5d15c6c..d808dcb 100644 --- a/app/effects_api/modules/services_service.py +++ b/app/effects_api/modules/services_service.py @@ -1,5 +1,7 @@ import geopandas as gpd import pandas as pd +from blocksnet.blocks.aggregation import aggregate_objects +from loguru import logger def _adapt_service_type(data: dict, service_types: pd.DataFrame) -> int: @@ -44,3 +46,51 @@ def adapt_services( st: gdf[gdf["service_type"] == st].drop(columns=["service_type"]) for st in sorted(gdf["service_type"].unique()) } + +async def get_services_layer(self, scenario_id: int, token: str): + """ + Fetch every service layer for a scenario, aggregate counts/capacities + into the scenario blocks and return the resulting block layer. + + Params: + scenario_id : int + Scenario whose services are queried and aggregated. + + Returns: + gpd.GeoDataFrame + Scenario block layer with additional columns + `capacity_` and `count_` for each + detected service category. + """ + blocks = await self.scenario.get_scenario_blocks(scenario_id, token) + blocks_crs = blocks.crs + logger.info( + f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}" + ) + service_types = await self.urban_api_client.get_service_types() + logger.info(f"{service_types}") + services_dict = await self.scenario.get_scenario_services( + scenario_id, service_types, token + ) + + for service_type, services in services_dict.items(): + services = services.to_crs(blocks_crs) + blocks_services, _ = aggregate_objects(blocks, services) + blocks_services["capacity"] = ( + blocks_services["capacity"].fillna(0).astype(int) + ) + blocks_services["count"] = ( + blocks_services["count"].fillna(0).astype(int) + ) + blocks = blocks.join( + blocks_services.drop(columns=["geometry"]).rename( + columns={ + "capacity": f"capacity_{service_type}", + "count": f"count_{service_type}", + } + ) + ) + logger.info( + f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}" + ) + return blocks diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 5a51eb7..21c1553 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -16,6 +16,7 @@ "territory_transformation": effects_service.territory_transformation, "values_transformation": effects_service.values_transformation, "values_oriented_requirements": effects_service.values_oriented_requirements, + "socio_economics": effects_service.evaluate_social_economical_metrics, } _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() diff --git a/app/system_router/system_controller.py b/app/system_router/system_controller.py index f43d971..b826239 100644 --- a/app/system_router/system_controller.py +++ b/app/system_router/system_controller.py @@ -7,7 +7,6 @@ LOGS_PATH = absolute_app_path / f"{config.get('LOG_NAME')}" system_router = APIRouter(prefix="/system", tags=["System"]) -#123 # TODO use structlog instead of loguru @system_router.get("/logs") async def get_logs(): From 8b65d1233fa20bf51e3cf4f390250df021156d55 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 28 Oct 2025 00:50:51 +0300 Subject: [PATCH 109/161] feat(f22): 1. logic for tasks in F 22 --- .../dto/socio_economic_project_dto.py | 6 --- app/effects_api/effects_service.py | 36 ++++++++++++-- app/effects_api/modules/context_service.py | 4 +- app/effects_api/modules/task_service.py | 34 +++++++++++-- app/effects_api/tasks_controller.py | 49 ++++++++++++++++++- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/app/effects_api/dto/socio_economic_project_dto.py b/app/effects_api/dto/socio_economic_project_dto.py index b0b1aa3..5c51503 100644 --- a/app/effects_api/dto/socio_economic_project_dto.py +++ b/app/effects_api/dto/socio_economic_project_dto.py @@ -18,12 +18,6 @@ class SocioEconomicByProjectDTO(BaseModel): description="Regional scenario ID using for filtering.", ) - split: bool = Field( - default=False, - examples=[False, True], - description="If split will return additional evaluation for each context mo", - ) - class SocioEconomicByProjectComputedDTO(SocioEconomicByProjectDTO): context_func_zone_source: Literal["PZZ", "OSM", "User"] diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 04cc664..c0e3fa8 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -437,7 +437,7 @@ async def territory_transformation_scenario_after( ) best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=MAX_RUNS, timeout=4*60, initial_runs_num=1 + max_runs=MAX_RUNS, timeout=10, initial_runs_num=1 ) prov_gdfs_after = {} @@ -759,6 +759,12 @@ def _fill_cell(x): gdf_out = gdf_out.to_crs(crs="EPSG:4326") gdf_out.geometry = round_coords(gdf_out.geometry, 6) + geom_col = gdf_out.geometry.name + non_geom = [c for c in gdf_out.columns if c != geom_col] + non_geom_sorted = sorted(non_geom, key=lambda s: s.casefold()) + pin_first = [c for c in ["is_project"] if c in non_geom_sorted] + rest = [c for c in non_geom_sorted if c not in pin_first] + gdf_out = gdf_out[pin_first + rest + [geom_col]] geojson = json.loads(gdf_out.to_json()) @@ -767,6 +773,7 @@ def _fill_cell(x): geojson = await remap_properties_keys_in_geojson(geojson, en2ru) + self.cache.save( "values_transformation", params.scenario_id, @@ -1114,6 +1121,7 @@ async def _compute_for_single_scenario( return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") + #FIXME починить перепутанную передачу params и token async def evaluate_social_economical_metrics( self, params: SocioEconomicByProjectDTO, @@ -1124,8 +1132,8 @@ async def evaluate_social_economical_metrics( Return: {scenario_id: [{territory_id, indicator_id, value}, ...]} """ - project_id = int(params.project_id) - parent_id = int(params.regional_scenario_id) + project_id = params.project_id + parent_id = params.regional_scenario_id context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, token) @@ -1160,6 +1168,26 @@ async def evaluate_social_economical_metrics( ) results[sid] = records - return results + method_name = "evaluate_social_economical_metrics" + project_info = await self.urban_api_client.get_project(project_id, token) + updated_at = project_info.get("updated_at") + + params_for_hash = { + "project_id": project_id, + "regional_scenario_id": parent_id, + "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else None, + } + phash = self.cache.params_hash(params_for_hash) + + self.cache.save( + method_name, + project_id, + params_for_hash, + {"results": results}, + scenario_updated_at=updated_at, + ) + + logger.success(f"[Effects] socio-economic metrics cached for project_id={project_id}") + return results diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 0f19266..4c3e9ee 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -295,14 +295,14 @@ async def get_shared_context( cached = self.cache.load(method, project_id, phash) if cached: - logger.info("Shared context cache hit for project_id=%d", project_id) + logger.info(f"Shared context cache hit for project_id={project_id}") data = cached["data"] ctx_blocks = self.cache.load_gdf_artifact(Path(data["context_blocks_path"])) ctx_territories = self.cache.load_gdf_artifact(Path(data["context_territories_path"])) service_types = self.cache.load_df_artifact(Path(data["service_types_path"])) return ctx_blocks, ctx_territories, service_types - logger.info("Shared context cache miss for project_id=%d — building", project_id) + logger.info("Shared context cache miss for project_id={project_id} — building") territory_id = (await self.client.get_all_project_info(project_id, token))["territory"]["id"] base_sid = await self.client.get_base_scenario_id(project_id) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 21c1553..1ac3e50 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -102,15 +102,41 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: async def create_task(method: str, token: str, params, task_id: str) -> str: - norm_params = await effects_service.get_optimal_func_zone_data(params, token) - params_for_hash = await effects_service.build_hash_params(norm_params, token) - phash = file_cache.params_hash(params_for_hash) + """ + Создание задачи для асинхронного расчёта эффектов. + Теперь поддерживает как сценарные (scenario_id), так и проектные (project_id) методы. + """ + + # --- Проверяем, относится ли метод к проектным --- + is_project_based = method in {"socio_economics", "evaluate_social_economical_metrics"} + + if is_project_based: + norm_params = params + params_for_hash = { + "project_id": getattr(params, "project_id", None), + "regional_scenario_id": getattr(params, "regional_scenario_id", None), + } + phash = file_cache.params_hash(params_for_hash) + owner_id = getattr(params, "project_id", None) + + else: + norm_params = await effects_service.get_optimal_func_zone_data(params, token) + params_for_hash = await effects_service.build_hash_params(norm_params, token) + phash = file_cache.params_hash(params_for_hash) + owner_id = norm_params.scenario_id task = AnyTask( - method, norm_params.scenario_id, token, norm_params, phash, file_cache, task_id + method, + owner_id, + token, + norm_params, + phash, + file_cache, + task_id, ) _task_map[task.task_id] = task await _task_queue.put(task) + return task.task_id diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index ce12211..7fda676 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -12,6 +12,7 @@ _task_map, _task_queue, ) +from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client @@ -65,7 +66,7 @@ async def get_methods(): @router.post("/{method}", status_code=202) -async def create_task( +async def create_scenario_task( method: str, params: Annotated[ContextDevelopmentDTO, Depends()], token: str = Depends(verify_token), @@ -109,6 +110,52 @@ async def create_task( return {"task_id": task_id, "status": "queued"} +@router.post("/project/{method}", status_code=202) +async def create_project_task( + token: Annotated[str, Depends(verify_token)], + method: str, + params: Annotated[SocioEconomicByProjectDTO, Depends()], +): + """ + separate endpoint for project-based tasks (e.g., socio_economics). + """ + if method not in {"socio_economics", "evaluate_social_economical_metrics"}: + raise http_exception(400, f"method '{method}' is not project-based", method) + + project_id = params.project_id + regional_id = params.regional_scenario_id + + params_for_hash = { + "project_id": project_id, + "regional_scenario_id": regional_id, + "territory_ids": getattr(params, "territory_ids", []), + } + phash = file_cache.params_hash(params_for_hash) + task_id = f"{method}_{project_id}_{phash}" + + force = getattr(params, "force", False) + cached = None if force else file_cache.load(method, project_id, phash) + if not force and _cache_complete(method, cached): + return {"task_id": task_id, "status": "done"} + + existing = None if force else _task_map.get(task_id) + if not force and existing and existing.status in {"queued", "running"}: + return {"task_id": task_id, "status": existing.status} + + task = AnyTask( + method, + project_id, + token, + params, + phash, + file_cache, + task_id, + ) + _task_map[task_id] = task + await _task_queue.put(task) + + return {"task_id": task_id, "status": "queued"} + @router.get("/status/{task_id}") async def task_status(task_id: str): From 800b67c6cd3649fe2d32d2e1c07b926be425e710 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Tue, 28 Oct 2025 18:25:07 +0300 Subject: [PATCH 110/161] feat(effects_logic): small fixes 1. Rewrite graph creation 2. Values transformation services sorting 3. F 22 tasks addition 4. Invalid parameters fix --- app/common/caching/caching_service.py | 11 +++- app/common/utils/geodata.py | 33 ++++++++++- app/effects_api/constants/const.py | 4 +- app/effects_api/effects_controller.py | 31 +--------- app/effects_api/effects_service.py | 79 ++++++++++--------------- app/effects_api/modules/task_service.py | 7 +-- app/effects_api/tasks_controller.py | 4 +- 7 files changed, 78 insertions(+), 91 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 1e6bb32..bcb5794 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -66,7 +66,10 @@ def save( return path def _latest_path(self, method: str, scenario_id: int) -> Path | None: - pattern = f"*__scenario_{scenario_id}__{_safe(method)}__*.json" + if method == "social_economical_metric": + pattern = f"*__project_{scenario_id}__{_safe(method)}__*.json" + else: + pattern = f"*__scenario_{scenario_id}__{_safe(method)}__*.json" files = sorted(_CACHE_DIR.glob(pattern), reverse=True) return files[0] if files else None @@ -77,8 +80,10 @@ def load( params_hash: str, max_age: timedelta | None = None, ) -> dict[str, Any] | None: - - pattern = f"*__scenario_{scenario_id}__{_safe(method)}__{params_hash}.json" + if method == "social_economical_metric": + pattern = f"*__project_{scenario_id}__{_safe(method)}__{params_hash}.json" + else: + pattern = f"*__scenario_{scenario_id}__{_safe(method)}__{params_hash}.json" files = sorted(_CACHE_DIR.glob(pattern), reverse=True) if not files: return None diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index fd9f975..6e850c9 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -3,12 +3,13 @@ import geopandas as gpd import pandas as pd +from blocksnet.relations import calculate_distance_matrix from loguru import logger from shapely.geometry.base import BaseGeometry from shapely.wkt import dumps, loads from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.constants.const import COL_RU +from app.effects_api.constants.const import COL_RU, SPEED, ROADS_ID from app.effects_api.modules.scenario_service import SOURCES_PRIORITY @@ -127,4 +128,32 @@ def _ensure_block_index(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: gdf.index = gdf.index.astype(int) gdf = gdf[~gdf.index.duplicated(keep="last")].sort_index() gdf.index.name = "block_id" - return gdf \ No newline at end of file + return gdf + +def get_accessibility_matrix(blocks : gpd.GeoDataFrame) -> pd.DataFrame: + crs = blocks.estimate_utm_crs() + dist_mx = calculate_distance_matrix(blocks.to_crs(crs)) + return dist_mx // SPEED + + +async def _roads_overlay_fast(self, scenario_id: int, token: str, target_crs, bounds): + """ + Try to clip by bbox first, then lighter op than overlay. Offload to executor. + """ + logger.info(f"Fetching roads for scenario_id={scenario_id}") + roads_gdf = await self.urban_api_client.get_physical_objects_scenario( + scenario_id, token=token, physical_object_function_id=ROADS_ID + ) + if roads_gdf.crs != target_crs: + roads_gdf = roads_gdf.to_crs(target_crs) + + minx, miny, maxx, maxy = bounds + roads_gdf = roads_gdf.cx[minx:maxx, miny:maxy] + + def _clip(): + try: + return roads_gdf + except Exception: + return roads_gdf + + return await self._to_executor(_clip) \ No newline at end of file diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index fde85b7..257a97e 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -310,4 +310,6 @@ SocialProvisionIndicator.TOURIST_BASE: 360, SocialCountIndicator.CATERING: 344, SocialProvisionIndicator.CATERING: 226 # Обеспеченность кафе / кофейнями -} \ No newline at end of file +} + +SPEED = 5 * 1_000 / 60 \ No newline at end of file diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py index 2129b9e..79d73ee 100644 --- a/app/effects_api/effects_controller.py +++ b/app/effects_api/effects_controller.py @@ -12,9 +12,8 @@ ContextDevelopmentDTO, DevelopmentDTO, ) -from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO, SocioEconomicByProjectComputedDTO +from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO from .dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO -from .dto.transformation_effects_dto import TerritoryTransformationDTO from .schemas.development_response_schema import DevelopmentResponseSchema from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema @@ -64,34 +63,6 @@ async def get_socio_economic_prediction( ) -> SocioEconomicResponseSchema: return await effects_service.evaluate_master_plan_by_scenario(params, token) - -# @f_35_router.get("/territory_transformation") -# async def territory_transformation( -# params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], -# token: str = Depends(verify_token), -# ): -# gdf = await effects_service.territory_transformation_scenario_before(token, params) -# gdf = gdf.to_crs(4326) -# -# geojson_dict = json.loads(gdf.to_json(drop_id=True)) -# return JSONResponse(content=geojson_dict) -# -# -# @f_26_router.get("/values_development") -# async def values_development( -# params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], -# token: str = Depends(verify_token), -# ): -# return await effects_service.values_transformation(token, params) -# -# -# @f_36_router.get("/values_oriented_requirements") -# async def values_requirements( -# params: Annotated[TerritoryTransformationDTO, Depends(TerritoryTransformationDTO)], -# token: str = Depends(verify_token), -# ): -# return await effects_service.values_oriented_requirements(token, params) - @f_22_router.get( "/scenario_f_22" ) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c0e3fa8..b3329ec 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -34,7 +34,8 @@ from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, _ensure_block_index +from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, _ensure_block_index, \ + get_accessibility_matrix from .constants.const import ( INFRASTRUCTURES_WEIGHTS, MAX_EVALS, @@ -237,13 +238,7 @@ async def territory_transformation_scenario_before( before_blocks["is_project"] = ( before_blocks["is_project"].fillna(False).astype(bool) ) - try: - graph = get_accessibility_graph(before_blocks, "intermodal") - except Exception as e: - raise http_exception( - 500, "Error generating territory graph", _detail=str(e) - ) - acc_mx = calculate_accessibility_matrix(before_blocks, graph) + acc_mx = get_accessibility_matrix(before_blocks) prov_gdfs_before = {} for st_id in service_types.index: @@ -391,14 +386,7 @@ async def territory_transformation_scenario_after( after_blocks["is_project"] = ( after_blocks["is_project"].fillna(False).astype(bool) ) - try: - graph = get_accessibility_graph(after_blocks, "intermodal") - except Exception as e: - raise http_exception( - 500, "Error generating territory graph", _detail=str(e) - ) - - acc_mx = calculate_accessibility_matrix(after_blocks, graph) + acc_mx = get_accessibility_matrix(after_blocks) service_types["infrastructure_weight"] = ( service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) @@ -622,14 +610,7 @@ async def values_transformation( else: after_blocks["is_project"] = False - try: - graph = get_accessibility_graph(after_blocks, "intermodal") - except Exception as e: - raise http_exception( - 500, "Error generating territory graph", _detail=str(e) - ) - - acc_mx = calculate_accessibility_matrix(after_blocks, graph) + acc_mx = get_accessibility_matrix(after_blocks) service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) @@ -757,22 +738,26 @@ def _fill_cell(x): except Exception as e: raise http_exception(500, "Failed to attach land-use predictions: {}", e) - gdf_out = gdf_out.to_crs(crs="EPSG:4326") + gdf_out = gdf_out.to_crs("EPSG:4326") gdf_out.geometry = round_coords(gdf_out.geometry, 6) + + service_types = await self.urban_api_client.get_service_types() + en2ru = await build_en_to_ru_map(service_types) + rename_map = {k: v for k, v in en2ru.items() if k in gdf_out.columns} + if rename_map: + gdf_out = gdf_out.rename(columns=rename_map) + geom_col = gdf_out.geometry.name non_geom = [c for c in gdf_out.columns if c != geom_col] - non_geom_sorted = sorted(non_geom, key=lambda s: s.casefold()) - pin_first = [c for c in ["is_project"] if c in non_geom_sorted] - rest = [c for c in non_geom_sorted if c not in pin_first] - gdf_out = gdf_out[pin_first + rest + [geom_col]] - geojson = json.loads(gdf_out.to_json()) + pin_first = [c for c in ["is_project", "Предсказанный вид использования"] if c in non_geom] - service_types = await self.urban_api_client.get_service_types() - en2ru = await build_en_to_ru_map(service_types) + rest = [c for c in non_geom if c not in pin_first] + rest_sorted = sorted(rest, key=lambda s: s.casefold()) - geojson = await remap_properties_keys_in_geojson(geojson, en2ru) + gdf_out = gdf_out[pin_first + rest_sorted + [geom_col]] + geojson = json.loads(gdf_out.to_json()) self.cache.save( "values_transformation", @@ -919,13 +904,7 @@ def _result_to_df(payload: Any) -> pd.DataFrame: service_types = await adapt_service_types(service_types, self.urban_api_client) service_types = service_types[~service_types["social_values"].isna()].copy() - try: - graph = get_accessibility_graph(blocks, "intermodal") - except Exception as e: - raise http_exception( - 500, "Error generating territory graph", _detail=str(e) - ) - acc_mx = calculate_accessibility_matrix(blocks, graph) + acc_mx = get_accessibility_matrix(blocks) prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} for st_id in service_types.index: @@ -1088,8 +1067,7 @@ async def _compute_for_single_scenario( ) roads_gdf = roads_gdf.to_crs(before_blocks.crs).overlay(before_blocks) - graph = get_accessibility_graph(before_blocks, "drive") - acc_mx = calculate_accessibility_matrix(before_blocks, graph) + acc_mx = get_accessibility_matrix(before_blocks) dist_mx = calculate_distance_matrix(before_blocks) st_for_social = service_types_df[ @@ -1121,11 +1099,10 @@ async def _compute_for_single_scenario( return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") - #FIXME починить перепутанную передачу params и token async def evaluate_social_economical_metrics( self, - params: SocioEconomicByProjectDTO, token: str, + params: SocioEconomicByProjectDTO ): """ Project-level multi-scenario calculation with a shared context. @@ -1168,17 +1145,23 @@ async def evaluate_social_economical_metrics( ) results[sid] = records - method_name = "evaluate_social_economical_metrics" + method_name = "social_economical_metrics" project_info = await self.urban_api_client.get_project(project_id, token) updated_at = project_info.get("updated_at") + #FIXME проверить хэш параметров + + # params_for_hash = { + # "project_id": project_id, + # "regional_scenario_id": parent_id, + # "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else None, + # } + params_for_hash = { "project_id": project_id, - "regional_scenario_id": parent_id, - "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else None, + "regional_scenario_id": parent_id } - phash = self.cache.params_hash(params_for_hash) self.cache.save( method_name, diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 1ac3e50..a22db23 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -16,7 +16,7 @@ "territory_transformation": effects_service.territory_transformation, "values_transformation": effects_service.values_transformation, "values_oriented_requirements": effects_service.values_oriented_requirements, - "socio_economics": effects_service.evaluate_social_economical_metrics, + "social_economical_metrics": effects_service.evaluate_social_economical_metrics, } _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() @@ -103,11 +103,8 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: async def create_task(method: str, token: str, params, task_id: str) -> str: """ - Создание задачи для асинхронного расчёта эффектов. - Теперь поддерживает как сценарные (scenario_id), так и проектные (project_id) методы. + Task creation for async Effects calculations """ - - # --- Проверяем, относится ли метод к проектным --- is_project_based = method in {"socio_economics", "evaluate_social_economical_metrics"} if is_project_based: diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 7fda676..4841cb5 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -112,14 +112,14 @@ async def create_scenario_task( @router.post("/project/{method}", status_code=202) async def create_project_task( - token: Annotated[str, Depends(verify_token)], method: str, params: Annotated[SocioEconomicByProjectDTO, Depends()], + token: Annotated[str, Depends(verify_token)] ): """ separate endpoint for project-based tasks (e.g., socio_economics). """ - if method not in {"socio_economics", "evaluate_social_economical_metrics"}: + if method not in {"social_economical_metrics"}: raise http_exception(400, f"method '{method}' is not project-based", method) project_id = params.project_id From cb269c1fe47b1f005d291637a8716d1e5cc83484 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 29 Oct 2025 19:18:43 +0300 Subject: [PATCH 111/161] feat(f22): 1. Converted response for F22 2. Started to implement response schemas 3. Added docstring for endpoints --- app/common/dto/models.py | 7 +- app/common/utils/effects_utils.py | 53 ++++++++ .../dto/socio_economic_project_dto.py | 12 +- app/effects_api/effects_controller.py | 73 ----------- app/effects_api/effects_service.py | 43 ++++--- .../schemas/service_types_response_schema.py | 21 ++++ .../socio_economic_metrics_response_schema.py | 0 .../schemas/socio_economic_response_schema.py | 4 +- ...erritory_transformation_response_schema.py | 0 .../values_oriented_response_schema.py | 0 .../schemas/values_tables_response_schema.py | 0 .../values_transformation_response_schema.py | 0 app/effects_api/tasks_controller.py | 118 +++++++++++++++--- app/main.py | 15 +-- 14 files changed, 214 insertions(+), 132 deletions(-) delete mode 100644 app/effects_api/effects_controller.py create mode 100644 app/effects_api/schemas/service_types_response_schema.py create mode 100644 app/effects_api/schemas/socio_economic_metrics_response_schema.py create mode 100644 app/effects_api/schemas/territory_transformation_response_schema.py create mode 100644 app/effects_api/schemas/values_oriented_response_schema.py create mode 100644 app/effects_api/schemas/values_tables_response_schema.py create mode 100644 app/effects_api/schemas/values_transformation_response_schema.py diff --git a/app/common/dto/models.py b/app/common/dto/models.py index 913fa0d..0850848 100644 --- a/app/common/dto/models.py +++ b/app/common/dto/models.py @@ -1,8 +1,13 @@ from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, Field class SourceYear(BaseModel): source: Literal["PZZ", "OSM", "User"] year: int + + +class ServiceType(BaseModel): + id: int + name: str diff --git a/app/common/utils/effects_utils.py b/app/common/utils/effects_utils.py index 8c87499..48024bb 100644 --- a/app/common/utils/effects_utils.py +++ b/app/common/utils/effects_utils.py @@ -5,6 +5,7 @@ import pandas as pd from blocksnet.optimization.services import AreaSolution, Facade import geopandas as gpd +from loguru import logger from app.clients.urban_api_client import UrbanAPIClient @@ -109,3 +110,55 @@ def build_facade( facade.add_service_type(st_name, st_weight, df) return facade + + def pivot_results_by_territory( + self, + results: dict[int, list[dict]] + ) -> dict[int, dict[int, dict[int, float]]]: + """ + Transform scenario-first results to territory-first pivot. + + Input: + results: { + scenario_id: [ + {"territory_id": int, "indicator_id": int, "value": number}, + ... + ], + ... + } + + Output: + { + territory_id: [ + {"indicator_id": int, : number, : number, ...}, + ... + ], + ... + } + """ + pivot: dict[int, dict[int, dict[int, float]]] = {} + + for scenario_id, records in results.items(): + if not records: + continue + for rec in records: + try: + t_id = int(rec["territory_id"]) + ind_id = int(rec["indicator_id"]) + val = rec.get("value") + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + f"[Effects] Skip bad record in scenario {scenario_id}: {rec} ({exc})" + ) + continue + + if t_id not in pivot: + pivot[t_id] = {} + if ind_id not in pivot[t_id]: + pivot[t_id][ind_id] = {} + pivot[t_id][ind_id][int(scenario_id)] = val + + logger.info( + f"[Effects] Pivoted to nested format: {len(pivot)} territories." + ) + return pivot \ No newline at end of file diff --git a/app/effects_api/dto/socio_economic_project_dto.py b/app/effects_api/dto/socio_economic_project_dto.py index 5c51503..81c1021 100644 --- a/app/effects_api/dto/socio_economic_project_dto.py +++ b/app/effects_api/dto/socio_economic_project_dto.py @@ -1,9 +1,5 @@ -from typing import Dict, Literal - from pydantic import BaseModel, Field -from app.common.dto.models import SourceYear - class SocioEconomicByProjectDTO(BaseModel): project_id: int = Field( @@ -18,8 +14,6 @@ class SocioEconomicByProjectDTO(BaseModel): description="Regional scenario ID using for filtering.", ) - -class SocioEconomicByProjectComputedDTO(SocioEconomicByProjectDTO): - context_func_zone_source: Literal["PZZ", "OSM", "User"] - context_func_source_year: int - project_sources: Dict[int, SourceYear] + force: bool = Field( + default=False, description="flag for recalculating the scenario" + ) diff --git a/app/effects_api/effects_controller.py b/app/effects_api/effects_controller.py deleted file mode 100644 index 79d73ee..0000000 --- a/app/effects_api/effects_controller.py +++ /dev/null @@ -1,73 +0,0 @@ -import json -from typing import Annotated - -from fastapi import APIRouter -from fastapi.params import Depends -from starlette.responses import JSONResponse - -from app.common.auth.auth import verify_token - -from ..dependencies import effects_service -from .dto.development_dto import ( - ContextDevelopmentDTO, - DevelopmentDTO, -) -from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO -from .dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO -from .schemas.development_response_schema import DevelopmentResponseSchema -from .schemas.socio_economic_response_schema import SocioEconomicResponseSchema - -development_router = APIRouter(prefix="/development", tags=["Effects"]) -f_22_router = APIRouter(prefix="/f22", tags=["Effects"]) -f_26_router = APIRouter(prefix="/f26", tags=["Effects"]) -f_35_router = APIRouter(prefix="/f35", tags=["Effects"]) -f_36_router = APIRouter(prefix="/f36", tags=["Effects"]) - - -@development_router.get( - "/project_development", response_model=DevelopmentResponseSchema -) -async def get_project_development( - params: Annotated[DevelopmentDTO, Depends(DevelopmentDTO)], - token: str = Depends(verify_token), -) -> DevelopmentResponseSchema: - return await effects_service.calc_project_development(token, params) - - -@development_router.get( - "/context_development", response_model=DevelopmentResponseSchema -) -async def get_context_development( - params: Annotated[ContextDevelopmentDTO, Depends(ContextDevelopmentDTO)], - token: str = Depends(verify_token), -) -> DevelopmentResponseSchema: - return await effects_service.calc_context_development(token, params) - - -@f_22_router.get( - "/project_socio_economic_prediction", response_model=SocioEconomicResponseSchema -) -async def get_socio_economic_prediction( - params: Annotated[SocioEconomicByProjectDTO, Depends(SocioEconomicByProjectDTO)], - token: str = Depends(verify_token), -) -> SocioEconomicResponseSchema: - return await effects_service.evaluate_master_plan_by_project(params, token) - - -@f_22_router.get( - "/scenario_socio_economic_prediction", response_model=SocioEconomicResponseSchema -) -async def get_socio_economic_prediction( - params: Annotated[SocioEconomicByScenarioDTO, Depends(SocioEconomicByScenarioDTO)], - token: str = Depends(verify_token), -) -> SocioEconomicResponseSchema: - return await effects_service.evaluate_master_plan_by_scenario(params, token) - -@f_22_router.get( - "/scenario_f_22" -) -async def get_socio_economic_prediction_new( - params: Annotated[SocioEconomicByProjectDTO, Depends(SocioEconomicByProjectDTO)], - token: str = Depends(verify_token), -): - return await effects_service.evaluate_social_economical_metrics(params, token) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index b3329ec..9cfa27f 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -773,7 +773,7 @@ def _fill_cell(x): def _get_value_level(self, provisions: list[float | None]) -> float: vals = [p for p in provisions if p is not None] return float(np.mean(vals)) if vals else np.nan - +#FIXME is not calculating for base id async def values_oriented_requirements( self, token: str, @@ -1106,13 +1106,34 @@ async def evaluate_social_economical_metrics( ): """ Project-level multi-scenario calculation with a shared context. - Return: {scenario_id: [{territory_id, indicator_id, value}, ...]} + Return: {territory_id: {indicator_id: {scenario_id: value}}} """ project_id = params.project_id parent_id = params.regional_scenario_id - context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, token) + method_name = "social_economical_metrics" + + only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None + + params_for_hash = { + "project_id": project_id, + "regional_scenario_id": parent_id, + "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else [], + } + + if not params.force: + phash = self.cache.params_hash(params_for_hash) + cached = self.cache.load(method_name, project_id, phash) + if cached: + logger.info(f"[Effects] cache hit for project {project_id}, returning cached data") + return cached["results"] + else: + logger.info(f"[Effects] force=True, recalculating metrics for project {project_id}") + + context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context( + project_id, token + ) scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) target = [ @@ -1124,7 +1145,6 @@ async def evaluate_social_economical_metrics( ) only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None - results: dict[int, list[dict]] = {} for s in target: @@ -1145,24 +1165,11 @@ async def evaluate_social_economical_metrics( ) results[sid] = records - method_name = "social_economical_metrics" + results = self.effects_utils.pivot_results_by_territory(results) project_info = await self.urban_api_client.get_project(project_id, token) updated_at = project_info.get("updated_at") - #FIXME проверить хэш параметров - - # params_for_hash = { - # "project_id": project_id, - # "regional_scenario_id": parent_id, - # "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else None, - # } - - params_for_hash = { - "project_id": project_id, - "regional_scenario_id": parent_id - } - self.cache.save( method_name, project_id, diff --git a/app/effects_api/schemas/service_types_response_schema.py b/app/effects_api/schemas/service_types_response_schema.py new file mode 100644 index 0000000..db4c35a --- /dev/null +++ b/app/effects_api/schemas/service_types_response_schema.py @@ -0,0 +1,21 @@ +from typing import List, Optional + +from pydantic import BaseModel, Field + +from app.common.dto.models import ServiceType + + +class ServiceTypesResponse(BaseModel): + """ + List of service types available before and/or after scenario transformation. + Both lists may be present, and `after` may be empty or populated depending on scenario changes. + """ + before: List[ServiceType] = Field( + ..., + description="Service types in the base (before) scenario" + ) + after: Optional[List[ServiceType]] = Field( + None, + description="Service types in the transformed (after) scenario; may be empty or identical to 'before'" + ) + diff --git a/app/effects_api/schemas/socio_economic_metrics_response_schema.py b/app/effects_api/schemas/socio_economic_metrics_response_schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py index c6540e5..42b2ff3 100644 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ b/app/effects_api/schemas/socio_economic_response_schema.py @@ -9,7 +9,6 @@ ) from ..dto.socio_economic_project_dto import ( - SocioEconomicByProjectComputedDTO, SocioEconomicByProjectDTO, ) from ..dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO @@ -74,6 +73,5 @@ class SocioEconomicResponseSchema(SocioEconomicSchema): DevelopmentDTO, ContextDevelopmentDTO, SocioEconomicByProjectDTO, - SocioEconomicByScenarioDTO, - SocioEconomicByProjectComputedDTO, + SocioEconomicByScenarioDTO ] diff --git a/app/effects_api/schemas/territory_transformation_response_schema.py b/app/effects_api/schemas/territory_transformation_response_schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/schemas/values_oriented_response_schema.py b/app/effects_api/schemas/values_oriented_response_schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/schemas/values_tables_response_schema.py b/app/effects_api/schemas/values_tables_response_schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/schemas/values_transformation_response_schema.py b/app/effects_api/schemas/values_transformation_response_schema.py new file mode 100644 index 0000000..e69de29 diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 4841cb5..79c6fa1 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -24,6 +24,8 @@ _locks: dict[str, asyncio.Lock] = {} +#TODO continue response schemas + def _get_lock(key: str) -> asyncio.Lock: lock = _locks.get(key) if lock is None: @@ -59,18 +61,38 @@ def _cache_complete(method: str, cached: dict | None) -> bool: return True -@router.get("/methods") +@router.get("/methods", summary="List available task methods", + description=( + "Returns the current list of task method names that can be scheduled via this API.\n\n" + "- `territory_transformation` — F 35 scenario-based, create with `POST /tasks/{method}`\n" + "- `values_transformation` — F 26 scenario-based, create with `POST /tasks/{method}`\n" + "- `values_oriented_requirements` — F 36 scenario-based, create with `POST /tasks/{method}`\n" + "- `social_economical_metrics` — F22 project-based, create with `POST /tasks/project/{method}`" + )) async def get_methods(): - """router for getting method names available for tasks creation""" return list(TASK_METHODS.keys()) -@router.post("/{method}", status_code=202) +@router.post("/{method}", status_code=202, + summary="Create scenario-based task", + description=( + "Queues an asynchronous **scenario-based** task.\n\n" + "**Caching behavior**: if `force=false` and a complete cached result exists " + "for the computed parameter hash, the endpoint returns `status=done` immediately. " + "Otherwise a task is queued and `status=queued` is returned.\n\n" + "**Response statuses**:\n" + "- `queued`: task was enqueued successfully\n" + "- `running`: a task with the same id is already being processed\n" + "- `done`: cached result is available\n" + "- `failed`: check `GET /tasks/status/{task_id}` for error details\n\n" + "**Task id format**: `{method}_{scenario_id}_{phash}`" + )) async def create_scenario_task( method: str, params: Annotated[ContextDevelopmentDTO, Depends()], token: str = Depends(verify_token), ): + """Roter for task creation""" if method not in TASK_METHODS: raise http_exception(404, f"method '{method}' is not registered", method) @@ -110,7 +132,22 @@ async def create_scenario_task( return {"task_id": task_id, "status": "queued"} -@router.post("/project/{method}", status_code=202) +@router.post("/project/{method}", status_code=202, + summary="Create project-based task", + description=( + "Queues an asynchronous **project-level** task. Currently supported: " + "`social_economical_metrics`.\n\n" + "**Hash parameters**: `{project_id, regional_scenario_id, territory_ids}`.\n" + "**Caching behavior**: if `force=false` and a complete cached result exists, " + "for the computed parameter hash, the endpoint returns `status=done` immediately. " + "Otherwise a task is queued and `status=queued` is returned.\n\n" + "**Response statuses**:\n" + "- `queued`: task was enqueued successfully\n" + "- `running`: a task with the same id is already being processed\n" + "- `done`: cached result is available\n" + "- `failed`: check `GET /tasks/status/{task_id}` for error details\n\n" + "**Task id format**: `{method}_{project_id}_{phash}`" + )) async def create_project_task( method: str, params: Annotated[SocioEconomicByProjectDTO, Depends()], @@ -157,7 +194,19 @@ async def create_project_task( return {"task_id": task_id, "status": "queued"} -@router.get("/status/{task_id}") +@router.get("/status/{task_id}", + summary="Get task status", + description=( + "Returns current status for a task id.\n\n" + "**Statuses**:\n" + "- `queued`: waiting in queue\n" + "- `running`: being processed\n" + "- `done`: cached (final) result exists\n" + "- `failed`: task failed, `error` field may be present\n" + "- `unknown`: task is tracked but status cannot be resolved\n\n" + "If the cache already contains a complete result for the `task_id`, " + "the endpoint responds with `status=done`." + )) async def task_status(task_id: str): method, scenario_id, phash = file_cache.parse_task_id(task_id) if method and scenario_id is not None and phash: @@ -183,18 +232,35 @@ async def task_status(task_id: str): raise http_exception(404, "task not found", task_id) -@router.get("/get_service_types") +@router.get("/get_service_types", + summary="List service types for a scenario", + description=( + "Returns service type identifiers available for a given `scenario_id` and `method` " + "from the cached layer. Intended to help clients discover which services can be requested." + )) async def get_service_types( scenario_id: int, method: str = "territory_transformation", token: str = Depends(verify_token), ): + reposnse = await get_services_with_ids_from_layer( + scenario_id, method, file_cache, effects_utils, token=token + ) return await get_services_with_ids_from_layer( scenario_id, method, file_cache, effects_utils, token=token ) -@router.get("/territory_transformation/{scenario_id}/{service_name}") +@router.get("/territory_transformation/{scenario_id}/{service_name}", + summary="Get territory transformation layer by service", + description=( + "Fetches a GeoJSON layer for a specific `service_name` from the cached " + "`territory_transformation` result.\n\n" + "**Responses**:\n" + "- When both versions exist: returns `{ before, after, provision_total_before, provision_total_after }`\n" + "- When only `before` exists: returns `{ before, provision_total_before }`\n" + "- When only `after` exists: returns `{ after, provision_total_after }`" + )) async def get_territory_transformation_layer(scenario_id: int, service_name: str): cached = file_cache.load_latest("territory_transformation", scenario_id) if not cached: @@ -240,7 +306,13 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str raise http_exception(404, f"service '{service_name}' not found") -@router.get("/values_oriented_requirements/{scenario_id}/{service_name}") +@router.get("/values_oriented_requirements/{scenario_id}/{service_name}", + summary="Get Values-Oriented Requirements layer", + description=( + "Returns the GeoJSON layer and values table for a `service_name`, computed for the " + "**base scenario** of the provided `scenario_id`.\n\n" + "Rejects the request if the cached base result is stale compared to the base scenario metadata." + )) async def get_values_oriented_requirements_layer( scenario_id: int, service_name: str, @@ -280,7 +352,12 @@ async def get_values_oriented_requirements_layer( ) -@router.get("/values_oriented_requirements_table/{scenario_id}") +@router.get("/values_oriented_requirements_table/{scenario_id}", + summary="Get Values-Oriented Requirements tables", + description=( + "Returns the values table and service-type deficit table for the **base scenario** " + "of the provided `scenario_id`." + )) async def get_values_oriented_requirements_table( scenario_id: int, token: str = Depends(verify_token), @@ -312,17 +389,30 @@ async def get_values_oriented_requirements_table( ) -@router.get("/get_from_cache/{method_name}/{scenario_id}") -async def get_layer(scenario_id: int, method_name: str): - cached = file_cache.load_latest(method_name, scenario_id) +@router.get("/get_from_cache/{method_name}/{project_scenario_id}", + summary="Get raw cached data by method and owner id", + description=( + "Reads the latest cached JSON payload for a given `method_name` and owner id. " + "For scenario-based methods the owner is a **scenario id**; for project-based " + "methods the owner is a **project id**." + )) +async def get_layer(project_scenario_id: int, method_name: str): + cached = file_cache.load_latest(method_name, project_scenario_id) if not cached: - raise http_exception(404, "no saved result for this scenario", scenario_id) + raise http_exception(404, "no saved result for this scenario", project_scenario_id) data: dict = cached["data"] return JSONResponse(content=data) -@router.get("/get_provisions/{scenario_id}") +@router.get("/get_provisions/{scenario_id}", + summary="Get total provision values", + description=( + "Returns total provision values from the cached `territory_transformation` result for " + "the specified `scenario_id`. Depending on availability, the response contains:\n" + "- `provision_total_before` and `provision_total_after`, or\n" + "- only one of them if the other is not present." + )) async def get_total_provisions(scenario_id: int): cached = file_cache.load_latest("territory_transformation", scenario_id) if not cached: diff --git a/app/main.py b/app/main.py index 5cd2741..e2374cb 100644 --- a/app/main.py +++ b/app/main.py @@ -4,13 +4,6 @@ from fastapi.responses import RedirectResponse from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware -from app.effects_api.effects_controller import ( - development_router, - f_22_router, - f_26_router, - f_35_router, - f_36_router, -) from app.effects_api.modules.task_service import lifespan from app.effects_api.tasks_controller import router as tasks_router from app.system_router.system_controller import system_router @@ -32,7 +25,7 @@ allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) -# app.add_middleware(ExceptionHandlerMiddleware) +app.add_middleware(ExceptionHandlerMiddleware) @app.get("/", include_in_schema=False) @@ -42,9 +35,3 @@ async def read_root(): app.include_router(tasks_router) app.include_router(system_router) -app.include_router(development_router) - -app.include_router(f_22_router) -app.include_router(f_26_router) -app.include_router(f_35_router) -app.include_router(f_36_router) From 852251dd7fdee1943e8885b964ae938057fdeb4b Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 29 Oct 2025 22:28:54 +0300 Subject: [PATCH 112/161] fix(f36): 1. Logic for when child scenario is provided, calculations for base scenario is performed (only!) 2. Tasks addition for values_oriented_requirements 3. Response schema and description for get_service_types router --- app/effects_api/effects_service.py | 53 +---------- app/effects_api/modules/task_service.py | 94 ++++++++++++++----- .../schemas/service_types_response_schema.py | 9 ++ app/effects_api/tasks_controller.py | 42 ++++++--- app/main.py | 2 +- 5 files changed, 115 insertions(+), 85 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 9cfa27f..ede6832 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -773,65 +773,20 @@ def _fill_cell(x): def _get_value_level(self, provisions: list[float | None]) -> float: vals = [p for p in provisions if p is not None] return float(np.mean(vals)) if vals else np.nan -#FIXME is not calculating for base id + async def values_oriented_requirements( self, token: str, params: TerritoryTransformationDTO | DevelopmentDTO, persist: Literal["full", "table_only"] = "full", ): - method_name = "values_oriented_requirements" - info_curr = await self.urban_api_client.get_scenario_info( - params.scenario_id, token - ) - updated_at_curr = info_curr["updated_at"] - project_id = (info_curr.get("project") or {}).get("project_id") - regional_id = (info_curr.get("parent_scenario") or {}).get("id") - force: bool = bool(getattr(params, "force", False)) - base_id: Optional[int] = None - if project_id and regional_id: - proj_scenarios = await self.urban_api_client.get_project_scenarios( - project_id, token - ) - - matches = [ - s - for s in proj_scenarios - if self.effects_utils.truthy_is_based(s) - and self.effects_utils.parent_id(s) == regional_id - and self.effects_utils.sid(s) is not None - ] - if not matches: - only_based = [ - s - for s in proj_scenarios - if self.effects_utils.truthy_is_based(s) and self.effects_utils.sid(s) is not None - ] - if only_based: - only_based.sort( - key=lambda x: ( - x.get("updated_at") is not None, - x.get("updated_at"), - ), - reverse=True, - ) - matches = [only_based[0]] - if matches: - matches.sort( - key=lambda x: ( - x.get("updated_at") is not None, - x.get("updated_at"), - ), - reverse=True, - ) - base_id = self.effects_utils.sid(matches[0]) - - if base_id is None: - base_id = params.scenario_id + base_id = await self.effects_utils.resolve_base_id(token, params.scenario_id) + logger.info( + f"Using base scenario_id={base_id} (requested={params.scenario_id})") params_base = params.model_copy( update={ diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index a22db23..0010de2 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -8,7 +8,7 @@ from fastapi import FastAPI from loguru import logger -from app.dependencies import effects_service, file_cache +from app.dependencies import effects_service, file_cache, effects_utils MethodFunc = Callable[[str, Any], "dict[str, Any]"] @@ -101,40 +101,90 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: self.error = str(exc) -async def create_task(method: str, token: str, params, task_id: str) -> str: +async def create_task( + method: str, + token: str, + params, +) -> dict: """ - Task creation for async Effects calculations + Create (or reuse) an async Effects task. + + Returns: + dict: { "task_id": str, "status": "queued" | "running" | "done" } """ - is_project_based = method in {"socio_economics", "evaluate_social_economical_metrics"} - if is_project_based: - norm_params = params + project_based_methods = {"social_economical_metrics"} + + if method in project_based_methods: + owner_id = getattr(params, "project_id", None) params_for_hash = { "project_id": getattr(params, "project_id", None), "regional_scenario_id": getattr(params, "regional_scenario_id", None), } phash = file_cache.params_hash(params_for_hash) - owner_id = getattr(params, "project_id", None) + task_id = f"{method}_{owner_id}_{phash}" + + cached = file_cache.load(method, owner_id, phash) + if cached and "data" in cached: + return {"task_id": task_id, "status": "done"} + + norm_params = params + task = AnyTask(method, owner_id, token, norm_params, phash, file_cache, task_id) + if task.task_id in _task_map: + return {"task_id": task.task_id, "status": "running"} + _task_map[task.task_id] = task + await _task_queue.put(task) + return {"task_id": task.task_id, "status": "queued"} + + if method == "values_oriented_requirements": + base_id = await effects_utils.resolve_base_id(token, getattr(params, "scenario_id")) + logger.info( + "[Tasks] values_oriented_requirements base_id=%s (requested=%s)", + base_id, getattr(params, "scenario_id") + ) + + base_params = params.model_copy(update={ + "scenario_id": base_id, + "proj_func_zone_source": None, + "proj_func_source_year": None, + "context_func_zone_source": None, + "context_func_source_year": None, + }) + norm_params = await effects_service.get_optimal_func_zone_data(base_params, token) - else: - norm_params = await effects_service.get_optimal_func_zone_data(params, token) params_for_hash = await effects_service.build_hash_params(norm_params, token) phash = file_cache.params_hash(params_for_hash) - owner_id = norm_params.scenario_id - - task = AnyTask( - method, - owner_id, - token, - norm_params, - phash, - file_cache, - task_id, - ) + owner_id = base_id + task_id = f"{method}_{owner_id}_{phash}" + + cached = file_cache.load(method, owner_id, phash) + if cached and "data" in cached and "result" in cached["data"]: + logger.info("[Tasks] Cache hit for values_oriented_requirements -> DONE") + return {"task_id": task_id, "status": "done"} + + task = AnyTask(method, owner_id, token, norm_params, phash, file_cache, task_id) + if task.task_id in _task_map: + return {"task_id": task.task_id, "status": "running"} + _task_map[task.task_id] = task + await _task_queue.put(task) + return {"task_id": task.task_id, "status": "queued"} + + norm_params = await effects_service.get_optimal_func_zone_data(params, token) + params_for_hash = await effects_service.build_hash_params(norm_params, token) + phash = file_cache.params_hash(params_for_hash) + owner_id = norm_params.scenario_id + task_id = f"{method}_{owner_id}_{phash}" + + cached = file_cache.load(method, owner_id, phash) + if cached and "data" in cached: + return {"task_id": task_id, "status": "done"} + + task = AnyTask(method, owner_id, token, norm_params, phash, file_cache, task_id) + if task.task_id in _task_map: + return {"task_id": task.task_id, "status": "running"} _task_map[task.task_id] = task await _task_queue.put(task) - - return task.task_id + return {"task_id": task.task_id, "status": "queued"} async def _worker(): diff --git a/app/effects_api/schemas/service_types_response_schema.py b/app/effects_api/schemas/service_types_response_schema.py index db4c35a..0196a04 100644 --- a/app/effects_api/schemas/service_types_response_schema.py +++ b/app/effects_api/schemas/service_types_response_schema.py @@ -19,3 +19,12 @@ class ServiceTypesResponse(BaseModel): description="Service types in the transformed (after) scenario; may be empty or identical to 'before'" ) + +class ValuesServiceTypesResponse(BaseModel): + """ + List of service types available for values oriented requirements. + """ + services: List[ServiceType] = Field( + ..., + description="Service types in the base scenario" + ) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 79c6fa1..2a6f2de 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -1,5 +1,5 @@ import asyncio -from typing import Annotated +from typing import Annotated, Union from fastapi import APIRouter from fastapi.params import Depends @@ -13,6 +13,7 @@ _task_queue, ) from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO +from .schemas.service_types_response_schema import ServiceTypesResponse, ValuesServiceTypesResponse from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client @@ -232,23 +233,38 @@ async def task_status(task_id: str): raise http_exception(404, "task not found", task_id) -@router.get("/get_service_types", - summary="List service types for a scenario", - description=( - "Returns service type identifiers available for a given `scenario_id` and `method` " - "from the cached layer. Intended to help clients discover which services can be requested." - )) +@router.get( + "/get_service_types", + summary="List service types", + response_model=Union[ServiceTypesResponse, ValuesServiceTypesResponse], + description=( + "Returns service type identifiers available for a given `scenario_id` and `method` " + "from the cached layer. Intended to help clients discover which services can be requested." + "For 'territory_transformation' method 'before' and 'after' keys with services are returned" + "For 'values_oriented_requirements' only 'services' key with services is returned" + ), + response_model_exclude_none=True, +) async def get_service_types( scenario_id: int, method: str = "territory_transformation", token: str = Depends(verify_token), ): - reposnse = await get_services_with_ids_from_layer( - scenario_id, method, file_cache, effects_utils, token=token - ) - return await get_services_with_ids_from_layer( - scenario_id, method, file_cache, effects_utils, token=token - ) + """Return service types depending on the method.""" + if method == "territory_transformation": + data = await get_services_with_ids_from_layer( + scenario_id, method, file_cache, effects_utils, token=token + ) + return ServiceTypesResponse(before=data["before"], after=data.get("after", [])) + + if method == "values_oriented_requirements": + services = await get_services_with_ids_from_layer( + scenario_id, method, file_cache, effects_utils, token=token + ) + return ValuesServiceTypesResponse( + services=services.get("services", []) + ) + raise http_exception(400, f"Unsupported method", f"{method}") @router.get("/territory_transformation/{scenario_id}/{service_name}", diff --git a/app/main.py b/app/main.py index e2374cb..23d087e 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,7 @@ allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) -app.add_middleware(ExceptionHandlerMiddleware) +# app.add_middleware(ExceptionHandlerMiddleware) @app.get("/", include_in_schema=False) From 22dde9d7a9f60176e27146c874d32f04383d4a5d Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 29 Oct 2025 23:54:46 +0300 Subject: [PATCH 113/161] fix(f35): 1. Implementation of response schema for F35 territory_transformation --- ...erritory_transformation_response_schema.py | 24 ++++++++++++++++ app/effects_api/tasks_controller.py | 28 +++++++++---------- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/app/effects_api/schemas/territory_transformation_response_schema.py b/app/effects_api/schemas/territory_transformation_response_schema.py index e69de29..30a1281 100644 --- a/app/effects_api/schemas/territory_transformation_response_schema.py +++ b/app/effects_api/schemas/territory_transformation_response_schema.py @@ -0,0 +1,24 @@ +from typing import Optional, Dict + +from pydantic import BaseModel, Field +from pydantic_geojson import FeatureCollectionModel + + +class TerritoryTransformationLayerResponse(BaseModel): + """ + API response for a single service's territory transformation layer. + Either 'before', 'after', or both can be present. + Provision totals are optional numeric aggregates. + """ + before: Optional[FeatureCollectionModel] = Field( + None, description="GeoJSON FeatureCollection for the base (before) scenario" + ) + after: Optional[FeatureCollectionModel] = Field( + None, description="GeoJSON FeatureCollection for the transformed (after) scenario" + ) + provision_total_before: Dict[str, float] = Field( + None, description="Provision values for the base scenario, by service name" + ) + provision_total_after: Optional[Dict[str, float]] = Field( + None, description="Provision values for the transformed scenario, by service name" + ) \ No newline at end of file diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 2a6f2de..a7878e9 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -14,6 +14,7 @@ ) from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO from .schemas.service_types_response_schema import ServiceTypesResponse, ValuesServiceTypesResponse +from .schemas.territory_transformation_response_schema import TerritoryTransformationLayerResponse from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client @@ -276,7 +277,9 @@ async def get_service_types( "- When both versions exist: returns `{ before, after, provision_total_before, provision_total_after }`\n" "- When only `before` exists: returns `{ before, provision_total_before }`\n" "- When only `after` exists: returns `{ after, provision_total_after }`" - )) + ), + response_model=TerritoryTransformationLayerResponse, + ) async def get_territory_transformation_layer(scenario_id: int, service_name: str): cached = file_cache.load_latest("territory_transformation", scenario_id) if not cached: @@ -288,7 +291,7 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str fc = data.get("before", {}).get(service_name) if not fc: raise http_exception(404, f"service '{service_name}' not found") - return JSONResponse(content=fc) + return TerritoryTransformationLayerResponse(before= fc) before_dict = data.get("before", {}) or {} after_dict = data.get("after", {}) or {} @@ -300,23 +303,20 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str provision_after = after_dict.get("provision_total_after") if fc_before and fc_after: - return JSONResponse( - content={ - "before": fc_before, - "after": fc_after, - "provision_total_before": provision_before, - "provision_total_after": provision_after, - } + return TerritoryTransformationLayerResponse( + before = fc_before, + after = fc_after, + provision_total_before = provision_before, + provision_total_after = provision_after, ) if fc_before and not fc_after: - return JSONResponse( - content={"before": fc_before, "provision_total_before": provision_before} - ) + return TerritoryTransformationLayerResponse( + before = fc_before, provision_total_before = provision_before) if fc_after and not fc_before: - return JSONResponse( - content={"after": fc_after, "provision_total_after": provision_after} + return TerritoryTransformationLayerResponse( + after= fc_after, provision_total_after = provision_after ) raise http_exception(404, f"service '{service_name}' not found") From 9cd83c9a0170b6ae5f085776e6ce7d5330a7d1be Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 30 Oct 2025 15:43:06 +0300 Subject: [PATCH 114/161] feat(response_schema): 1. Added response schemas for all endpoints --- app/common/dto/models.py | 16 +++- app/effects_api/effects_service.py | 4 +- .../schemas/development_response_schema.py | 29 ------- .../socio_economic_metrics_response_schema.py | 6 ++ .../schemas/socio_economic_response_schema.py | 77 ------------------- ...erritory_transformation_response_schema.py | 21 +++-- .../values_oriented_response_schema.py | 21 +++++ .../schemas/values_tables_response_schema.py | 15 ++++ .../values_transformation_response_schema.py | 10 +++ app/effects_api/tasks_controller.py | 67 +++++++++------- 10 files changed, 121 insertions(+), 145 deletions(-) delete mode 100644 app/effects_api/schemas/development_response_schema.py delete mode 100644 app/effects_api/schemas/socio_economic_response_schema.py diff --git a/app/common/dto/models.py b/app/common/dto/models.py index 0850848..1a708bb 100644 --- a/app/common/dto/models.py +++ b/app/common/dto/models.py @@ -1,6 +1,8 @@ -from typing import Literal +from typing import Literal, List, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel +from pydantic_geojson import PolygonModel, MultiPolygonModel, FeatureModel +from pydantic_geojson._base import FeatureCollectionFieldType class SourceYear(BaseModel): @@ -11,3 +13,13 @@ class SourceYear(BaseModel): class ServiceType(BaseModel): id: int name: str + +class FeatureCollectionModel(BaseModel): + type: str = FeatureCollectionFieldType + features: List[ + Union[ + PolygonModel, + MultiPolygonModel, + FeatureModel + ], + ] \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index ede6832..c9c3251 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -19,8 +19,8 @@ WeightedObjective, GradientChooser, ) from blocksnet.relations import ( - calculate_accessibility_matrix, - get_accessibility_graph, calculate_distance_matrix, generate_adjacency_graph, + calculate_distance_matrix, + generate_adjacency_graph, ) from loguru import logger diff --git a/app/effects_api/schemas/development_response_schema.py b/app/effects_api/schemas/development_response_schema.py deleted file mode 100644 index 00b6663..0000000 --- a/app/effects_api/schemas/development_response_schema.py +++ /dev/null @@ -1,29 +0,0 @@ -from pydantic import BaseModel, Field, model_validator -from typing_extensions import Self - -from app.effects_api.dto.development_dto import ContextDevelopmentDTO, DevelopmentDTO - - -class DevelopmentResponseSchema(BaseModel): - """ - Schema for the response of the development endpoint. - """ - - site_area: list[float] - fsi: list[float] - gsi: list[float] - mxi: list[float] - build_floor_area: list[float] - footprint_area: list[float] - living_area: list[float] - non_living_area: list[float] - population: list[float] - params_data: DevelopmentDTO | ContextDevelopmentDTO - - @model_validator(mode="after") - def round_floats(self) -> Self: - for field, value in self.model_fields.items(): - if self.model_fields[field].annotation == list[float]: - rounded = [round(i, 2) for i in getattr(self, field)] - setattr(self, field, rounded) - return self diff --git a/app/effects_api/schemas/socio_economic_metrics_response_schema.py b/app/effects_api/schemas/socio_economic_metrics_response_schema.py index e69de29..bac0fb3 100644 --- a/app/effects_api/schemas/socio_economic_metrics_response_schema.py +++ b/app/effects_api/schemas/socio_economic_metrics_response_schema.py @@ -0,0 +1,6 @@ + +from pydantic import BaseModel + + +class SocioEconomicMetricsResponseSchema(BaseModel): + results: dict[str, list[dict[str, int | float]]] \ No newline at end of file diff --git a/app/effects_api/schemas/socio_economic_response_schema.py b/app/effects_api/schemas/socio_economic_response_schema.py deleted file mode 100644 index 42b2ff3..0000000 --- a/app/effects_api/schemas/socio_economic_response_schema.py +++ /dev/null @@ -1,77 +0,0 @@ -from typing import Dict, Optional, Union - -from pydantic import BaseModel, Field, field_validator, model_serializer - -from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.dto.development_dto import ( - ContextDevelopmentDTO, - DevelopmentDTO, -) - -from ..dto.socio_economic_project_dto import ( - SocioEconomicByProjectDTO, -) -from ..dto.socio_economic_scenario_dto import SocioEconomicByScenarioDTO -from .output_maps import pred_columns_names_map, soc_economy_pred_name_map - - -class SocioEconomicParams(BaseModel): - pred: int = Field(..., description="Prediction column name") - lower: int = Field(..., description="Lower prediction column name") - upper: float = Field(..., description="Upper prediction column name") - is_interval: bool = Field(..., description="Is interval prediction column name") - - @field_validator("upper", mode="after") - @classmethod - def validate_upper(cls, v: float) -> float: - return round(v, 2) - - @model_serializer - def serialize_model(self): - return { - pred_columns_names_map[str(field)]: getattr(self, field) - for field in self.model_fields - } - - -class SocioEconomicSchema(BaseModel): - socio_economic_prediction: dict[str, SocioEconomicParams] - - @field_validator("socio_economic_prediction", mode="after") - @classmethod - def rename_attributes(cls, value: dict[str, SocioEconomicParams]): - - try: - return { - ( - k - if k not in pred_columns_names_map.keys() - else soc_economy_pred_name_map[k] - ): v - for k, v in value.items() - } - except KeyError as key_e: - raise http_exception( - 500, - "Could not rename socio economic prediction attributes", - _input=list(value.keys()), - _detail={"error": repr(key_e)}, - ) from key_e - except Exception as e: - raise http_exception( - 500, - "Error during output validation", - _input=list(value.keys()), - _detail={"error": repr(e)}, - ) from e - - -class SocioEconomicResponseSchema(SocioEconomicSchema): - socio_economic_prediction: Dict[int, Dict[str, SocioEconomicParams]] - split_prediction: Optional[Dict[int, SocioEconomicSchema]] - params_data: Union[ - DevelopmentDTO, - ContextDevelopmentDTO, - SocioEconomicByProjectDTO, - SocioEconomicByScenarioDTO - ] diff --git a/app/effects_api/schemas/territory_transformation_response_schema.py b/app/effects_api/schemas/territory_transformation_response_schema.py index 30a1281..295bb04 100644 --- a/app/effects_api/schemas/territory_transformation_response_schema.py +++ b/app/effects_api/schemas/territory_transformation_response_schema.py @@ -1,10 +1,21 @@ from typing import Optional, Dict from pydantic import BaseModel, Field -from pydantic_geojson import FeatureCollectionModel +from app.common.dto.models import FeatureCollectionModel -class TerritoryTransformationLayerResponse(BaseModel): + +# from pydantic_geojson import FeatureCollectionModel + +class TerritoryTransformationResponseTablesSchema(BaseModel): + provision_total_before: Dict[str, float] = Field( + None, description="Provision values for the base scenario, by service name" + ) + provision_total_after: Optional[Dict[str, float]] = Field( + None, description="Provision values for the transformed scenario, by service name" + ) + +class TerritoryTransformationLayerResponse(TerritoryTransformationResponseTablesSchema): """ API response for a single service's territory transformation layer. Either 'before', 'after', or both can be present. @@ -16,9 +27,3 @@ class TerritoryTransformationLayerResponse(BaseModel): after: Optional[FeatureCollectionModel] = Field( None, description="GeoJSON FeatureCollection for the transformed (after) scenario" ) - provision_total_before: Dict[str, float] = Field( - None, description="Provision values for the base scenario, by service name" - ) - provision_total_after: Optional[Dict[str, float]] = Field( - None, description="Provision values for the transformed scenario, by service name" - ) \ No newline at end of file diff --git a/app/effects_api/schemas/values_oriented_response_schema.py b/app/effects_api/schemas/values_oriented_response_schema.py index e69de29..9884481 100644 --- a/app/effects_api/schemas/values_oriented_response_schema.py +++ b/app/effects_api/schemas/values_oriented_response_schema.py @@ -0,0 +1,21 @@ +from typing import Dict, Optional, List + +from pydantic import BaseModel, Field + +from app.common.dto.models import FeatureCollectionModel + + +class ValuesOrientedResponseSchema(BaseModel): + base_scenario_id: int = Field( + None, description="Id of the base scenario" + ) + geojson: FeatureCollectionModel = Field( + None, description="GeoJSON FeatureCollection for the base scenario" + ) + values_table: Dict[str, Dict[str, str | float]] = Field( + None, description="Values table for the base scenario" + ) + services_type_deficit: List[Dict[str, str | float | List[str]]] = Field( + None, description="Services type deficit for the base scenario" + ) + diff --git a/app/effects_api/schemas/values_tables_response_schema.py b/app/effects_api/schemas/values_tables_response_schema.py index e69de29..a9a6875 100644 --- a/app/effects_api/schemas/values_tables_response_schema.py +++ b/app/effects_api/schemas/values_tables_response_schema.py @@ -0,0 +1,15 @@ +from typing import Dict, List + +from pydantic import BaseModel, Field + + +class ValuesOrientedResponseTablesSchema(BaseModel): + base_scenario_id: int = Field( + None, description="Id of the base scenario" + ) + values_table: Dict[str, Dict[str, str | float]] = Field( + None, description="Values table for the base scenario" + ) + services_type_deficit: List[Dict[str, str | float | List[str]]] = Field( + None, description="Services type deficit for the base scenario" + ) \ No newline at end of file diff --git a/app/effects_api/schemas/values_transformation_response_schema.py b/app/effects_api/schemas/values_transformation_response_schema.py index e69de29..a9ed71e 100644 --- a/app/effects_api/schemas/values_transformation_response_schema.py +++ b/app/effects_api/schemas/values_transformation_response_schema.py @@ -0,0 +1,10 @@ +from typing import Dict, Optional, List + +from pydantic import BaseModel, Field + +from app.common.dto.models import FeatureCollectionModel + + +class ValuesTransformationSchema(BaseModel): + geojson: FeatureCollectionModel = Field( + None, description="GeoJSON FeatureCollection for the scenario") \ No newline at end of file diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index a7878e9..673089c 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -14,7 +14,12 @@ ) from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO from .schemas.service_types_response_schema import ServiceTypesResponse, ValuesServiceTypesResponse -from .schemas.territory_transformation_response_schema import TerritoryTransformationLayerResponse +from .schemas.socio_economic_metrics_response_schema import SocioEconomicMetricsResponseSchema +from .schemas.territory_transformation_response_schema import TerritoryTransformationLayerResponse, \ + TerritoryTransformationResponseTablesSchema +from .schemas.values_oriented_response_schema import ValuesOrientedResponseSchema +from .schemas.values_tables_response_schema import ValuesOrientedResponseTablesSchema +from .schemas.values_transformation_response_schema import ValuesTransformationSchema from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client @@ -328,7 +333,8 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str "Returns the GeoJSON layer and values table for a `service_name`, computed for the " "**base scenario** of the provided `scenario_id`.\n\n" "Rejects the request if the cached base result is stale compared to the base scenario metadata." - )) + ), + response_model=ValuesOrientedResponseSchema) async def get_values_oriented_requirements_layer( scenario_id: int, service_name: str, @@ -358,13 +364,11 @@ async def get_values_oriented_requirements_layer( 404, f"service '{service_name}' not found in base scenario {base_id}" ) - return JSONResponse( - content={ - "base_scenario_id": base_id, - "geojson": prov, - "values_table": values_dict, - "services_type_deficit": values_table, - } + return ValuesOrientedResponseSchema( + base_scenario_id= base_id, + geojson= prov, + values_table= values_dict, + services_type_deficit= values_table, ) @@ -373,7 +377,9 @@ async def get_values_oriented_requirements_layer( description=( "Returns the values table and service-type deficit table for the **base scenario** " "of the provided `scenario_id`." - )) + ), + response_model=ValuesOrientedResponseTablesSchema + ) async def get_values_oriented_requirements_table( scenario_id: int, token: str = Depends(verify_token), @@ -396,12 +402,10 @@ async def get_values_oriented_requirements_table( values_dict = data.get("result") values_table = data.get("social_values_table") - return JSONResponse( - content={ - "base_scenario_id": base_id, - "values_table": values_dict, - "services_type_deficit": values_table, - } + return ValuesOrientedResponseTablesSchema( + base_scenario_id = base_id, + values_table = values_dict, + services_type_deficit = values_table, ) @@ -411,14 +415,24 @@ async def get_values_oriented_requirements_table( "Reads the latest cached JSON payload for a given `method_name` and owner id. " "For scenario-based methods the owner is a **scenario id**; for project-based " "methods the owner is a **project id**." - )) + ), + response_model=Union[ValuesTransformationSchema, SocioEconomicMetricsResponseSchema]) async def get_layer(project_scenario_id: int, method_name: str): cached = file_cache.load_latest(method_name, project_scenario_id) if not cached: raise http_exception(404, "no saved result for this scenario", project_scenario_id) - data: dict = cached["data"] - return JSONResponse(content=data) + data = cached["data"] + + if method_name == "values_transformation": + return ValuesTransformationSchema(geojson=data) + + if method_name == "social_economical_metrics": + data = cached["data"]["results"] + return SocioEconomicMetricsResponseSchema(results=data) + + else: + raise http_exception(400, "Method not implemented", method_name, "Allowed methods: values_transformation, social_economical_metrics") @router.get("/get_provisions/{scenario_id}", @@ -428,7 +442,8 @@ async def get_layer(project_scenario_id: int, method_name: str): "the specified `scenario_id`. Depending on availability, the response contains:\n" "- `provision_total_before` and `provision_total_after`, or\n" "- only one of them if the other is not present." - )) + ), + response_model=TerritoryTransformationResponseTablesSchema) async def get_total_provisions(scenario_id: int): cached = file_cache.load_latest("territory_transformation", scenario_id) if not cached: @@ -443,17 +458,15 @@ async def get_total_provisions(scenario_id: int): provision_after = after_dict.get("provision_total_after") if provision_before and provision_after: - return JSONResponse( - content={ - "provision_total_before": provision_before, - "provision_total_after": provision_after, - } + return TerritoryTransformationResponseTablesSchema( + provision_total_before = provision_before, + provision_total_after= provision_after, ) if provision_before and not provision_after: - return JSONResponse(content={"provision_total_before": provision_before}) + return TerritoryTransformationResponseTablesSchema(provision_total_before = provision_before) if provision_after and not provision_before: - return JSONResponse(content={"provision_total_after": provision_after}) + return TerritoryTransformationResponseTablesSchema(provision_total_after= provision_after) raise http_exception(404, f"Result for scenario ID{scenario_id} not found") From bb752d54bc757ad7339b4e88c63a703ba98a3277 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 30 Oct 2025 16:41:28 +0300 Subject: [PATCH 115/161] fix(response_schema): 1. Fix for SocioEconomicMetricsResponseSchema 2. Round of social_economical_effects --- app/effects_api/effects_service.py | 1 + .../schemas/socio_economic_metrics_response_schema.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c9c3251..b85e65b 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1050,6 +1050,7 @@ async def _compute_for_single_scenario( long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(self.effects_utils.clean_number) long_df["indicator_id"] = long_df["indicator_id"].apply(self.effects_utils.clean_number) long_df["value"] = long_df["value"].apply(self.effects_utils.clean_number) + long_df["value"] = long_df["value"].round(2) long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()].fillna(0) return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") diff --git a/app/effects_api/schemas/socio_economic_metrics_response_schema.py b/app/effects_api/schemas/socio_economic_metrics_response_schema.py index bac0fb3..df97567 100644 --- a/app/effects_api/schemas/socio_economic_metrics_response_schema.py +++ b/app/effects_api/schemas/socio_economic_metrics_response_schema.py @@ -3,4 +3,5 @@ class SocioEconomicMetricsResponseSchema(BaseModel): - results: dict[str, list[dict[str, int | float]]] \ No newline at end of file + results: dict[str, dict[str, dict[str, int | float]]] + # results: dict \ No newline at end of file From c3f581906ad1fab5bc1b5bd712f6d7d9aa80c5b5 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 30 Oct 2025 16:44:17 +0300 Subject: [PATCH 116/161] fix(main): 1. Return of exceptions --- app/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/main.py b/app/main.py index 23d087e..e2374cb 100644 --- a/app/main.py +++ b/app/main.py @@ -25,7 +25,7 @@ allow_headers=["*"], ) app.add_middleware(GZipMiddleware, minimum_size=100) -# app.add_middleware(ExceptionHandlerMiddleware) +app.add_middleware(ExceptionHandlerMiddleware) @app.get("/", include_in_schema=False) From 9a06aec234f642c171293c23be06bc577f5abfa0 Mon Sep 17 00:00:00 2001 From: Dmitry-Grachev Date: Fri, 31 Oct 2025 15:27:29 +0300 Subject: [PATCH 117/161] pypi mirror config changed --- pip.conf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pip.conf b/pip.conf index 774069a..d16d3ff 100644 --- a/pip.conf +++ b/pip.conf @@ -1,4 +1,4 @@ [global] -index-url=http://10.32.1.108:3141/root/pypi/+simple/ -trusted-host=10.32.1.108 +index-url=http://10.32.11.13:3141/root/pypi/+simple/ +trusted-host=10.32.11.13 timeout=120 From 07be102ac360923f1e7fcb5fc8baea2d2920e0df Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 31 Oct 2025 18:21:16 +0300 Subject: [PATCH 118/161] fix(comments_tmp): 1. Comment fixes --- app/clients/urban_api_client.py | 2 +- app/common/utils/effects_utils.py | 108 +------- app/effects_api/effects_service.py | 247 ++++++++++++++---- app/effects_api/modules/services_service.py | 48 ---- app/effects_api/modules/task_service.py | 2 +- .../socio_economic_metrics_response_schema.py | 1 - ...erritory_transformation_response_schema.py | 2 - .../schemas/values_tables_response_schema.py | 2 +- app/effects_api/tasks_controller.py | 3 +- 9 files changed, 206 insertions(+), 209 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index c9ae971..3f26c24 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -398,7 +398,7 @@ async def get_social_values_info(self) -> dict[int, str]: res = await self.json_handler.get("/api/v1/social_values") return {item["soc_value_id"]: item["name"] for item in res} - async def get_territory_normatives(self, territory_id: int): + async def get_territory_normatives(self, territory_id: int) -> pd.DataFrame: res = await self.json_handler.get(f'/api/v1/territory/{territory_id}/normatives', params={'last_only': True}) df = pd.DataFrame(res) df['service_type_id'] = df['service_type'].apply(lambda st: st['id']) diff --git a/app/common/utils/effects_utils.py b/app/common/utils/effects_utils.py index 48024bb..f54e11d 100644 --- a/app/common/utils/effects_utils.py +++ b/app/common/utils/effects_utils.py @@ -25,7 +25,7 @@ def parent_id(self, s: Dict[str, Any]) -> Optional[int]: p = s.get("parent_scenario") return p.get("id") if isinstance(p, dict) else p - def sid(self, s: Dict[str, Any]) -> Optional[int]: + def get_service_id(self, s: Dict[str, Any]) -> Optional[int]: try: return int(s.get("scenario_id")) except Exception: @@ -45,13 +45,13 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: for s in scenarios if self.truthy_is_based(s.get("is_based")) and self.parent_id(s) == regional_id - and self.sid(s) is not None + and self.get_service_id(s) is not None ] if not matches: only_based = [ s for s in scenarios - if self.truthy_is_based(s.get("is_based")) and self.sid(s) is not None + if self.truthy_is_based(s.get("is_based")) and self.get_service_id(s) is not None ] if not only_based: return scenario_id @@ -61,104 +61,4 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True, ) - return self.sid(matches[0]) or scenario_id - - def clean_number(self, v): - if v is None or (isinstance(v, float) and np.isnan(v)): - return None - try: - if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite(float(v)): - return None - except Exception: - pass - if isinstance(v, (np.integer,)): - return int(v) - if isinstance(v, (np.floating,)): - return float(v) - return v - - def build_facade( - self, - after_blocks: gpd.GeoDataFrame, - acc_mx: pd.DataFrame, - service_types: pd.DataFrame, - ) -> Facade: - blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] - blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() - - var_adapter = AreaSolution(blocks_lus) - - facade = Facade( - blocks_lu=blocks_lus, - blocks_df=after_blocks, - accessibility_matrix=acc_mx, - var_adapter=var_adapter, - ) - - for st_id, row in service_types.iterrows(): - st_name = row["name"] - st_weight = row["infrastructure_weight"] - st_column = f"capacity_{st_name}" - - if st_column in after_blocks.columns: - df = after_blocks.rename(columns={st_column: "capacity"})[ - ["capacity"] - ].fillna(0) - else: - df = after_blocks[[]].copy() - df["capacity"] = 0 - facade.add_service_type(st_name, st_weight, df) - - return facade - - def pivot_results_by_territory( - self, - results: dict[int, list[dict]] - ) -> dict[int, dict[int, dict[int, float]]]: - """ - Transform scenario-first results to territory-first pivot. - - Input: - results: { - scenario_id: [ - {"territory_id": int, "indicator_id": int, "value": number}, - ... - ], - ... - } - - Output: - { - territory_id: [ - {"indicator_id": int, : number, : number, ...}, - ... - ], - ... - } - """ - pivot: dict[int, dict[int, dict[int, float]]] = {} - - for scenario_id, records in results.items(): - if not records: - continue - for rec in records: - try: - t_id = int(rec["territory_id"]) - ind_id = int(rec["indicator_id"]) - val = rec.get("value") - except (KeyError, TypeError, ValueError) as exc: - logger.warning( - f"[Effects] Skip bad record in scenario {scenario_id}: {rec} ({exc})" - ) - continue - - if t_id not in pivot: - pivot[t_id] = {} - if ind_id not in pivot[t_id]: - pivot[t_id][ind_id] = {} - pivot[t_id][ind_id][int(scenario_id)] = val - - logger.info( - f"[Effects] Pivoted to nested format: {len(pivot)} territories." - ) - return pivot \ No newline at end of file + return self.get_service_id(matches[0]) or scenario_id \ No newline at end of file diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index b85e65b..f372b67 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,5 +1,5 @@ import json -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, Literal import geopandas as gpd import numpy as np @@ -16,7 +16,7 @@ from blocksnet.optimization.services import ( TPEOptimizer, WeightedConstraints, - WeightedObjective, GradientChooser, + WeightedObjective, GradientChooser, Facade, AreaSolution, ) from blocksnet.relations import ( calculate_distance_matrix, @@ -28,7 +28,7 @@ from app.effects_api.modules.service_type_service import ( adapt_service_types, build_en_to_ru_map, - remap_properties_keys_in_geojson, generate_blocksnet_columns, ensure_missing_id_and_name_columns, + generate_blocksnet_columns, ensure_missing_id_and_name_columns, ) from .modules.context_service import ContextService from ..clients.urban_api_client import UrbanAPIClient @@ -170,7 +170,12 @@ async def calculate_provision_totals( if prov_gdf.demand.sum() == 0: prov_totals[st_name] = None else: - total = float(provision_strong_total(prov_gdf)) + try: + total = float(provision_strong_total(prov_gdf)) + except Exception as e: + logger.exception("Provision total calculation failed") + raise http_exception(500, "Provision total calculation failed", + _input={"service_type": st_name}, _detail=str(e)) prov_totals[st_name] = round(total, ndigits) return prov_totals @@ -238,7 +243,11 @@ async def territory_transformation_scenario_before( before_blocks["is_project"] = ( before_blocks["is_project"].fillna(False).astype(bool) ) - acc_mx = get_accessibility_matrix(before_blocks) + try: + acc_mx = get_accessibility_matrix(before_blocks) + except Exception as e: + logger.exception(f"Error getting accessibility matrix: {str(e)}") + raise http_exception(500, "Error getting accessibility matrix", _detail=e) prov_gdfs_before = {} for st_id in service_types.index: @@ -256,10 +265,14 @@ async def territory_transformation_scenario_before( prov_totals = await self.calculate_provision_totals(prov_gdfs_before) existing_data = cached["data"] if cached else {} - existing_data["before"] = { - name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) - for name, gdf in prov_gdfs_before.items() - } + try: + existing_data["before"] = { + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs_before.items() + } + except Exception as e: + logger.exception(f"Error calculating BEFORE: {str(e)}") + raise http_exception(500, "Error calculating BEFORE", _detail=e) existing_data["before"]["provision_total_before"] = prov_totals self.cache.save( @@ -302,10 +315,20 @@ async def run_development_parameters( for lu in LandUse: blocks_gdf[lu.value] = blocks_gdf[lu.value].apply(lambda v: min(v, 1)) - adjacency_graph = generate_adjacency_graph(blocks_gdf, 10) + try: + adjacency_graph = generate_adjacency_graph(blocks_gdf, 10) + except Exception as e: + logger.exception("Adjacency graph generation failed") + raise http_exception(500, "Adjacency graph generation failed", _detail=str(e)) + dr = DensityRegressor() - density_df = dr.evaluate(blocks_gdf, adjacency_graph) + try: + density_df = dr.evaluate(blocks_gdf, adjacency_graph) + except Exception as e: + logger.exception("Density evaluation failed") + raise http_exception(500, "Density evaluation failed", _detail=str(e)) + density_df.loc[density_df["fsi"] < 0, "fsi"] = 0 density_df.loc[density_df["gsi"] < 0, "gsi"] = 0 @@ -317,11 +340,50 @@ async def run_development_parameters( density_df.loc[blocks_gdf["residential"] == 0, "mxi"] = 0 density_df["site_area"] = blocks_gdf["site_area"] - development_df = calculate_development_indicators(density_df) + try: + development_df = calculate_development_indicators(density_df) + except Exception as e: + logger.exception("Development indicator calculation failed") + raise http_exception(500, "Development indicator calculation failed", _detail=str(e)) + development_df["population"] = development_df["living_area"] // 20 return development_df + def _build_facade( + self, + after_blocks: gpd.GeoDataFrame, + acc_mx: pd.DataFrame, + service_types: pd.DataFrame, + ) -> Facade: + blocks_lus = after_blocks.loc[after_blocks["is_project"], "land_use"] + blocks_lus = blocks_lus[~blocks_lus.isna()].to_dict() + + var_adapter = AreaSolution(blocks_lus) + + facade = Facade( + blocks_lu=blocks_lus, + blocks_df=after_blocks, + accessibility_matrix=acc_mx, + var_adapter=var_adapter, + ) + + for st_id, row in service_types.iterrows(): + st_name = row["name"] + st_weight = row["infrastructure_weight"] + st_column = f"capacity_{st_name}" + + if st_column in after_blocks.columns: + df = after_blocks.rename(columns={st_column: "capacity"})[ + ["capacity"] + ].fillna(0) + else: + df = after_blocks[[]].copy() + df["capacity"] = 0 + facade.add_service_type(st_name, st_weight, df) + + return facade + async def territory_transformation_scenario_after( self, token, @@ -337,8 +399,9 @@ async def territory_transformation_scenario_after( is_based = info["is_based"] if is_based: + logger.exception("Base scenario has no 'after' layer needed for calculation") raise http_exception( - 400, "base scenario has no 'after' layer needed for calculation" + 400, "Base scenario has no 'after' layer needed for calculation" ) params = await self.get_optimal_func_zone_data(params, token) @@ -386,7 +449,11 @@ async def territory_transformation_scenario_after( after_blocks["is_project"] = ( after_blocks["is_project"].fillna(False).astype(bool) ) - acc_mx = get_accessibility_matrix(after_blocks) + try: + acc_mx = get_accessibility_matrix(after_blocks) + except Exception as e: + logger.exception("Accessibility matrix calculation failed") + raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) service_types["infrastructure_weight"] = ( service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) @@ -405,7 +472,7 @@ async def territory_transformation_scenario_after( after_blocks["population"] = pd.to_numeric( after_blocks["population"], errors="coerce" ).fillna(0) - facade = self.effects_utils.build_facade(after_blocks, acc_mx, service_types) + facade = self._build_facade(after_blocks, acc_mx, service_types) services_weights = service_types.set_index("name")[ "infrastructure_weight" @@ -424,9 +491,11 @@ async def territory_transformation_scenario_after( vars_chooser=GradientChooser(facade, facade.num_params, num_top=5), ) - best_x, best_val, perc, func_evals = tpe_optimizer.run( - max_runs=MAX_RUNS, timeout=10, initial_runs_num=1 - ) + try: + best_x, best_val, perc, func_evals = tpe_optimizer.run(max_runs=MAX_RUNS, timeout=10, initial_runs_num=1) + except Exception as e: + logger.exception("Optimization (TPE) failed") + raise http_exception(500, "Service placement optimization failed", _detail=str(e)) prov_gdfs_after = {} for st_id in service_types.index: @@ -610,7 +679,11 @@ async def values_transformation( else: after_blocks["is_project"] = False - acc_mx = get_accessibility_matrix(after_blocks) + try: + acc_mx = get_accessibility_matrix(after_blocks) + except Exception as e: + logger.exception("Accessibility matrix calculation failed") + raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) @@ -622,13 +695,18 @@ async def values_transformation( * service_types["infrastructure_weight"] ) - facade = self.effects_utils.build_facade(after_blocks, acc_mx, service_types) + facade = self._build_facade(after_blocks, acc_mx, service_types) test_blocks: gpd.GeoDataFrame = after_blocks.loc[ list(facade._blocks_lu.keys()) ].copy() test_blocks.index = test_blocks.index.astype(int) - solution_df = facade.solution_to_services_df(best_x).copy() + try: + solution_df = facade.solution_to_services_df(best_x).copy() + except Exception as e: + logger.exception("Solution calculation failed") + raise http_exception(500, "Solution calculation failed", _detail=str(e)) + solution_df["block_id"] = solution_df["block_id"].astype(int) metrics = [ c @@ -742,22 +820,26 @@ def _fill_cell(x): gdf_out.geometry = round_coords(gdf_out.geometry, 6) service_types = await self.urban_api_client.get_service_types() - en2ru = await build_en_to_ru_map(service_types) - rename_map = {k: v for k, v in en2ru.items() if k in gdf_out.columns} - if rename_map: - gdf_out = gdf_out.rename(columns=rename_map) + try: + en2ru = await build_en_to_ru_map(service_types) + rename_map = {k: v for k, v in en2ru.items() if k in gdf_out.columns} + if rename_map: + gdf_out = gdf_out.rename(columns=rename_map) - geom_col = gdf_out.geometry.name - non_geom = [c for c in gdf_out.columns if c != geom_col] + geom_col = gdf_out.geometry.name + non_geom = [c for c in gdf_out.columns if c != geom_col] - pin_first = [c for c in ["is_project", "Предсказанный вид использования"] if c in non_geom] + pin_first = [c for c in ["is_project", "Предсказанный вид использования"] if c in non_geom] - rest = [c for c in non_geom if c not in pin_first] - rest_sorted = sorted(rest, key=lambda s: s.casefold()) + rest = [c for c in non_geom if c not in pin_first] + rest_sorted = sorted(rest, key=lambda s: s.casefold()) - gdf_out = gdf_out[pin_first + rest_sorted + [geom_col]] + gdf_out = gdf_out[pin_first + rest_sorted + [geom_col]] - geojson = json.loads(gdf_out.to_json()) + geojson = json.loads(gdf_out.to_json()) + except Exception as e: + logger.exception("Failed to attach land-use predictions to gdf_out") + raise http_exception(500, "Failed to attach land-use predictions", e) self.cache.save( "values_transformation", @@ -859,7 +941,11 @@ def _result_to_df(payload: Any) -> pd.DataFrame: service_types = await adapt_service_types(service_types, self.urban_api_client) service_types = service_types[~service_types["social_values"].isna()].copy() - acc_mx = get_accessibility_matrix(blocks) + try: + acc_mx = get_accessibility_matrix(blocks) + except Exception as e: + logger.exception("Accessibility matrix calculation failed") + raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} for st_id in service_types.index: @@ -961,16 +1047,19 @@ def _result_to_df(payload: Any) -> pd.DataFrame: return result_df - async def _get_project_scenarios_by_parent( - self, project_id: int, regional_scenario_id: int, token: str - ) -> list[int]: - """Return scenario_ids for a given project that have the given regional_scenario_id as parent.""" - all_sc = await self.urban_api_client.get_project_scenarios(project_id, token) - return [ - int(s["scenario_id"]) - for s in all_sc - if (s.get("parent_scenario") or {}).get("id") == int(regional_scenario_id) - ] + def _clean_number(self, v): + if v is None or (isinstance(v, float) and np.isnan(v)): + return None + try: + if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite(float(v)): + return None + except Exception: + pass + if isinstance(v, (np.integer,)): + return int(v) + if isinstance(v, (np.floating,)): + return float(v) + return v async def _compute_for_single_scenario( self, @@ -1001,7 +1090,11 @@ async def _compute_for_single_scenario( ) context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) - assigned = assign_objects(before_blocks, context_territories_gdf.rename(columns={"parent": "name"})) + try: + assigned = assign_objects(before_blocks, context_territories_gdf.rename(columns={"parent": "name"})) + except Exception as e: + logger.exception("Error assigning objects") + raise http_exception(500, "Error assigning objects", _detail=str(e)) before_blocks["parent"] = assigned["name"].astype(int) if only_parent_ids: @@ -1022,7 +1115,11 @@ async def _compute_for_single_scenario( ) roads_gdf = roads_gdf.to_crs(before_blocks.crs).overlay(before_blocks) - acc_mx = get_accessibility_matrix(before_blocks) + try: + acc_mx = get_accessibility_matrix(before_blocks) + except Exception as e: + logger.exception("Accessibility matrix calculation failed") + raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) dist_mx = calculate_distance_matrix(before_blocks) st_for_social = service_types_df[ @@ -1047,14 +1144,66 @@ async def _compute_for_single_scenario( long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) - long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(self.effects_utils.clean_number) - long_df["indicator_id"] = long_df["indicator_id"].apply(self.effects_utils.clean_number) - long_df["value"] = long_df["value"].apply(self.effects_utils.clean_number) + long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(self._clean_number) + long_df["indicator_id"] = long_df["indicator_id"].apply(self._clean_number) + long_df["value"] = long_df["value"].apply(self._clean_number) long_df["value"] = long_df["value"].round(2) long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()].fillna(0) return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") + def _pivot_results_by_territory( + self, + results: dict[int, list[dict]] + ) -> dict[int, dict[int, dict[int, float]]]: + """ + Transform scenario-first results to territory-first pivot. + + Input: + results: { + scenario_id: [ + {"territory_id": int, "indicator_id": int, "value": number}, + ... + ], + ... + } + + Output: + { + territory_id: [ + {"indicator_id": int, : number, : number, ...}, + ... + ], + ... + } + """ + pivot: dict[int, dict[int, dict[int, float]]] = {} + + for scenario_id, records in results.items(): + if not records: + continue + for rec in records: + try: + t_id = int(rec["territory_id"]) + ind_id = int(rec["indicator_id"]) + val = rec.get("value") + except (KeyError, TypeError, ValueError) as exc: + logger.warning( + f"[Effects] Skip bad record in scenario {scenario_id}: {rec} ({exc})" + ) + continue + + if t_id not in pivot: + pivot[t_id] = {} + if ind_id not in pivot[t_id]: + pivot[t_id][ind_id] = {} + pivot[t_id][ind_id][int(scenario_id)] = val + + logger.info( + f"[Effects] Pivoted to nested format: {len(pivot)} territories." + ) + return pivot + async def evaluate_social_economical_metrics( self, token: str, @@ -1121,7 +1270,7 @@ async def evaluate_social_economical_metrics( ) results[sid] = records - results = self.effects_utils.pivot_results_by_territory(results) + results = self._pivot_results_by_territory(results) project_info = await self.urban_api_client.get_project(project_id, token) updated_at = project_info.get("updated_at") diff --git a/app/effects_api/modules/services_service.py b/app/effects_api/modules/services_service.py index d808dcb..ff7c672 100644 --- a/app/effects_api/modules/services_service.py +++ b/app/effects_api/modules/services_service.py @@ -46,51 +46,3 @@ def adapt_services( st: gdf[gdf["service_type"] == st].drop(columns=["service_type"]) for st in sorted(gdf["service_type"].unique()) } - -async def get_services_layer(self, scenario_id: int, token: str): - """ - Fetch every service layer for a scenario, aggregate counts/capacities - into the scenario blocks and return the resulting block layer. - - Params: - scenario_id : int - Scenario whose services are queried and aggregated. - - Returns: - gpd.GeoDataFrame - Scenario block layer with additional columns - `capacity_` and `count_` for each - detected service category. - """ - blocks = await self.scenario.get_scenario_blocks(scenario_id, token) - blocks_crs = blocks.crs - logger.info( - f"{len(blocks)} START blocks layer scenario{scenario_id}, CRS: {blocks.crs}" - ) - service_types = await self.urban_api_client.get_service_types() - logger.info(f"{service_types}") - services_dict = await self.scenario.get_scenario_services( - scenario_id, service_types, token - ) - - for service_type, services in services_dict.items(): - services = services.to_crs(blocks_crs) - blocks_services, _ = aggregate_objects(blocks, services) - blocks_services["capacity"] = ( - blocks_services["capacity"].fillna(0).astype(int) - ) - blocks_services["count"] = ( - blocks_services["count"].fillna(0).astype(int) - ) - blocks = blocks.join( - blocks_services.drop(columns=["geometry"]).rename( - columns={ - "capacity": f"capacity_{service_type}", - "count": f"count_{service_type}", - } - ) - ) - logger.info( - f"{len(blocks)} SERVICES blocks layer scenario {scenario_id}, CRS: {blocks.crs}" - ) - return blocks diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 0010de2..b05b4b7 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -137,7 +137,7 @@ async def create_task( return {"task_id": task.task_id, "status": "queued"} if method == "values_oriented_requirements": - base_id = await effects_utils.resolve_base_id(token, getattr(params, "scenario_id")) + base_id = await effects_utils._resolve_base_id(token, getattr(params, "scenario_id")) logger.info( "[Tasks] values_oriented_requirements base_id=%s (requested=%s)", base_id, getattr(params, "scenario_id") diff --git a/app/effects_api/schemas/socio_economic_metrics_response_schema.py b/app/effects_api/schemas/socio_economic_metrics_response_schema.py index df97567..a5c1fcc 100644 --- a/app/effects_api/schemas/socio_economic_metrics_response_schema.py +++ b/app/effects_api/schemas/socio_economic_metrics_response_schema.py @@ -4,4 +4,3 @@ class SocioEconomicMetricsResponseSchema(BaseModel): results: dict[str, dict[str, dict[str, int | float]]] - # results: dict \ No newline at end of file diff --git a/app/effects_api/schemas/territory_transformation_response_schema.py b/app/effects_api/schemas/territory_transformation_response_schema.py index 295bb04..61fb4b0 100644 --- a/app/effects_api/schemas/territory_transformation_response_schema.py +++ b/app/effects_api/schemas/territory_transformation_response_schema.py @@ -5,8 +5,6 @@ from app.common.dto.models import FeatureCollectionModel -# from pydantic_geojson import FeatureCollectionModel - class TerritoryTransformationResponseTablesSchema(BaseModel): provision_total_before: Dict[str, float] = Field( None, description="Provision values for the base scenario, by service name" diff --git a/app/effects_api/schemas/values_tables_response_schema.py b/app/effects_api/schemas/values_tables_response_schema.py index a9a6875..63858d6 100644 --- a/app/effects_api/schemas/values_tables_response_schema.py +++ b/app/effects_api/schemas/values_tables_response_schema.py @@ -12,4 +12,4 @@ class ValuesOrientedResponseTablesSchema(BaseModel): ) services_type_deficit: List[Dict[str, str | float | List[str]]] = Field( None, description="Services type deficit for the base scenario" - ) \ No newline at end of file + ) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 673089c..d4a8ade 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -3,7 +3,6 @@ from fastapi import APIRouter from fastapi.params import Depends -from starlette.responses import JSONResponse from app.common.auth.auth import verify_token from app.effects_api.modules.task_service import ( @@ -340,7 +339,7 @@ async def get_values_oriented_requirements_layer( service_name: str, token: str = Depends(verify_token), ): - base_id = await effects_utils.resolve_base_id(token, scenario_id) + base_id = await effects_utils._resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: From 5aab7295e01424b6aabc3e1c63b4fb5bbd4981fd Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sat, 1 Nov 2025 00:38:39 +0300 Subject: [PATCH 119/161] fix(comment_fixes): 1. black formating 2. fix for optional values in response schemas 3. Deleted redundant functions --- app/clients/urban_api_client.py | 8 +- app/common/caching/caching_service.py | 50 +-- app/common/dto/models.py | 13 +- app/common/utils/effects_utils.py | 16 +- app/common/utils/geodata.py | 48 ++- app/dependencies.py | 4 +- app/effects_api/constants/const.py | 289 +++++++++--------- app/effects_api/effects_service.py | 232 +++++++++----- app/effects_api/modules/context_service.py | 159 +++++++--- app/effects_api/modules/scenario_service.py | 19 +- .../modules/service_type_service.py | 65 ++-- .../schemas/service_types_response_schema.py | 10 +- .../socio_economic_metrics_response_schema.py | 7 +- ...erritory_transformation_response_schema.py | 10 +- .../values_oriented_response_schema.py | 13 +- .../schemas/values_tables_response_schema.py | 8 +- .../values_transformation_response_schema.py | 5 +- app/system_router/system_controller.py | 1 + 18 files changed, 570 insertions(+), 387 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 3f26c24..ba0942a 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -399,7 +399,9 @@ async def get_social_values_info(self) -> dict[int, str]: return {item["soc_value_id"]: item["name"] for item in res} async def get_territory_normatives(self, territory_id: int) -> pd.DataFrame: - res = await self.json_handler.get(f'/api/v1/territory/{territory_id}/normatives', params={'last_only': True}) + res = await self.json_handler.get( + f"/api/v1/territory/{territory_id}/normatives", params={"last_only": True} + ) df = pd.DataFrame(res) - df['service_type_id'] = df['service_type'].apply(lambda st: st['id']) - return df.set_index('service_type_id', drop=True) + df["service_type_id"] = df["service_type"].apply(lambda st: st["id"]) + return df.set_index("service_type_id", drop=True) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index bcb5794..f612a1e 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any, Literal + import geopandas as gpd import pandas as pd @@ -121,36 +122,40 @@ def parse_task_id(self, task_id: str): else: phash = self.params_hash(tail) - scenario_id = int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw + scenario_id = ( + int(scenario_id_raw) if scenario_id_raw.isdigit() else scenario_id_raw + ) return method, scenario_id, phash def _artifact_path( - self, - method: str, - owner_id: int, - phash: str, - name: str, - ext: Literal["parquet", "pkl"], + self, + method: str, + owner_id: int, + phash: str, + name: str, + ext: Literal["parquet", "pkl"], ) -> Path: """Build path for a heavy artifact near JSON cache directory.""" fname = f"artifact__{_safe(method)}__{owner_id}__{phash}__{_safe(name)}.{ext}" return _CACHE_DIR / fname def save_df_artifact( - self, - df: pd.DataFrame, - method: str, - owner_id: int, - params: dict[str, Any], - name: str, - fmt: Literal["parquet", "pkl"] = "parquet", + self, + df: pd.DataFrame, + method: str, + owner_id: int, + params: dict[str, Any], + name: str, + fmt: Literal["parquet", "pkl"] = "parquet", ) -> Path: """ Save a pandas DataFrame as a heavy artifact. fmt='parquet' (default) is compact and fast; fmt='pkl' as a fallback. """ phash = self.params_hash(params) - path = self._artifact_path(method, owner_id, phash, name, "parquet" if fmt == "parquet" else "pkl") + path = self._artifact_path( + method, owner_id, phash, name, "parquet" if fmt == "parquet" else "pkl" + ) if fmt == "parquet": df.to_parquet(path, index=True) @@ -167,14 +172,15 @@ def load_df_artifact(self, path: Path) -> pd.DataFrame: elif ext == ".pkl": return pd.read_pickle(path) raise ValueError(f"Unsupported artifact extension: {ext}") + def save_gdf_artifact( - self, - gdf: gpd.GeoDataFrame, - method: str, - owner_id: int, - params: dict[str, Any], - name: str, - fmt: Literal["parquet", "pkl"] = "parquet", + self, + gdf: gpd.GeoDataFrame, + method: str, + owner_id: int, + params: dict[str, Any], + name: str, + fmt: Literal["parquet", "pkl"] = "parquet", ) -> Path: phash = self.params_hash(params) ext = "parquet" if fmt == "parquet" else "pkl" diff --git a/app/common/dto/models.py b/app/common/dto/models.py index 1a708bb..be8b36a 100644 --- a/app/common/dto/models.py +++ b/app/common/dto/models.py @@ -1,7 +1,7 @@ -from typing import Literal, List, Union +from typing import List, Literal, Union from pydantic import BaseModel -from pydantic_geojson import PolygonModel, MultiPolygonModel, FeatureModel +from pydantic_geojson import FeatureModel, MultiPolygonModel, PolygonModel from pydantic_geojson._base import FeatureCollectionFieldType @@ -14,12 +14,7 @@ class ServiceType(BaseModel): id: int name: str + class FeatureCollectionModel(BaseModel): type: str = FeatureCollectionFieldType - features: List[ - Union[ - PolygonModel, - MultiPolygonModel, - FeatureModel - ], - ] \ No newline at end of file + features: List[Union[PolygonModel, MultiPolygonModel, FeatureModel],] diff --git a/app/common/utils/effects_utils.py b/app/common/utils/effects_utils.py index f54e11d..c8d2107 100644 --- a/app/common/utils/effects_utils.py +++ b/app/common/utils/effects_utils.py @@ -1,12 +1,5 @@ -import re from typing import Any, Dict, Optional -import numpy as np -import pandas as pd -from blocksnet.optimization.services import AreaSolution, Facade -import geopandas as gpd -from loguru import logger - from app.clients.urban_api_client import UrbanAPIClient @@ -44,14 +37,15 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: s for s in scenarios if self.truthy_is_based(s.get("is_based")) - and self.parent_id(s) == regional_id - and self.get_service_id(s) is not None + and self.parent_id(s) == regional_id + and self.get_service_id(s) is not None ] if not matches: only_based = [ s for s in scenarios - if self.truthy_is_based(s.get("is_based")) and self.get_service_id(s) is not None + if self.truthy_is_based(s.get("is_based")) + and self.get_service_id(s) is not None ] if not only_based: return scenario_id @@ -61,4 +55,4 @@ async def resolve_base_id(self, token: str, scenario_id: int) -> int: key=lambda x: (x.get("updated_at") is not None, x.get("updated_at")), reverse=True, ) - return self.get_service_id(matches[0]) or scenario_id \ No newline at end of file + return self.get_service_id(matches[0]) or scenario_id diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index 6e850c9..ab20167 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -9,7 +9,7 @@ from shapely.wkt import dumps, loads from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.constants.const import COL_RU, SPEED, ROADS_ID +from app.effects_api.constants.const import COL_RU, ROADS_ID, SPEED from app.effects_api.modules.scenario_service import SOURCES_PRIORITY @@ -29,6 +29,7 @@ async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: return json.loads(gdf_copy.to_json(drop_id=True)) + def safe_gdf_to_geojson( gdf: gpd.GeoDataFrame, to_epsg: int = 4326, @@ -44,7 +45,9 @@ def safe_gdf_to_geojson( - Ensure all properties are JSON-serializable. - Return parsed dict (FeatureCollection). """ - logger.info(f"Serializing GeoDataFrame to GeoJSON (EPSG:{to_epsg}, round={round_ndigits})") + logger.info( + f"Serializing GeoDataFrame to GeoJSON (EPSG:{to_epsg}, round={round_ndigits})" + ) gdf2 = gdf.drop(columns=[c for c in drop_cols if c in gdf.columns]).copy() gdf2 = gdf2.to_crs(to_epsg) gdf2.geometry = round_coords(gdf2.geometry, round_ndigits) @@ -56,7 +59,11 @@ def fc_to_gdf(fc: dict) -> gpd.GeoDataFrame: def is_fc(obj: dict) -> bool: - return isinstance(obj, dict) and obj.get("type") == "FeatureCollection" and "features" in obj + return ( + isinstance(obj, dict) + and obj.get("type") == "FeatureCollection" + and "features" in obj + ) def round_coords( @@ -98,7 +105,10 @@ async def get_best_functional_zones_source( raise http_exception(404, "No available functional zone sources to choose from") -def gdf_join_on_block_id(left: gpd.GeoDataFrame, right: pd.DataFrame, how: str = "left") -> gpd.GeoDataFrame: + +def gdf_join_on_block_id( + left: gpd.GeoDataFrame, right: pd.DataFrame, how: str = "left" +) -> gpd.GeoDataFrame: """Join two frames by block_id index safely. - Ensures both indices are int. @@ -130,30 +140,8 @@ def _ensure_block_index(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame: gdf.index.name = "block_id" return gdf -def get_accessibility_matrix(blocks : gpd.GeoDataFrame) -> pd.DataFrame: - crs = blocks.estimate_utm_crs() - dist_mx = calculate_distance_matrix(blocks.to_crs(crs)) - return dist_mx // SPEED - - -async def _roads_overlay_fast(self, scenario_id: int, token: str, target_crs, bounds): - """ - Try to clip by bbox first, then lighter op than overlay. Offload to executor. - """ - logger.info(f"Fetching roads for scenario_id={scenario_id}") - roads_gdf = await self.urban_api_client.get_physical_objects_scenario( - scenario_id, token=token, physical_object_function_id=ROADS_ID - ) - if roads_gdf.crs != target_crs: - roads_gdf = roads_gdf.to_crs(target_crs) - - minx, miny, maxx, maxy = bounds - roads_gdf = roads_gdf.cx[minx:maxx, miny:maxy] - - def _clip(): - try: - return roads_gdf - except Exception: - return roads_gdf - return await self._to_executor(_clip) \ No newline at end of file +def get_accessibility_matrix(blocks: gpd.GeoDataFrame) -> pd.DataFrame: + crs = blocks.estimate_utm_crs() + dist_mx = calculate_distance_matrix(blocks.to_crs(crs)) + return dist_mx // SPEED diff --git a/app/dependencies.py b/app/dependencies.py index 1a96d2f..47e646d 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -31,4 +31,6 @@ scenario_service = ScenarioService(urban_api_client) effects_utils = EffectsUtils(urban_api_client) context_service = ContextService(urban_api_client, file_cache) -effects_service = EffectsService(urban_api_client, file_cache, scenario_service, context_service, effects_utils) +effects_service = EffectsService( + urban_api_client, file_cache, scenario_service, context_service, effects_utils +) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index 257a97e..1e5d28c 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -1,6 +1,15 @@ -from blocksnet.analysis.indicators.socio_economic import GeneralIndicator, DemographicIndicator, TransportIndicator, \ - EngineeringIndicator, SocialCountIndicator, SocialProvisionIndicator, SocialIndicator, EconomicIndicator, \ - EcologicalIndicator, SettlementIndicator +from blocksnet.analysis.indicators.socio_economic import ( + DemographicIndicator, + EcologicalIndicator, + EconomicIndicator, + EngineeringIndicator, + GeneralIndicator, + SettlementIndicator, + SocialCountIndicator, + SocialIndicator, + SocialProvisionIndicator, + TransportIndicator, +) from blocksnet.enums import LandUse # UrbanDB to Blocksnet land use types mapping @@ -21,96 +30,96 @@ # TODO add map autogeneration SERVICE_TYPES_MAPPING = { - 1: 'park', - 5 : 'beach', - 21: 'kindergarten', - 22: 'school', - 23 : None, # доп образование - 24 : None, # доп образование - 26 : 'college', - 27 : 'university', - 28 : 'polyclinic', - 29 : None, # детская поликлиника - 30 : None, # стоматология - 31 : None, # фельдшерско-акушерский пункт - 32 : None, # женская консультация - 34 : 'pharmacy', - 35 : 'hospital', - 36 : None, # роддом - 37 : None, # детская больница - 38 : None, # хоспис - 39 : None, # скорая помощь - 40 : None, # травматология - 41 : None, # морг - 42 : None, # диспансер - 43 : None, # центры соц обслуживания - 44 : 'social_facility', # дом престарелых - 45 : 'recruitment', - 46 : None, # детский дом - 47 : 'multifunctional_center', - 48 : 'library', - 49 : None, # дворцы культуры - 50 : 'museum', - 51 : 'theatre', - 53 : None, # концертный зал - 55 : 'zoo', - 56 : 'cinema', - 57 : 'mall', - 59 : 'stadium', - 60 : None, # ледовая арена - 61 : 'cafe', - 62 : 'restaurant', - 63 : 'bar', - 64 : 'cafe', - 65 : 'bakery', - 66 : 'pitch', - 67 : 'swimming_pool', - 68 : None, # спортивный зал - 69 : None, # каток - 70 : None, # футбольное поле - 72 : None, # эко тропа - 74 : 'playground', - 75 : None, # парк аттракционов - 77 : None, # скейт парк - 78 : 'police', - 79 : None, # пожарная станция - 80 : 'train_station', - 81 : 'train_building', - 82 : 'aeroway_terminal', - 84 : 'fuel', - 86 : 'bus_station', - 88 : 'subway_entrance', - 89 : 'supermarket', - 91 : 'market', - 93 : None, # одежда и обувь - 94 : None, # бытовая техника - 95 : None, # книжный магазин - 96 : None, # детские товары - 97 : None, # спортивный магазин - 98 : 'post', - 99 : None, # пункт выдачи - 100 : 'bank', - 102 : 'lawyer', - 103 : 'notary', - 107 : 'veterinary', - 108 : None, # зоомагазин - 109 : 'dog_park', - 110 : 'hotel', - 111 : 'hostel', - 112 : None, # база отдыха - 113 : None, # памятник - 114 : 'religion', # религиозный объект + 1: "park", + 5: "beach", + 21: "kindergarten", + 22: "school", + 23: None, # доп образование + 24: None, # доп образование + 26: "college", + 27: "university", + 28: "polyclinic", + 29: None, # детская поликлиника + 30: None, # стоматология + 31: None, # фельдшерско-акушерский пункт + 32: None, # женская консультация + 34: "pharmacy", + 35: "hospital", + 36: None, # роддом + 37: None, # детская больница + 38: None, # хоспис + 39: None, # скорая помощь + 40: None, # травматология + 41: None, # морг + 42: None, # диспансер + 43: None, # центры соц обслуживания + 44: "social_facility", # дом престарелых + 45: "recruitment", + 46: None, # детский дом + 47: "multifunctional_center", + 48: "library", + 49: None, # дворцы культуры + 50: "museum", + 51: "theatre", + 53: None, # концертный зал + 55: "zoo", + 56: "cinema", + 57: "mall", + 59: "stadium", + 60: None, # ледовая арена + 61: "cafe", + 62: "restaurant", + 63: "bar", + 64: "cafe", + 65: "bakery", + 66: "pitch", + 67: "swimming_pool", + 68: None, # спортивный зал + 69: None, # каток + 70: None, # футбольное поле + 72: None, # эко тропа + 74: "playground", + 75: None, # парк аттракционов + 77: None, # скейт парк + 78: "police", + 79: None, # пожарная станция + 80: "train_station", + 81: "train_building", + 82: "aeroway_terminal", + 84: "fuel", + 86: "bus_station", + 88: "subway_entrance", + 89: "supermarket", + 91: "market", + 93: None, # одежда и обувь + 94: None, # бытовая техника + 95: None, # книжный магазин + 96: None, # детские товары + 97: None, # спортивный магазин + 98: "post", + 99: None, # пункт выдачи + 100: "bank", + 102: "lawyer", + 103: "notary", + 107: "veterinary", + 108: None, # зоомагазин + 109: "dog_park", + 110: "hotel", + 111: "hostel", + 112: None, # база отдыха + 113: None, # памятник + 114: "religion", # религиозный объект # электростанции -- start - 118 : 'substation', # Атомная электростанция - 119 : 'substation', # Гидро-электростанция - 120 : 'substation', # Тепловая электростанция + 118: "substation", # Атомная электростанция + 119: "substation", # Гидро-электростанция + 120: "substation", # Тепловая электростанция # электростанции -- end - 124 : 'water_works', + 124: "water_works", # водоочистные сооружения -- start - 126 : 'wastewater_plant', # Сооружения для очистки воды - 128 : 'wastewater_plant', # Водоочистные сооружения + 126: "wastewater_plant", # Сооружения для очистки воды + 128: "wastewater_plant", # Водоочистные сооружения # водоочистные сооружения -- end - 143 : 'sanatorium', + 143: "sanatorium", } # Rules for agregating building properties from UrbanDB API @@ -163,10 +172,10 @@ # ID of water objects physical_object_function_id WATER_ID = 4 -#Maximum number of function evaluations +# Maximum number of function evaluations MAX_EVALS = 1000 -#Maximum number of runs for optimization +# Maximum number of runs for optimization MAX_RUNS = 1000 PRED_VALUE_RU = { @@ -199,8 +208,8 @@ INDICATORS_MAPPING = { # общие - GeneralIndicator.AREA : 4, - GeneralIndicator.URBANIZATION : 16, + GeneralIndicator.AREA: 4, + GeneralIndicator.URBANIZATION: 16, # демография DemographicIndicator.POPULATION: 1, DemographicIndicator.DENSITY: 37, @@ -213,7 +222,7 @@ TransportIndicator.RAILWAY_STOPS_COUNT: 75, TransportIndicator.AVERAGE_RAILWAY_STOP_ACCESSIBILITY: 76, TransportIndicator.AIRPORTS_COUNT: 78, - TransportIndicator.AVERAGE_AIRPORT_ACCESSIBILITY: None, # TODO Средняя доступность аэропортов (без разделения на международные и местные) + TransportIndicator.AVERAGE_AIRPORT_ACCESSIBILITY: None, # TODO Средняя доступность аэропортов (без разделения на международные и местные) # инженерная инфраструктура EngineeringIndicator.INFRASTRUCTURE_OBJECT: 88, EngineeringIndicator.SUBSTATION: 89, @@ -224,83 +233,83 @@ # социальная инфраструктура # образование SocialCountIndicator.KINDERGARTEN: 309, - SocialProvisionIndicator.KINDERGARTEN: 207, # Обеспеченность детскими садами + SocialProvisionIndicator.KINDERGARTEN: 207, # Обеспеченность детскими садами SocialCountIndicator.SCHOOL: 338, - SocialProvisionIndicator.SCHOOL: 208, # Обеспеченность школами + SocialProvisionIndicator.SCHOOL: 208, # Обеспеченность школами SocialCountIndicator.COLLEGE: 310, - SocialProvisionIndicator.COLLEGE: None, # FIXME Обеспеченность образовательными учреждениями СПО (нет их) + SocialProvisionIndicator.COLLEGE: None, # FIXME Обеспеченность образовательными учреждениями СПО (нет их) SocialCountIndicator.UNIVERSITY: 311, SocialProvisionIndicator.UNIVERSITY: 350, - SocialCountIndicator.EXTRACURRICULAR: None, # FIXME Организации дополнительного образования детей (нет их) - SocialProvisionIndicator.EXTRACURRICULAR: None, # FIXME Обеспеченность организациями дополнительного образования детей (нет их) + SocialCountIndicator.EXTRACURRICULAR: None, # FIXME Организации дополнительного образования детей (нет их) + SocialProvisionIndicator.EXTRACURRICULAR: None, # FIXME Обеспеченность организациями дополнительного образования детей (нет их) # здравоохранение SocialCountIndicator.HOSPITAL: 341, - SocialProvisionIndicator.HOSPITAL: 361, # Обеспеченность больницами + SocialProvisionIndicator.HOSPITAL: 361, # Обеспеченность больницами SocialCountIndicator.POLYCLINIC: 342, - SocialProvisionIndicator.POLYCLINIC: 362, # Обеспеченность поликлиниками + SocialProvisionIndicator.POLYCLINIC: 362, # Обеспеченность поликлиниками SocialCountIndicator.AMBULANCE: 343, - SocialProvisionIndicator.AMBULANCE: None, # FIXME Обеспеченность объектами скорой медицинской помощи + SocialProvisionIndicator.AMBULANCE: None, # FIXME Обеспеченность объектами скорой медицинской помощи SocialCountIndicator.SANATORIUM: 312, - SocialProvisionIndicator.SANATORIUM: None, # FIXME Обеспеченность объектами санаторного назначения - SocialCountIndicator.SPECIAL_MEDICAL: None, # FIXME Медицинские учреждения особого типа - SocialProvisionIndicator.SPECIAL_MEDICAL: None, # FIXME Обеспеченность медицинскими учреждениями особого типа + SocialProvisionIndicator.SANATORIUM: None, # FIXME Обеспеченность объектами санаторного назначения + SocialCountIndicator.SPECIAL_MEDICAL: None, # FIXME Медицинские учреждения особого типа + SocialProvisionIndicator.SPECIAL_MEDICAL: None, # FIXME Обеспеченность медицинскими учреждениями особого типа SocialCountIndicator.PREVENTIVE_MEDICAL: 346, - SocialProvisionIndicator.PREVENTIVE_MEDICAL: None, # FIXME Обеспеченность лечебно-профилактическими медицинскими учреждениями + SocialProvisionIndicator.PREVENTIVE_MEDICAL: None, # FIXME Обеспеченность лечебно-профилактическими медицинскими учреждениями SocialCountIndicator.PHARMACY: 345, - SocialProvisionIndicator.PHARMACY: 213, # Обеспеченность аптеками + SocialProvisionIndicator.PHARMACY: 213, # Обеспеченность аптеками # спорт SocialCountIndicator.GYM: 313, - SocialProvisionIndicator.GYM: 243, # Обеспеченность спортзалами ОП / фитнес-центрами + SocialProvisionIndicator.GYM: 243, # Обеспеченность спортзалами ОП / фитнес-центрами SocialCountIndicator.SWIMMING_POOL: 314, - SocialProvisionIndicator.SWIMMING_POOL: 245, # Обеспеченность ФОК / бассейнами + SocialProvisionIndicator.SWIMMING_POOL: 245, # Обеспеченность ФОК / бассейнами SocialCountIndicator.PITCH: 340, SocialProvisionIndicator.PITCH: 357, SocialCountIndicator.STADIUM: 315, SocialProvisionIndicator.STADIUM: 356, # социальная помощь SocialCountIndicator.ORPHANAGE: 316, - SocialProvisionIndicator.ORPHANAGE: None, # FIXME Обеспеченность детскими домами-интернатами + SocialProvisionIndicator.ORPHANAGE: None, # FIXME Обеспеченность детскими домами-интернатами SocialCountIndicator.SOCIAL_FACILITY: 317, - SocialProvisionIndicator.SOCIAL_FACILITY: None, # FIXME Обеспеченность домами престарелых + SocialProvisionIndicator.SOCIAL_FACILITY: None, # FIXME Обеспеченность домами престарелых SocialCountIndicator.SOCIAL_SERVICE_CENTER: 318, - SocialProvisionIndicator.SOCIAL_SERVICE_CENTER: None, # FIXME Обеспеченность центрами социального обслуживания + SocialProvisionIndicator.SOCIAL_SERVICE_CENTER: None, # FIXME Обеспеченность центрами социального обслуживания # услуги SocialCountIndicator.POST: 319, - SocialProvisionIndicator.POST: 247, # Обеспеченность пунктами доставки / почтовыми отделениями + SocialProvisionIndicator.POST: 247, # Обеспеченность пунктами доставки / почтовыми отделениями SocialCountIndicator.BANK: 320, - SocialProvisionIndicator.BANK: 250, # Обеспеченность отделениями банков + SocialProvisionIndicator.BANK: 250, # Обеспеченность отделениями банков SocialCountIndicator.MULTIFUNCTIONAL_CENTER: 321, SocialProvisionIndicator.MULTIFUNCTIONAL_CENTER: 351, # культура и отдых - SocialCountIndicator.LIBRARY : 322, - SocialProvisionIndicator.LIBRARY : 232, # Обеспеченность медиатеками / библиотеками - SocialCountIndicator.MUSEUM : 323, - SocialProvisionIndicator.MUSEUM : 352, - SocialCountIndicator.THEATRE : 324, - SocialProvisionIndicator.THEATRE : 353, - SocialCountIndicator.CULTURAL_CENTER : 325, - SocialProvisionIndicator.CULTURAL_CENTER : 231, # Обеспеченность комьюнити-центрами / домами культуры - SocialCountIndicator.CINEMA : 326, - SocialProvisionIndicator.CINEMA : 354, - SocialCountIndicator.CONCERT_HALL : 327, - SocialProvisionIndicator.CONCERT_HALL : None, # FIXME Обеспеченность концертными залами + SocialCountIndicator.LIBRARY: 322, + SocialProvisionIndicator.LIBRARY: 232, # Обеспеченность медиатеками / библиотеками + SocialCountIndicator.MUSEUM: 323, + SocialProvisionIndicator.MUSEUM: 352, + SocialCountIndicator.THEATRE: 324, + SocialProvisionIndicator.THEATRE: 353, + SocialCountIndicator.CULTURAL_CENTER: 325, + SocialProvisionIndicator.CULTURAL_CENTER: 231, # Обеспеченность комьюнити-центрами / домами культуры + SocialCountIndicator.CINEMA: 326, + SocialProvisionIndicator.CINEMA: 354, + SocialCountIndicator.CONCERT_HALL: 327, + SocialProvisionIndicator.CONCERT_HALL: None, # FIXME Обеспеченность концертными залами # SocialCountIndicator.STADIUM : 315, ПОВТОР # SocialProvisionIndicator.STADIUM : 356, ПОВТОР - SocialCountIndicator.ICE_ARENA : 328, - SocialProvisionIndicator.ICE_ARENA : None, # FIXME Обеспеченность ледовыми аренами - SocialCountIndicator.MALL : 329, - SocialProvisionIndicator.MALL : 355, - SocialCountIndicator.PARK : 330, - SocialProvisionIndicator.PARK : 238, # Обеспеченность парками - SocialCountIndicator.BEACH : 331, - SocialProvisionIndicator.BEACH : None, # FIXME Обеспеченность пляжами - SocialCountIndicator.ECO_TRAIL : 332, - SocialProvisionIndicator.ECO_TRAIL : None, # FIXME Обеспеченность экологическими тропами + SocialCountIndicator.ICE_ARENA: 328, + SocialProvisionIndicator.ICE_ARENA: None, # FIXME Обеспеченность ледовыми аренами + SocialCountIndicator.MALL: 329, + SocialProvisionIndicator.MALL: 355, + SocialCountIndicator.PARK: 330, + SocialProvisionIndicator.PARK: 238, # Обеспеченность парками + SocialCountIndicator.BEACH: 331, + SocialProvisionIndicator.BEACH: None, # FIXME Обеспеченность пляжами + SocialCountIndicator.ECO_TRAIL: 332, + SocialProvisionIndicator.ECO_TRAIL: None, # FIXME Обеспеченность экологическими тропами # безопасность SocialCountIndicator.FIRE_STATION: 333, - SocialProvisionIndicator.FIRE_STATION: 260, # Обеспеченность пожарными депо + SocialProvisionIndicator.FIRE_STATION: 260, # Обеспеченность пожарными депо SocialCountIndicator.POLICE: 334, - SocialProvisionIndicator.POLICE: 258, # Обеспеченность пунктами полиции + SocialProvisionIndicator.POLICE: 258, # Обеспеченность пунктами полиции # туризм SocialCountIndicator.HOTEL: 335, SocialProvisionIndicator.HOTEL: 358, @@ -309,7 +318,7 @@ SocialCountIndicator.TOURIST_BASE: 337, SocialProvisionIndicator.TOURIST_BASE: 360, SocialCountIndicator.CATERING: 344, - SocialProvisionIndicator.CATERING: 226 # Обеспеченность кафе / кофейнями + SocialProvisionIndicator.CATERING: 226, # Обеспеченность кафе / кофейнями } -SPEED = 5 * 1_000 / 60 \ No newline at end of file +SPEED = 5 * 1_000 / 60 diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index f372b67..1a68c5a 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -5,22 +5,30 @@ import numpy as np import pandas as pd from blocksnet.analysis.indicators import calculate_development_indicators -from blocksnet.analysis.indicators.socio_economic import calculate_general_indicators, calculate_demographic_indicators, \ - calculate_transport_indicators, calculate_engineering_indicators, calculate_social_indicators +from blocksnet.analysis.indicators.socio_economic import ( + calculate_demographic_indicators, + calculate_engineering_indicators, + calculate_general_indicators, + calculate_social_indicators, + calculate_transport_indicators, +) from blocksnet.analysis.land_use.prediction import SpatialClassifier from blocksnet.analysis.provision import competitive_provision, provision_strong_total from blocksnet.blocks.assignment import assign_objects from blocksnet.config import service_types_config from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import SocialRegressor, DensityRegressor +from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor from blocksnet.optimization.services import ( + AreaSolution, + Facade, + GradientChooser, TPEOptimizer, WeightedConstraints, - WeightedObjective, GradientChooser, Facade, AreaSolution, + WeightedObjective, ) from blocksnet.relations import ( - calculate_distance_matrix, - generate_adjacency_graph, + calculate_distance_matrix, + generate_adjacency_graph, ) from loguru import logger @@ -28,20 +36,31 @@ from app.effects_api.modules.service_type_service import ( adapt_service_types, build_en_to_ru_map, - generate_blocksnet_columns, ensure_missing_id_and_name_columns, + ensure_missing_id_and_name_columns, + generate_blocksnet_columns, ) -from .modules.context_service import ContextService + from ..clients.urban_api_client import UrbanAPIClient from ..common.caching.caching_service import FileCache from ..common.exceptions.http_exception_wrapper import http_exception -from ..common.utils.geodata import fc_to_gdf, gdf_to_ru_fc_rounded, is_fc, round_coords, _ensure_block_index, \ - get_accessibility_matrix +from ..common.utils.effects_utils import EffectsUtils +from ..common.utils.geodata import ( + _ensure_block_index, + fc_to_gdf, + gdf_to_ru_fc_rounded, + get_accessibility_matrix, + is_fc, + round_coords, +) from .constants.const import ( + INDICATORS_MAPPING, INFRASTRUCTURES_WEIGHTS, MAX_EVALS, MAX_RUNS, PRED_VALUE_RU, - PROB_COLS_EN_TO_RU, ROADS_ID, INDICATORS_MAPPING, ) + PROB_COLS_EN_TO_RU, + ROADS_ID, +) from .dto.development_dto import ( ContextDevelopmentDTO, DevelopmentDTO, @@ -50,7 +69,7 @@ SocioEconomicByProjectDTO, ) from .dto.transformation_effects_dto import TerritoryTransformationDTO -from ..common.utils.effects_utils import EffectsUtils +from .modules.context_service import ContextService class EffectsService: @@ -60,7 +79,7 @@ def __init__( cache: FileCache, scenario_service: ScenarioService, context_service: ContextService, - effects_utils: EffectsUtils + effects_utils: EffectsUtils, ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() @@ -174,8 +193,12 @@ async def calculate_provision_totals( total = float(provision_strong_total(prov_gdf)) except Exception as e: logger.exception("Provision total calculation failed") - raise http_exception(500, "Provision total calculation failed", - _input={"service_type": st_name}, _detail=str(e)) + raise http_exception( + 500, + "Provision total calculation failed", + _input={"service_type": st_name}, + _detail=str(e), + ) prov_totals[st_name] = round(total, ndigits) return prov_totals @@ -285,11 +308,9 @@ async def territory_transformation_scenario_before( return prov_gdfs_before - - @staticmethod async def run_development_parameters( - blocks_gdf: gpd.GeoDataFrame, + blocks_gdf: gpd.GeoDataFrame, ) -> pd.DataFrame: """ Compute core *development* indicators (FSI, GSI, MXI, etc.) for each @@ -319,7 +340,9 @@ async def run_development_parameters( adjacency_graph = generate_adjacency_graph(blocks_gdf, 10) except Exception as e: logger.exception("Adjacency graph generation failed") - raise http_exception(500, "Adjacency graph generation failed", _detail=str(e)) + raise http_exception( + 500, "Adjacency graph generation failed", _detail=str(e) + ) dr = DensityRegressor() @@ -344,7 +367,9 @@ async def run_development_parameters( development_df = calculate_development_indicators(density_df) except Exception as e: logger.exception("Development indicator calculation failed") - raise http_exception(500, "Development indicator calculation failed", _detail=str(e)) + raise http_exception( + 500, "Development indicator calculation failed", _detail=str(e) + ) development_df["population"] = development_df["living_area"] // 20 @@ -399,7 +424,9 @@ async def territory_transformation_scenario_after( is_based = info["is_based"] if is_based: - logger.exception("Base scenario has no 'after' layer needed for calculation") + logger.exception( + "Base scenario has no 'after' layer needed for calculation" + ) raise http_exception( 400, "Base scenario has no 'after' layer needed for calculation" ) @@ -453,7 +480,9 @@ async def territory_transformation_scenario_after( acc_mx = get_accessibility_matrix(after_blocks) except Exception as e: logger.exception("Accessibility matrix calculation failed") - raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(e) + ) service_types["infrastructure_weight"] = ( service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) @@ -492,10 +521,14 @@ async def territory_transformation_scenario_after( ) try: - best_x, best_val, perc, func_evals = tpe_optimizer.run(max_runs=MAX_RUNS, timeout=10, initial_runs_num=1) + best_x, best_val, perc, func_evals = tpe_optimizer.run( + max_runs=MAX_RUNS, timeout=10, initial_runs_num=1 + ) except Exception as e: logger.exception("Optimization (TPE) failed") - raise http_exception(500, "Service placement optimization failed", _detail=str(e)) + raise http_exception( + 500, "Service placement optimization failed", _detail=str(e) + ) prov_gdfs_after = {} for st_id in service_types.index: @@ -683,7 +716,9 @@ async def values_transformation( acc_mx = get_accessibility_matrix(after_blocks) except Exception as e: logger.exception("Accessibility matrix calculation failed") - raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(e) + ) service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) @@ -771,7 +806,9 @@ def _fill_cell(x): try: logger.info("Running land-use prediction on 'after_blocks'") - ab = after_blocks[after_blocks.geometry.notna() & ~after_blocks.geometry.is_empty].copy() + ab = after_blocks[ + after_blocks.geometry.notna() & ~after_blocks.geometry.is_empty + ].copy() ab.geometry = ab.geometry.buffer(0) try: @@ -795,7 +832,9 @@ def _fill_cell(x): gdf_out = _ensure_block_index(gdf_out) gdf_out = gdf_out.join(lu, how="left") - logger.info("Attached land-use predictions to gdf_out (cols: {})", keep_cols) + logger.info( + "Attached land-use predictions to gdf_out (cols: {})", keep_cols + ) if "pred_name" in gdf_out.columns: gdf_out["Предсказанный вид использования"] = ( @@ -806,11 +845,17 @@ def _fill_cell(x): ) gdf_out = gdf_out.drop(columns=["pred_name"]) - prob_cols = [c for c in ["prob_urban", "prob_non_urban", "prob_industrial"] if c in gdf_out.columns] + prob_cols = [ + c + for c in ["prob_urban", "prob_non_urban", "prob_industrial"] + if c in gdf_out.columns + ] for col in prob_cols: gdf_out[col] = gdf_out[col].astype(float).round(1) - rename_map = {k: v for k, v in PROB_COLS_EN_TO_RU.items() if k in gdf_out.columns} + rename_map = { + k: v for k, v in PROB_COLS_EN_TO_RU.items() if k in gdf_out.columns + } gdf_out = gdf_out.rename(columns=rename_map) except Exception as e: @@ -829,7 +874,11 @@ def _fill_cell(x): geom_col = gdf_out.geometry.name non_geom = [c for c in gdf_out.columns if c != geom_col] - pin_first = [c for c in ["is_project", "Предсказанный вид использования"] if c in non_geom] + pin_first = [ + c + for c in ["is_project", "Предсказанный вид использования"] + if c in non_geom + ] rest = [c for c in non_geom if c not in pin_first] rest_sorted = sorted(rest, key=lambda s: s.casefold()) @@ -868,7 +917,8 @@ async def values_oriented_requirements( base_id = await self.effects_utils.resolve_base_id(token, params.scenario_id) logger.info( - f"Using base scenario_id={base_id} (requested={params.scenario_id})") + f"Using base scenario_id={base_id} (requested={params.scenario_id})" + ) params_base = params.model_copy( update={ @@ -945,7 +995,9 @@ def _result_to_df(payload: Any) -> pd.DataFrame: acc_mx = get_accessibility_matrix(blocks) except Exception as e: logger.exception("Accessibility matrix calculation failed") - raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(e) + ) prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} for st_id in service_types.index: @@ -1048,29 +1100,43 @@ def _result_to_df(payload: Any) -> pd.DataFrame: return result_df def _clean_number(self, v): + """ + Normalize numeric-like values to built-in Python types. + + Converts numpy numeric types (e.g. np.int64, np.float32) to plain `int` or `float`, + safely handling `None`, `NaN`, and infinite values. + + Returns: + int | float | Any | None: + - int or float for finite numeric inputs + - None for NaN, None, or ±inf + - unchanged value for non-numeric inputs + """ if v is None or (isinstance(v, float) and np.isnan(v)): return None try: - if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite(float(v)): + if isinstance(v, (np.floating, float, np.integer, int)) and not np.isfinite( + float(v) + ): return None except Exception: pass - if isinstance(v, (np.integer,)): + if isinstance(v, np.integer): return int(v) - if isinstance(v, (np.floating,)): + if isinstance(v, np.floating): return float(v) return v async def _compute_for_single_scenario( - self, - scenario_id: int, - context_blocks: gpd.GeoDataFrame, - context_territories_gdf: gpd.GeoDataFrame, - service_types_df: pd.DataFrame, - proj_src: str, - proj_year: int, - token: str, - only_parent_ids: set[int] | None = None, + self, + scenario_id: int, + context_blocks: gpd.GeoDataFrame, + context_territories_gdf: gpd.GeoDataFrame, + service_types_df: pd.DataFrame, + proj_src: str, + proj_year: int, + token: str, + only_parent_ids: set[int] | None = None, ) -> list[dict]: """ Compute indicators for ONE scenario with shared context. @@ -1083,22 +1149,32 @@ async def _compute_for_single_scenario( ) before_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=True) - svc_cols = [c for c in before_blocks.columns if c.startswith(("count_", "capacity_"))] + svc_cols = [ + c for c in before_blocks.columns if c.startswith(("count_", "capacity_")) + ] if svc_cols: before_blocks[svc_cols] = ( - before_blocks[svc_cols].apply(pd.to_numeric, errors="coerce").fillna(0).astype("int64") + before_blocks[svc_cols] + .apply(pd.to_numeric, errors="coerce") + .fillna(0) + .astype("int64") ) context_territories_gdf = context_territories_gdf.to_crs(before_blocks.crs) try: - assigned = assign_objects(before_blocks, context_territories_gdf.rename(columns={"parent": "name"})) + assigned = assign_objects( + before_blocks, + context_territories_gdf.rename(columns={"parent": "name"}), + ) except Exception as e: logger.exception("Error assigning objects") raise http_exception(500, "Error assigning objects", _detail=str(e)) before_blocks["parent"] = assigned["name"].astype(int) if only_parent_ids: - before_blocks = before_blocks[before_blocks["parent"].isin(only_parent_ids)].copy() + before_blocks = before_blocks[ + before_blocks["parent"].isin(only_parent_ids) + ].copy() before_blocks = generate_blocksnet_columns(before_blocks, service_types_df) before_blocks = ensure_missing_id_and_name_columns(before_blocks) @@ -1119,19 +1195,23 @@ async def _compute_for_single_scenario( acc_mx = get_accessibility_matrix(before_blocks) except Exception as e: logger.exception("Accessibility matrix calculation failed") - raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(e)) + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(e) + ) dist_mx = calculate_distance_matrix(before_blocks) st_for_social = service_types_df[ service_types_df["infrastructure_type"].notna() & service_types_df["blocksnet"].notna() - ].copy() + ].copy() general = calculate_general_indicators(before_blocks) demo = calculate_demographic_indicators(before_blocks) transp = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) eng = calculate_engineering_indicators(before_blocks) - sc, sp = calculate_social_indicators(before_blocks, acc_mx, dist_mx, st_for_social) + sc, sp = calculate_social_indicators( + before_blocks, acc_mx, dist_mx, st_for_social + ) indicators_df = pd.concat([general, demo, transp, eng, sc, sp]) @@ -1143,18 +1223,22 @@ async def _compute_for_single_scenario( long_df = long_df[long_df["territory_id"] != "total"].copy() long_df["indicator_id"] = long_df["indicator"].map(INDICATORS_MAPPING) - - long_df["territory_id"] = pd.to_numeric(long_df["territory_id"], errors="coerce").apply(self._clean_number) + long_df["territory_id"] = pd.to_numeric( + long_df["territory_id"], errors="coerce" + ).apply(self._clean_number) long_df["indicator_id"] = long_df["indicator_id"].apply(self._clean_number) long_df["value"] = long_df["value"].apply(self._clean_number) long_df["value"] = long_df["value"].round(2) - long_df = long_df[long_df["indicator_id"].notna() & long_df["territory_id"].notna()].fillna(0) + long_df = long_df[ + long_df["indicator_id"].notna() & long_df["territory_id"].notna() + ].fillna(0) - return long_df[["territory_id", "indicator_id", "value"]].to_dict(orient="records") + return long_df[["territory_id", "indicator_id", "value"]].to_dict( + orient="records" + ) def _pivot_results_by_territory( - self, - results: dict[int, list[dict]] + self, results: dict[int, list[dict]] ) -> dict[int, dict[int, dict[int, float]]]: """ Transform scenario-first results to territory-first pivot. @@ -1199,15 +1283,11 @@ def _pivot_results_by_territory( pivot[t_id][ind_id] = {} pivot[t_id][ind_id][int(scenario_id)] = val - logger.info( - f"[Effects] Pivoted to nested format: {len(pivot)} territories." - ) + logger.info(f"[Effects] Pivoted to nested format: {len(pivot)} territories.") return pivot async def evaluate_social_economical_metrics( - self, - token: str, - params: SocioEconomicByProjectDTO + self, token: str, params: SocioEconomicByProjectDTO ): """ Project-level multi-scenario calculation with a shared context. @@ -1231,18 +1311,23 @@ async def evaluate_social_economical_metrics( phash = self.cache.params_hash(params_for_hash) cached = self.cache.load(method_name, project_id, phash) if cached: - logger.info(f"[Effects] cache hit for project {project_id}, returning cached data") + logger.info( + f"[Effects] cache hit for project {project_id}, returning cached data" + ) return cached["results"] else: - logger.info(f"[Effects] force=True, recalculating metrics for project {project_id}") + logger.info( + f"[Effects] force=True, recalculating metrics for project {project_id}" + ) - context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context( - project_id, token + context_blocks, context_territories_gdf, service_types = ( + await self.context.get_shared_context(project_id, token) ) scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) target = [ - s for s in scenarios + s + for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id ] logger.info( @@ -1254,8 +1339,10 @@ async def evaluate_social_economical_metrics( for s in target: sid = int(s["scenario_id"]) - proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=None, year=None, project=True + proj_src, proj_year = ( + await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=None, year=None, project=True + ) ) records = await self._compute_for_single_scenario( @@ -1283,6 +1370,7 @@ async def evaluate_social_economical_metrics( scenario_updated_at=updated_at, ) - logger.success(f"[Effects] socio-economic metrics cached for project_id={project_id}") + logger.success( + f"[Effects] socio-economic metrics cached for project_id={project_id}" + ) return results - diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 4c3e9ee..17cb901 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -1,64 +1,75 @@ import asyncio from pathlib import Path -from typing import Tuple, Dict +from typing import Dict, Tuple import geopandas as gpd import pandas as pd -from blocksnet.relations import get_accessibility_context -from loguru import logger - from blocksnet.blocks.aggregation import aggregate_objects from blocksnet.blocks.assignment import assign_land_use from blocksnet.blocks.cutting import cut_urban_blocks, preprocess_urban_objects from blocksnet.preprocessing.imputing import impute_buildings, impute_services +from blocksnet.relations import get_accessibility_context +from loguru import logger from app.clients.urban_api_client import UrbanAPIClient from app.common.caching.caching_service import FileCache from app.common.utils.geodata import get_best_functional_zones_source from app.effects_api.constants.const import ( + LAND_USE_RULES, LIVING_BUILDINGS_ID, ROADS_ID, + SOCIAL_INDICATORS_MAPPING, WATER_ID, - LAND_USE_RULES, SOCIAL_INDICATORS_MAPPING, ) from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.scenario_service import close_gaps -from app.effects_api.modules.service_type_service import adapt_service_types, adapt_social_service_types_df +from app.effects_api.modules.service_type_service import ( + adapt_service_types, + adapt_social_service_types_df, +) from app.effects_api.modules.services_service import adapt_services - _SOURCES_PRIORITY = ["PZZ", "OSM", "User"] class ContextService: """Context layer orchestration (blocks, buildings, services, fzones).""" - def __init__(self, urban_api_client: UrbanAPIClient, cache: FileCache - ): + def __init__(self, urban_api_client: UrbanAPIClient, cache: FileCache): self.client = urban_api_client self.cache = cache - async def _get_project_boundaries(self, project_id: int, token: str) -> gpd.GeoDataFrame: + async def _get_project_boundaries( + self, project_id: int, token: str + ) -> gpd.GeoDataFrame: """Return project boundary polygon as GeoDataFrame (EPSG:4326).""" geom = await self.client.get_project_geometry(project_id, token=token) return gpd.GeoDataFrame(geometry=[geom], crs=4326) - async def _get_context_boundaries(self, project_id: int, token: str) -> gpd.GeoDataFrame: + async def _get_context_boundaries( + self, project_id: int, token: str + ) -> gpd.GeoDataFrame: """Return union of context territories as GeoDataFrame (EPSG:4326).""" project = await self.client.get_project(project_id, token) context_ids = project["properties"]["context"] - geometries = [await self.client.get_territory_geometry(tid) for tid in context_ids] + geometries = [ + await self.client.get_territory_geometry(tid) for tid in context_ids + ] return gpd.GeoDataFrame(geometry=geometries, crs=4326) - async def _get_context_roads(self, scenario_id: int, token: str) -> gpd.GeoDataFrame: + async def _get_context_roads( + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: """Return roads geometry for context cut (only geometry column).""" gdf = await self.client.get_physical_objects( scenario_id, token, physical_object_function_id=ROADS_ID ) return gdf[["geometry"]].reset_index(drop=True) - async def _get_context_water(self, scenario_id: int, token: str) -> gpd.GeoDataFrame: + async def _get_context_water( + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame: """Return water geometry for context cut (only geometry column).""" gdf = await self.client.get_physical_objects( scenario_id, token=token, physical_object_function_id=WATER_ID @@ -103,7 +114,9 @@ async def get_context_blocks( context_boundaries = context_boundaries.to_crs(crs) project_boundaries = project_boundaries.to_crs(crs) - context_boundaries = context_boundaries.overlay(project_boundaries, how="difference") + context_boundaries = context_boundaries.overlay( + project_boundaries, how="difference" + ) return await self._get_context_blocks(scenario_id, context_boundaries, token) async def get_context_functional_zones( @@ -118,13 +131,17 @@ async def get_context_functional_zones( """ sources_df = await self.client.get_functional_zones_sources(scenario_id, token) year, source = await get_best_functional_zones_source(sources_df, source, year) - functional_zones = await self.client.get_functional_zones(scenario_id, year, source, token) + functional_zones = await self.client.get_functional_zones( + scenario_id, year, source, token + ) functional_zones = functional_zones.loc[ functional_zones.geometry.geom_type.isin({"Polygon", "MultiPolygon"}) ].reset_index(drop=True) return adapt_functional_zones(functional_zones) - async def get_context_buildings(self, scenario_id: int, token: str) -> gpd.GeoDataFrame | None: + async def get_context_buildings( + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame | None: """ Fetch, adapt and impute living buildings for context. Returns EPSG:4326 GeoDataFrame or None if not found. @@ -153,19 +170,26 @@ async def get_context_services( gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(gdf, st) for st, gdf in gdfs.items()} - async def get_context_territories(self, project_id: int, token: str) -> gpd.GeoDataFrame: + async def get_context_territories( + self, project_id: int, token: str + ) -> gpd.GeoDataFrame: """ Return context territories as polygons with column 'parent' = territory_id (EPSG:4326). """ project = await self.client.get_all_project_info(project_id, token) context_ids = project["properties"]["context"] data = [ - {"parent": territory_id, "geometry": await self.client.get_territory_geometry(territory_id)} + { + "parent": territory_id, + "geometry": await self.client.get_territory_geometry(territory_id), + } for territory_id in context_ids ] return gpd.GeoDataFrame(data=data, crs=4326) - async def load_context_blocks(self, scenario_id: int, token: str) -> Tuple[gpd.GeoDataFrame, int]: + async def load_context_blocks( + self, scenario_id: int, token: str + ) -> Tuple[gpd.GeoDataFrame, int]: """ Load raw context blocks and compute site_area. """ @@ -185,7 +209,9 @@ async def assign_land_use_context( """ Assign land use to blocks via functional zones and LAND_USE_RULES. """ - fzones = await self.get_context_functional_zones(scenario_id, source, year, token) + fzones = await self.get_context_functional_zones( + scenario_id, source, year, token + ) fzones = fzones.to_crs(blocks.crs) lu = assign_land_use(blocks, fzones, LAND_USE_RULES) return blocks.join(lu.drop(columns=["geometry"])) @@ -225,13 +251,17 @@ async def enrich_with_context_services( sdict = await self.get_context_services(scenario_id, stypes, token) if not sdict: - logger.info(f"No context services to aggregate for scenario_id={scenario_id}") + logger.info( + f"No context services to aggregate for scenario_id={scenario_id}" + ) return blocks for stype, services in sdict.items(): services = services.to_crs(blocks.crs) b_srv, _ = aggregate_objects(blocks, services) - b_srv[["capacity", "count"]] = b_srv[["capacity", "count"]].fillna(0).astype(int) + b_srv[["capacity", "count"]] = ( + b_srv[["capacity", "count"]].fillna(0).astype(int) + ) blocks = blocks.join( b_srv.drop(columns=["geometry"]).rename( @@ -258,10 +288,14 @@ async def aggregate_blocks_layer_context( blocks, _project_id = await self.load_context_blocks(scenario_id, token) logger.info("Assigning land-use for context") - blocks = await self.assign_land_use_context(blocks, scenario_id, source, year, token) + blocks = await self.assign_land_use_context( + blocks, scenario_id, source, year, token + ) logger.info("Aggregating buildings for context") - blocks, buildings = await self.enrich_with_context_buildings(blocks, scenario_id, token) + blocks, buildings = await self.enrich_with_context_buildings( + blocks, scenario_id, token + ) logger.info("Aggregating services for context") blocks = await self.enrich_with_context_services(blocks, scenario_id, token) @@ -269,9 +303,8 @@ async def aggregate_blocks_layer_context( logger.success(f"[Context {scenario_id}] blocks layer ready", scenario_id) return blocks, buildings - async def get_accessibility_context( - self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float + self, blocks: pd.DataFrame, acc_mx: pd.DataFrame, accessibility: float ) -> list[int]: blocks["population"] = blocks["population"].fillna(0) project_blocks = blocks.copy() @@ -281,9 +314,9 @@ async def get_accessibility_context( return list(context_blocks.index) async def get_shared_context( - self, - project_id: int, - token: str, + self, + project_id: int, + token: str, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, pd.DataFrame]: """ Get cached context (blocks, territories, service_types) by project_id, @@ -298,46 +331,76 @@ async def get_shared_context( logger.info(f"Shared context cache hit for project_id={project_id}") data = cached["data"] ctx_blocks = self.cache.load_gdf_artifact(Path(data["context_blocks_path"])) - ctx_territories = self.cache.load_gdf_artifact(Path(data["context_territories_path"])) - service_types = self.cache.load_df_artifact(Path(data["service_types_path"])) + ctx_territories = self.cache.load_gdf_artifact( + Path(data["context_territories_path"]) + ) + service_types = self.cache.load_df_artifact( + Path(data["service_types_path"]) + ) return ctx_blocks, ctx_territories, service_types logger.info("Shared context cache miss for project_id={project_id} — building") - territory_id = (await self.client.get_all_project_info(project_id, token))["territory"]["id"] + territory_id = (await self.client.get_all_project_info(project_id, token))[ + "territory" + ]["id"] base_sid = await self.client.get_base_scenario_id(project_id) ctx_src, ctx_year = await self.client.get_optimal_func_zone_request_data( token=token, data_id=base_sid, source=None, year=None, project=False ) - normatives = (await self.client.get_territory_normatives(territory_id))[[ - "radius_availability_meters", - "time_availability_minutes", - "services_per_1000_normative", - "services_capacity_per_1000_normative", - ]].copy() + normatives = (await self.client.get_territory_normatives(territory_id))[ + [ + "radius_availability_meters", + "time_availability_minutes", + "services_per_1000_normative", + "services_capacity_per_1000_normative", + ] + ].copy() service_types = await self.client.get_service_types() service_types = await adapt_service_types(service_types, self.client) - service_types = service_types[service_types["infrastructure_type"].notna()].copy() - service_types = adapt_social_service_types_df(service_types, SOCIAL_INDICATORS_MAPPING).join(normatives) - - ctx_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) + service_types = service_types[ + service_types["infrastructure_type"].notna() + ].copy() + service_types = adapt_social_service_types_df( + service_types, SOCIAL_INDICATORS_MAPPING + ).join(normatives) + + ctx_blocks, _ = await self.aggregate_blocks_layer_context( + base_sid, ctx_src, ctx_year, token + ) ctx_territories = await self.get_context_territories(project_id, token) ctx_blocks_path = self.cache.save_gdf_artifact( - ctx_blocks, method=method, owner_id=project_id, params=params, name="context_blocks", fmt="pkl" + ctx_blocks, + method=method, + owner_id=project_id, + params=params, + name="context_blocks", + fmt="pkl", ) ctx_territories_path = self.cache.save_gdf_artifact( - ctx_territories, method=method, owner_id=project_id, params=params, name="context_territories", - fmt="parquet" + ctx_territories, + method=method, + owner_id=project_id, + params=params, + name="context_territories", + fmt="parquet", ) service_types_path = self.cache.save_df_artifact( - service_types, method=method, owner_id=project_id, params=params, name="service_types", fmt="parquet" + service_types, + method=method, + owner_id=project_id, + params=params, + name="service_types", + fmt="parquet", ) self.cache.save( - method, project_id, params, + method, + project_id, + params, { "context_blocks_path": str(ctx_blocks_path), "context_territories_path": str(ctx_territories_path), diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index b9ff2c3..f762e35 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -12,7 +12,12 @@ from app.clients.urban_api_client import UrbanAPIClient from app.common.exceptions.http_exception_wrapper import http_exception -from app.effects_api.constants.const import LIVING_BUILDINGS_ID, ROADS_ID, WATER_ID, LAND_USE_RULES +from app.effects_api.constants.const import ( + LAND_USE_RULES, + LIVING_BUILDINGS_ID, + ROADS_ID, + WATER_ID, +) from app.effects_api.modules.buildings_service import adapt_buildings from app.effects_api.modules.functional_sources_service import adapt_functional_zones from app.effects_api.modules.service_type_service import adapt_service_types @@ -175,17 +180,23 @@ async def get_scenario_services( features = res.get("features") or [] if not features: - logger.info(f"Scenario {scenario_id}: no services (features=[]) -> returning empty dict") + logger.info( + f"Scenario {scenario_id}: no services (features=[]) -> returning empty dict" + ) return {} - gdf = gpd.GeoDataFrame.from_features(features, crs="EPSG:4326").set_index("service_id", drop=False) + gdf = gpd.GeoDataFrame.from_features(features, crs="EPSG:4326").set_index( + "service_id", drop=False + ) gdf = gdf.to_crs(gdf.estimate_utm_crs()) gdfs = adapt_services(gdf.reset_index(drop=True), service_types) return {st: impute_services(g, st) for st, g in gdfs.items()} except Exception as e: - logger.exception(f"Failed to fetch/process services for scenario {scenario_id}: {str(e)}") + logger.exception( + f"Failed to fetch/process services for scenario {scenario_id}: {str(e)}" + ) raise http_exception( 404, f"No services found for scenario {scenario_id}", diff --git a/app/effects_api/modules/service_type_service.py b/app/effects_api/modules/service_type_service.py index 5957527..8cec4b7 100644 --- a/app/effects_api/modules/service_type_service.py +++ b/app/effects_api/modules/service_type_service.py @@ -1,11 +1,11 @@ import asyncio import re -from typing import Dict, List, Optional, Mapping, Iterable, cast +from typing import Dict, Iterable, List, Mapping, Optional, cast +import geopandas as gpd import pandas as pd from blocksnet.analysis.indicators.socio_economic import SocialIndicator from blocksnet.config import service_types_config -import geopandas as gpd from loguru import logger from app.clients.urban_api_client import UrbanAPIClient @@ -69,7 +69,7 @@ async def adapt_service_types( await _warmup_social_values(list(df.index), client) df["social_values"] = [_SOCIAL_VALUES_BY_ST.get(st_id) for st_id in df.index] - df['blocksnet'] = df.apply(lambda s: SERVICE_TYPES_MAPPING.get(s.name), axis=1) + df["blocksnet"] = df.apply(lambda s: SERVICE_TYPES_MAPPING.get(s.name), axis=1) # return df[["name", "infrastructure_type", "infrastructure_weight", "social_values"]] return df @@ -179,29 +179,36 @@ def adapt_social_service_types_df( df = service_types_df.copy() id_to_indicator = { - st_id: indicator - for indicator, ids in mapping.items() - for st_id in ids + st_id: indicator for indicator, ids in mapping.items() for st_id in ids } df["indicator"] = df.index.map(id_to_indicator) - df = df.rename(columns={ - 'radius_availability_meters': 'meters', - 'time_availability_minutes': 'minutes', - 'services_per_1000_normative': 'count', - 'services_capacity_per_1000_normative': 'capacity', - }) + df = df.rename( + columns={ + "radius_availability_meters": "meters", + "time_availability_minutes": "minutes", + "services_per_1000_normative": "count", + "services_capacity_per_1000_normative": "capacity", + } + ) return df -def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], dict[str, int]]: + +def _build_name_maps( + service_types_df: pd.DataFrame, +) -> tuple[dict[str, int], dict[str, int]]: """Build lookups from service type names to ids.""" if service_types_df.index.name is None: service_types_df = service_types_df.copy() service_types_df.index.name = "service_type_id" - id_series = pd.to_numeric(service_types_df.index.to_series(), errors="coerce").dropna().astype("int64") + id_series = ( + pd.to_numeric(service_types_df.index.to_series(), errors="coerce") + .dropna() + .astype("int64") + ) name_to_id: dict[str, int] = {} blocksnet_to_id: dict[str, int] = {} @@ -216,14 +223,21 @@ def _build_name_maps(service_types_df: pd.DataFrame) -> tuple[dict[str, int], di if isinstance(nm, str) and nm: prev = name_to_id.get(nm) if prev is not None and prev != sid_int: - logger.warning(f"Duplicate mapping for name {nm}: {prev} -> {sid_int} (last wins)", nm, prev, sid_int) + logger.warning( + f"Duplicate mapping for name {nm}: {prev} -> {sid_int} (last wins)", + nm, + prev, + sid_int, + ) name_to_id[nm] = sid_int bn = row.get("blocksnet") if isinstance(bn, str) and bn: prev = blocksnet_to_id.get(bn) if prev is not None and prev != sid_int: - logger.warning(f"Duplicate mapping for blocksnet {bn}: {prev} -> {sid_int} (last wins)") + logger.warning( + f"Duplicate mapping for blocksnet {bn}: {prev} -> {sid_int} (last wins)" + ) blocksnet_to_id[bn] = sid_int return name_to_id, blocksnet_to_id @@ -246,9 +260,10 @@ def _rename_non_id_columns_to_ids( pref = f"{prefix}_" if not col.startswith(pref): continue - suffix = col[len(pref):] + suffix = col[len(pref) :] - if _NUM_SUFFIX_RE.match(suffix): + suffix = col[len(pref) :] + if suffix.isnumeric(): break sid = blocksnet_to_id.get(suffix) or name_to_id.get(suffix) @@ -262,6 +277,7 @@ def _rename_non_id_columns_to_ids( df = df.rename(columns=rename_map) return df + def ensure_missing_id_and_name_columns( blocks_gdf: gpd.GeoDataFrame, count_prefix: str = "count", @@ -290,7 +306,11 @@ def ensure_missing_id_and_name_columns( Updated dataframe containing all required columns. """ ids = sorted(int(sid) for sid in SERVICE_TYPES_MAPPING.keys()) - names = [name for name in SERVICE_TYPES_MAPPING.values() if isinstance(name, str) and name.strip()] + names = [ + name + for name in SERVICE_TYPES_MAPPING.values() + if isinstance(name, str) and name.strip() + ] required_cols = [] for sid in ids: @@ -314,6 +334,7 @@ def ensure_missing_id_and_name_columns( return blocks_gdf + def generate_blocksnet_columns( blocks_gdf: gpd.GeoDataFrame, service_types_df: pd.DataFrame, @@ -382,5 +403,7 @@ def generate_blocksnet_columns( ) new_columns[f"{prefix}_{bnet}"] = series - out = pd.concat([blocks_gdf, pd.DataFrame(new_columns, index=blocks_gdf.index)], axis=1) - return cast(gpd.GeoDataFrame, out) \ No newline at end of file + out = pd.concat( + [blocks_gdf, pd.DataFrame(new_columns, index=blocks_gdf.index)], axis=1 + ) + return cast(gpd.GeoDataFrame, out) diff --git a/app/effects_api/schemas/service_types_response_schema.py b/app/effects_api/schemas/service_types_response_schema.py index 0196a04..2dca0a3 100644 --- a/app/effects_api/schemas/service_types_response_schema.py +++ b/app/effects_api/schemas/service_types_response_schema.py @@ -10,13 +10,13 @@ class ServiceTypesResponse(BaseModel): List of service types available before and/or after scenario transformation. Both lists may be present, and `after` may be empty or populated depending on scenario changes. """ + before: List[ServiceType] = Field( - ..., - description="Service types in the base (before) scenario" + ..., description="Service types in the base (before) scenario" ) after: Optional[List[ServiceType]] = Field( None, - description="Service types in the transformed (after) scenario; may be empty or identical to 'before'" + description="Service types in the transformed (after) scenario; may be empty or identical to 'before'", ) @@ -24,7 +24,7 @@ class ValuesServiceTypesResponse(BaseModel): """ List of service types available for values oriented requirements. """ + services: List[ServiceType] = Field( - ..., - description="Service types in the base scenario" + ..., description="Service types in the base scenario" ) diff --git a/app/effects_api/schemas/socio_economic_metrics_response_schema.py b/app/effects_api/schemas/socio_economic_metrics_response_schema.py index a5c1fcc..4b538e3 100644 --- a/app/effects_api/schemas/socio_economic_metrics_response_schema.py +++ b/app/effects_api/schemas/socio_economic_metrics_response_schema.py @@ -1,6 +1,7 @@ - -from pydantic import BaseModel +from pydantic import BaseModel, Field class SocioEconomicMetricsResponseSchema(BaseModel): - results: dict[str, dict[str, dict[str, int | float]]] + results: dict[str, dict[str, dict[str, int | float]]] = Field( + ..., description="Results of socio economic metrics" + ) diff --git a/app/effects_api/schemas/territory_transformation_response_schema.py b/app/effects_api/schemas/territory_transformation_response_schema.py index 61fb4b0..8a06d18 100644 --- a/app/effects_api/schemas/territory_transformation_response_schema.py +++ b/app/effects_api/schemas/territory_transformation_response_schema.py @@ -1,4 +1,4 @@ -from typing import Optional, Dict +from typing import Dict, Optional from pydantic import BaseModel, Field @@ -10,18 +10,22 @@ class TerritoryTransformationResponseTablesSchema(BaseModel): None, description="Provision values for the base scenario, by service name" ) provision_total_after: Optional[Dict[str, float]] = Field( - None, description="Provision values for the transformed scenario, by service name" + None, + description="Provision values for the transformed scenario, by service name", ) + class TerritoryTransformationLayerResponse(TerritoryTransformationResponseTablesSchema): """ API response for a single service's territory transformation layer. Either 'before', 'after', or both can be present. Provision totals are optional numeric aggregates. """ + before: Optional[FeatureCollectionModel] = Field( None, description="GeoJSON FeatureCollection for the base (before) scenario" ) after: Optional[FeatureCollectionModel] = Field( - None, description="GeoJSON FeatureCollection for the transformed (after) scenario" + None, + description="GeoJSON FeatureCollection for the transformed (after) scenario", ) diff --git a/app/effects_api/schemas/values_oriented_response_schema.py b/app/effects_api/schemas/values_oriented_response_schema.py index 9884481..9d0e448 100644 --- a/app/effects_api/schemas/values_oriented_response_schema.py +++ b/app/effects_api/schemas/values_oriented_response_schema.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, List +from typing import Dict, List, Optional from pydantic import BaseModel, Field @@ -6,16 +6,13 @@ class ValuesOrientedResponseSchema(BaseModel): - base_scenario_id: int = Field( - None, description="Id of the base scenario" - ) + base_scenario_id: int = Field(..., description="Id of the base scenario") geojson: FeatureCollectionModel = Field( - None, description="GeoJSON FeatureCollection for the base scenario" + ..., description="GeoJSON FeatureCollection for the base scenario" ) values_table: Dict[str, Dict[str, str | float]] = Field( - None, description="Values table for the base scenario" + ..., description="Values table for the base scenario" ) services_type_deficit: List[Dict[str, str | float | List[str]]] = Field( - None, description="Services type deficit for the base scenario" + ..., description="Services type deficit for the base scenario" ) - diff --git a/app/effects_api/schemas/values_tables_response_schema.py b/app/effects_api/schemas/values_tables_response_schema.py index 63858d6..95fee31 100644 --- a/app/effects_api/schemas/values_tables_response_schema.py +++ b/app/effects_api/schemas/values_tables_response_schema.py @@ -4,12 +4,10 @@ class ValuesOrientedResponseTablesSchema(BaseModel): - base_scenario_id: int = Field( - None, description="Id of the base scenario" - ) + base_scenario_id: int = Field(..., description="Id of the base scenario") values_table: Dict[str, Dict[str, str | float]] = Field( - None, description="Values table for the base scenario" + ..., description="Values table for the base scenario" ) services_type_deficit: List[Dict[str, str | float | List[str]]] = Field( - None, description="Services type deficit for the base scenario" + ..., description="Services type deficit for the base scenario" ) diff --git a/app/effects_api/schemas/values_transformation_response_schema.py b/app/effects_api/schemas/values_transformation_response_schema.py index a9ed71e..ea42979 100644 --- a/app/effects_api/schemas/values_transformation_response_schema.py +++ b/app/effects_api/schemas/values_transformation_response_schema.py @@ -1,4 +1,4 @@ -from typing import Dict, Optional, List +from typing import Dict, List, Optional from pydantic import BaseModel, Field @@ -7,4 +7,5 @@ class ValuesTransformationSchema(BaseModel): geojson: FeatureCollectionModel = Field( - None, description="GeoJSON FeatureCollection for the scenario") \ No newline at end of file + ..., description="GeoJSON FeatureCollection for the scenario" + ) diff --git a/app/system_router/system_controller.py b/app/system_router/system_controller.py index b826239..0177892 100644 --- a/app/system_router/system_controller.py +++ b/app/system_router/system_controller.py @@ -7,6 +7,7 @@ LOGS_PATH = absolute_app_path / f"{config.get('LOG_NAME')}" system_router = APIRouter(prefix="/system", tags=["System"]) + # TODO use structlog instead of loguru @system_router.get("/logs") async def get_logs(): From 998632e131bf5e13f47897100c610042c989f64d Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sat, 1 Nov 2025 01:04:25 +0300 Subject: [PATCH 120/161] fix(comment_fixes): 1. moved logic from controller to task service --- app/effects_api/modules/task_service.py | 56 ++++++++++++++++-------- app/effects_api/tasks_controller.py | 57 +++++++++---------------- 2 files changed, 58 insertions(+), 55 deletions(-) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index b05b4b7..ce4a0f7 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -8,6 +8,7 @@ from fastapi import FastAPI from loguru import logger +from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import effects_service, file_cache, effects_utils MethodFunc = Callable[[str, Any], "dict[str, Any]"] @@ -19,6 +20,15 @@ "social_economical_metrics": effects_service.evaluate_social_economical_metrics, } + +def _cache_complete(method: str, cached_obj: dict | None) -> bool: + if not cached_obj: + return False + data = cached_obj.get("data") or {} + if method == "territory_transformation": + return bool(data.get("after")) + return True + _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() _task_map: dict[str, "AnyTask"] = {} @@ -62,15 +72,7 @@ def run_sync(self) -> None: cached = None if force else self.cache.load(self.method, self.scenario_id, self.param_hash) - def cache_complete(method: str, cached_obj: dict | None) -> bool: - if not cached_obj: - return False - data = cached_obj.get("data") or {} - if method == "territory_transformation": - return bool(data.get("after")) - return True - - if not force and cache_complete(self.method, cached): + if not force and _cache_complete(self.method, cached): logger.info(f"[{self.task_id}] loaded from cache") self.result = cached["data"] self.status = "done" @@ -117,24 +119,42 @@ async def create_task( if method in project_based_methods: owner_id = getattr(params, "project_id", None) + params_for_hash = { "project_id": getattr(params, "project_id", None), "regional_scenario_id": getattr(params, "regional_scenario_id", None), + "territory_ids": getattr(params, "territory_ids", []) or [], } - phash = file_cache.params_hash(params_for_hash) + + force = bool(getattr(params, "force", False)) + + try: + phash = file_cache.params_hash(params_for_hash) + except Exception as e: + logger.exception("Failed to hash params (project)") + raise http_exception(500, "Failed to hash task parameters", + _input=params_for_hash, _detail=str(e)) + task_id = f"{method}_{owner_id}_{phash}" - cached = file_cache.load(method, owner_id, phash) - if cached and "data" in cached: + try: + cached = None if force else file_cache.load(method, owner_id, phash) + except Exception as e: + logger.exception("Cache load failed (project)") + raise http_exception(500, "Cache load failed", + _input={"method": method, "owner_id": owner_id}, _detail=str(e)) + + if not force and _cache_complete(method, cached): return {"task_id": task_id, "status": "done"} - norm_params = params - task = AnyTask(method, owner_id, token, norm_params, phash, file_cache, task_id) - if task.task_id in _task_map: - return {"task_id": task.task_id, "status": "running"} - _task_map[task.task_id] = task + existing = None if force else _task_map.get(task_id) + if not force and existing and existing.status in {"queued", "running"}: + return {"task_id": task_id, "status": existing.status} + + task = AnyTask(method, owner_id, token, params, phash, file_cache, task_id) + _task_map[task_id] = task await _task_queue.put(task) - return {"task_id": task.task_id, "status": "queued"} + return {"task_id": task_id, "status": "queued"} if method == "values_oriented_requirements": base_id = await effects_utils._resolve_base_id(token, getattr(params, "scenario_id")) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index d4a8ade..bfe50a2 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -1,15 +1,17 @@ import asyncio -from typing import Annotated, Union +from typing import Annotated, Union, Literal from fastapi import APIRouter from fastapi.params import Depends +from loguru import logger +from starlette.responses import JSONResponse from app.common.auth.auth import verify_token from app.effects_api.modules.task_service import ( TASK_METHODS, AnyTask, _task_map, - _task_queue, + _task_queue, create_task, ) from .dto.socio_economic_project_dto import SocioEconomicByProjectDTO from .schemas.service_types_response_schema import ServiceTypesResponse, ValuesServiceTypesResponse @@ -155,49 +157,30 @@ async def create_scenario_task( "**Task id format**: `{method}_{project_id}_{phash}`" )) async def create_project_task( - method: str, + method: Literal["social_economical_metrics"], params: Annotated[SocioEconomicByProjectDTO, Depends()], - token: Annotated[str, Depends(verify_token)] + token: Annotated[str, Depends(verify_token)], ): """ separate endpoint for project-based tasks (e.g., socio_economics). """ - if method not in {"social_economical_metrics"}: + if method != "social_economical_metrics": raise http_exception(400, f"method '{method}' is not project-based", method) - project_id = params.project_id - regional_id = params.regional_scenario_id - - params_for_hash = { - "project_id": project_id, - "regional_scenario_id": regional_id, - "territory_ids": getattr(params, "territory_ids", []), - } - phash = file_cache.params_hash(params_for_hash) - task_id = f"{method}_{project_id}_{phash}" - - force = getattr(params, "force", False) - cached = None if force else file_cache.load(method, project_id, phash) - if not force and _cache_complete(method, cached): - return {"task_id": task_id, "status": "done"} - - existing = None if force else _task_map.get(task_id) - if not force and existing and existing.status in {"queued", "running"}: - return {"task_id": task_id, "status": existing.status} - - task = AnyTask( - method, - project_id, - token, - params, - phash, - file_cache, - task_id, - ) - _task_map[task_id] = task - await _task_queue.put(task) + try: + result = await create_task(method, token, params) + except Exception as e: + logger.exception("Failed to enqueue project task") + raise http_exception( + 500, + "Failed to enqueue project task", + _input={"method": method, "project_id": params.project_id}, + _detail=str(e), + ) - return {"task_id": task_id, "status": "queued"} + status = result.get("status") + http_code = 200 if status == "done" else 202 + return JSONResponse(result, status_code=http_code) @router.get("/status/{task_id}", From bcdce057d1c1ff6bc4f7d4fd6a56fca1ea85d514 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 2 Nov 2025 14:26:10 +0300 Subject: [PATCH 121/161] fix(comment_fixes): 1. Small additions in endpoint descriptions --- app/effects_api/tasks_controller.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index bfe50a2..dc2a4b2 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -85,6 +85,8 @@ async def get_methods(): summary="Create scenario-based task", description=( "Queues an asynchronous **scenario-based** task.\n\n" + "Currently supported:" + "`territory_transformation`, `values_transformation`, `values_oriented_requirements` \n\n" "**Caching behavior**: if `force=false` and a complete cached result exists " "for the computed parameter hash, the endpoint returns `status=done` immediately. " "Otherwise a task is queued and `status=queued` is returned.\n\n" From e9a287490e8b726cf7b28477f1bfd08038db31fb Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 7 Nov 2025 14:40:23 +0300 Subject: [PATCH 122/161] fix(get_from_cache): 1. Fix rfor values_transformation response schema --- app/effects_api/tasks_controller.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index dc2a4b2..b1842f0 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -21,6 +21,7 @@ from .schemas.values_oriented_response_schema import ValuesOrientedResponseSchema from .schemas.values_tables_response_schema import ValuesOrientedResponseTablesSchema from .schemas.values_transformation_response_schema import ValuesTransformationSchema +from ..common.dto.models import FeatureCollectionModel from ..common.exceptions.http_exception_wrapper import http_exception from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client @@ -400,7 +401,7 @@ async def get_values_oriented_requirements_table( "For scenario-based methods the owner is a **scenario id**; for project-based " "methods the owner is a **project id**." ), - response_model=Union[ValuesTransformationSchema, SocioEconomicMetricsResponseSchema]) + response_model=Union[FeatureCollectionModel, SocioEconomicMetricsResponseSchema]) async def get_layer(project_scenario_id: int, method_name: str): cached = file_cache.load_latest(method_name, project_scenario_id) if not cached: @@ -409,7 +410,8 @@ async def get_layer(project_scenario_id: int, method_name: str): data = cached["data"] if method_name == "values_transformation": - return ValuesTransformationSchema(geojson=data) + fc = FeatureCollectionModel.model_validate(data) + return fc if method_name == "social_economical_metrics": data = cached["data"]["results"] From 418ae2004413554bf2e6b1549877f880d843d5b5 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 25 Nov 2025 21:10:21 +0300 Subject: [PATCH 123/161] feat(mapping_f22): 1. Featured mapping of indicators for F22 --- app/clients/urban_api_client.py | 4 + app/effects_api/effects_service.py | 201 ++++++++++++++---- .../socio_economic_metrics_response_schema.py | 2 +- 3 files changed, 168 insertions(+), 39 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index ba0942a..b8ea63a 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -405,3 +405,7 @@ async def get_territory_normatives(self, territory_id: int) -> pd.DataFrame: df = pd.DataFrame(res) df["service_type_id"] = df["service_type"].apply(lambda st: st["id"]) return df.set_index("service_type_id", drop=True) + + async def get_indicator_info(self, indicator_id: int) -> dict: + res = await self.json_handler.get(f"/api/v1/indicators/{indicator_id}") + return res diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 1a68c5a..9f46b01 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,3 +1,4 @@ +import asyncio import json from typing import Any, Dict, Literal @@ -80,6 +81,8 @@ def __init__( scenario_service: ScenarioService, context_service: ContextService, effects_utils: EffectsUtils, + _indicator_name_cache: dict[int, str] = {}, + _indicator_name_cache_lock: asyncio.Lock = asyncio.Lock() ): self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() @@ -88,6 +91,8 @@ def __init__( self.scenario = scenario_service self.context = context_service self.effects_utils = effects_utils + self._indicator_name_cache_lock = _indicator_name_cache_lock + self._indicator_name_cache = _indicator_name_cache async def build_hash_params( self, @@ -1127,6 +1132,86 @@ def _clean_number(self, v): return float(v) return v + async def _load_indicator_name_cache(self) -> dict[int, str]: + """Load indicator_id -> name_full mapping once, based on INDICATORS_MAPPING.""" + # если уже загружено – просто вернуть + if self._indicator_name_cache: + return self._indicator_name_cache + + async with self._indicator_name_cache_lock: + if self._indicator_name_cache: + return self._indicator_name_cache + + indicator_ids: set[int] = set() + for v in INDICATORS_MAPPING.values(): + if v is None: + continue + try: + indicator_ids.add(int(v)) + except (TypeError, ValueError): + logger.warning( + "Skipping invalid indicator id in INDICATORS_MAPPING: %r", v + ) + + logger.info(f"Preloading indicator names for {len(indicator_ids)} indicators") + + id_to_name: dict[int, str] = {} + for ind_id in sorted(indicator_ids): + try: + ind_info = await self.urban_api_client.get_indicator_info(ind_id) + id_to_name[ind_id] = ind_info["name_full"] + except Exception as exc: + logger.warning( + f"Failed to fetch indicator info for id={ind_id}: {exc}", + ) + + self._indicator_name_cache = id_to_name + logger.info( + f"Indicator name cache loaded: {len(self._indicator_name_cache)} entries" + ) + return self._indicator_name_cache + + async def _attach_indicator_names( + self, + df: pd.DataFrame, + ) -> pd.DataFrame: + """Attach indicator full names based on numeric indicator_id. + + Expects column 'indicator_id' with numeric IDs. + """ + if df.empty or "indicator_id" not in df.columns: + logger.warning("DataFrame is empty or has no 'indicator_id' column") + return df + + df = df.copy() + + id_to_name = await self._load_indicator_name_cache() + if not id_to_name: + logger.warning("Indicator name cache is empty, leaving dataframe as is") + return df + + def _map_name(v: Any) -> str | None: + if pd.isna(v): + return None + try: + return id_to_name.get(int(v)) + except (TypeError, ValueError): + return None + + df["indicator_name"] = ( + df["indicator_id"] + .astype("float64") + .map(_map_name) + ) + + before = len(df) + df = df[df["indicator_name"].notna()].copy() + logger.info( + f"Attached indicator names for {len(df)} rows (filtered out {before - len(df)} rows without names)" + ) + + return df + async def _compute_for_single_scenario( self, scenario_id: int, @@ -1231,22 +1316,25 @@ async def _compute_for_single_scenario( long_df["value"] = long_df["value"].round(2) long_df = long_df[ long_df["indicator_id"].notna() & long_df["territory_id"].notna() - ].fillna(0) + ].fillna(0) + + long_df = await self._attach_indicator_names(long_df) - return long_df[["territory_id", "indicator_id", "value"]].to_dict( + return long_df[["territory_id", "indicator_name", "value"]].to_dict( orient="records" ) - def _pivot_results_by_territory( - self, results: dict[int, list[dict]] - ) -> dict[int, dict[int, dict[int, float]]]: + async def _pivot_results_by_territory( + self, + results: dict[int, list[dict]], + ) -> dict[int, dict[str, dict[int, float]]]: """ Transform scenario-first results to territory-first pivot. Input: results: { scenario_id: [ - {"territory_id": int, "indicator_id": int, "value": number}, + {"territory_id": int, "indicator_name": str, "value": number}, ... ], ... @@ -1254,36 +1342,63 @@ def _pivot_results_by_territory( Output: { - territory_id: [ - {"indicator_id": int, : number, : number, ...}, + territory_id: { + indicator_name: { + scenario_id: value | None, + ... + }, ... - ], + }, ... } """ - pivot: dict[int, dict[int, dict[int, float]]] = {} + pivot: dict[int, dict[str, dict[int, float]]] = {} for scenario_id, records in results.items(): - if not records: - continue + for rec in records: + if not isinstance(rec, dict): + logger.warning( + f"[Effects] Skip non-dict record in scenario {scenario_id}: {rec}" + ) + continue + try: t_id = int(rec["territory_id"]) - ind_id = int(rec["indicator_id"]) - val = rec.get("value") + ind_name = str(rec["indicator_name"]) except (KeyError, TypeError, ValueError) as exc: logger.warning( - f"[Effects] Skip bad record in scenario {scenario_id}: {rec} ({exc})" + f"[Effects] Skip record without proper territory/indicator " + f"in scenario {scenario_id}: {rec} ({exc})" ) continue - if t_id not in pivot: - pivot[t_id] = {} - if ind_id not in pivot[t_id]: - pivot[t_id][ind_id] = {} - pivot[t_id][ind_id][int(scenario_id)] = val + val_raw = rec.get("value") + try: + val = float(val_raw) if val_raw is not None else None + except (TypeError, ValueError) as exc: + logger.warning( + f"[Effects] Failed to parse value for scenario {scenario_id}, " + f"territory {t_id}, indicator '{ind_name}': {val_raw} ({exc})" + ) + val = None + + terr_dict = pivot.setdefault(t_id, {}) + ind_dict = terr_dict.setdefault(ind_name, {}) + ind_dict[int(scenario_id)] = val + + logger.info(f"[Effects] Pivoted to nested format (names): {len(pivot)} territories.") + + all_scenario_ids = list(results.keys()) + if all_scenario_ids: + logger.info( + f"[Effects] Normalizing scenario coverage for {len(all_scenario_ids)} scenarios" + ) + for t_id, terr_dict in pivot.items(): + for ind_name, scenario_dict in terr_dict.items(): + for sid in all_scenario_ids: + scenario_dict.setdefault(int(sid), None) - logger.info(f"[Effects] Pivoted to nested format: {len(pivot)} territories.") return pivot async def evaluate_social_economical_metrics( @@ -1337,27 +1452,37 @@ async def evaluate_social_economical_metrics( only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None results: dict[int, list[dict]] = {} - for s in target: + for s in target[:6]: #TODO не забыть убрать + # for s in target: sid = int(s["scenario_id"]) - proj_src, proj_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=None, year=None, project=True + try: + proj_src, proj_year = ( + await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=None, year=None, project=True + ) ) - ) - records = await self._compute_for_single_scenario( - sid, - context_blocks=context_blocks, - context_territories_gdf=context_territories_gdf, - service_types_df=service_types, - proj_src=proj_src, - proj_year=proj_year, - token=token, - only_parent_ids=only_parent_ids, - ) - results[sid] = records + records = await self._compute_for_single_scenario( + sid, + context_blocks=context_blocks, + context_territories_gdf=context_territories_gdf, + service_types_df=service_types, + proj_src=proj_src, + proj_year=proj_year, + token=token, + only_parent_ids=only_parent_ids, + ) + + results[sid] = records + + except Exception as exc: + logger.error( + f"[Effects] Scenario {sid} failed during socio-economic computation: {exc}" + ) + logger.exception(exc) + results[sid] = [] - results = self._pivot_results_by_territory(results) + results = await self._pivot_results_by_territory(results) project_info = await self.urban_api_client.get_project(project_id, token) updated_at = project_info.get("updated_at") diff --git a/app/effects_api/schemas/socio_economic_metrics_response_schema.py b/app/effects_api/schemas/socio_economic_metrics_response_schema.py index 4b538e3..f973b97 100644 --- a/app/effects_api/schemas/socio_economic_metrics_response_schema.py +++ b/app/effects_api/schemas/socio_economic_metrics_response_schema.py @@ -2,6 +2,6 @@ class SocioEconomicMetricsResponseSchema(BaseModel): - results: dict[str, dict[str, dict[str, int | float]]] = Field( + results: dict[str, dict[str, dict[str, int | float | None]]] = Field( ..., description="Results of socio economic metrics" ) From 06a6b6590ec880a56b1cc059d9b004d5d9a85003 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 25 Nov 2025 21:11:44 +0300 Subject: [PATCH 124/161] fix(for): 1. Deletion of redundant dev code --- app/effects_api/effects_service.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 9f46b01..513c4ae 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1452,8 +1452,7 @@ async def evaluate_social_economical_metrics( only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None results: dict[int, list[dict]] = {} - for s in target[:6]: #TODO не забыть убрать - # for s in target: + for s in target: sid = int(s["scenario_id"]) try: proj_src, proj_year = ( From f1b09ffb308e2ad1327ae2a96570f26efd60e906 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 27 Nov 2025 17:33:24 +0300 Subject: [PATCH 125/161] feat(f_22): 1. Features for Urbanomy economical indicators --- app/clients/urban_api_client.py | 4 ++ app/effects_api/constants/const.py | 68 +++++++++++++++++++++++++++++ catboost_model.cbm | Bin 0 -> 4001268 bytes 3 files changed, 72 insertions(+) create mode 100644 catboost_model.cbm diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index b8ea63a..036119a 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -409,3 +409,7 @@ async def get_territory_normatives(self, territory_id: int) -> pd.DataFrame: async def get_indicator_info(self, indicator_id: int) -> dict: res = await self.json_handler.get(f"/api/v1/indicators/{indicator_id}") return res + + async def get_indicator_scenario_value(self, scenario_id: int) -> dict: + res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}/indicators_values") + return res diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index 1e5d28c..c47a258 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -1,3 +1,5 @@ +from typing import Final + from blocksnet.analysis.indicators.socio_economic import ( DemographicIndicator, EcologicalIndicator, @@ -322,3 +324,69 @@ } SPEED = 5 * 1_000 / 60 + +URBANOMY_LAND_USE_RULES: Final[dict[str, LandUse]] = { + 'Потенциал развития среднеэтажной жилой застройки': LandUse.RESIDENTIAL, + "Потенциал развития застройки общественно-деловой зоны": LandUse.BUSINESS, + "Потенциал развития застройки рекреационной зоны": LandUse.RECREATION, + "Потенциал развития застройки зоны специального назначения": LandUse.SPECIAL, + "Потенциал развития застройки промышленной зоны": LandUse.INDUSTRIAL, + "Потенциал развития застройки сельскохозяйственной зоны": LandUse.AGRICULTURE, + "Потенциал развития застройки транспортной зоны": LandUse.TRANSPORT, +} + +benchmarks_demo = { + LandUse.RESIDENTIAL: { + "cost_build": 45_000, + "price_sale": 120_000, + "construction_years": 3, + "sale_years": 3, + "opex_rate": 800, + }, + LandUse.BUSINESS: { + "cost_build": 55_000, + "rent_annual": 25_000, + "rent_years": 12, + "construction_years": 4, + "opex_rate": 1_300, + }, + LandUse.RECREATION: { + "cost_build": 20_000, + "rent_annual": 4_500, + "rent_years": 15, + "construction_years": 3, + "opex_rate": 1_000, + }, + LandUse.SPECIAL: { + "cost_build": 35_000, + "rent_annual": 11_000, + "rent_years": 15, + "construction_years": 3, + "opex_rate": 1_500, + }, + LandUse.INDUSTRIAL: { + "cost_build": 38_000, + "rent_annual": 14_800, + "rent_years": 12, + "construction_years": 3, + "opex_rate": 700, + }, + LandUse.AGRICULTURE: { + "cost_build": 25_000, + "rent_annual": 6_500, + "rent_years": 15, + "construction_years": 3, + "opex_rate": 300, + }, + LandUse.TRANSPORT: { + "cost_build": 18_000, + "rent_annual": 6_200, + "rent_years": 15, + "construction_years": 3, + "opex_rate": 600, + }, +} + +deafaut_cfg = { + "population": 300_000, +} diff --git a/catboost_model.cbm b/catboost_model.cbm new file mode 100644 index 0000000000000000000000000000000000000000..ef6bcec0b3445292257a120d996c755fd6359f6e GIT binary patch literal 4001268 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
F8gc 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 z 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�y12Vw<)-2 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
jYaVPE% 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%0DKAQua}gbD|5rhy-10K7VKdyHU$v{YYr~ZQ z>R|40AV^uWfb-mh=h27ufrT&G{#`a|~aeTDxIdv6_8Ro5*J ztCXOGimf0BA|lcu;i5~rk&^E2?vxY-1Stg-L{TxoUmBFZb3uKc=Y8+J-(PdgHGA%rbM15X`hWSpN9SI!EQg1@x5b9HdcwQ<7?VnPLuh48)z?xzPq_MO-{d?Q?32!a10`FGr zdB@nm12%Uma##P$_mV!Y>RhyeM;C5&-dOi=QvwVSNiKOy8;ibn(ZADcIZ4SQo zx+r)f;oYzW@>lQU7wtvCI37EjbuIhK4kP-n+9T!Pv?x`61#2XVXJY0$P2UZ?FXrl2 zFv%;5NA^w~6}1`B!RHoIeZPuZbWCUMJdV#jEN5aLJ$Qrr*P-Byz)v+c$iE1aZMU8_ z{B`+XqRZ>X66t~U3m>?y4DdOMG@j3=(>7b_QLb~6^3R6XH*oNKo8j?d`EHPx-mnzq zTT_E+9ZTu&*X3ukFQ>kOukPDUc;oP-N8}o5@VM;5yC*NH!NiIt*s(SmO2v~5p|%#Qq#gJ(`QxWXR0 z+yPRYK5q@! z{yO`ENn1xV1YJS4p!As)AqF6WX-%>I4KL)6zv}_vx|`U3?CYh-g%x?DCKh*L%!$9Pn!cN zJ`cs##7lYAl=CO;)wfwb-h@nkeQ9+0B@J1qeyDjqcm5}`-bUj47|jdhHk94W!~a3L*cZ_qeL^&;R{r z8hl%j>pb_m2q91Xz`evT!V9FlR&%N9b0CY4;2T}Adnl*Cj?DiinMyy8auayhbj3-3 znMGvu?^|k?U5oO93m#O<3m4=4C!v>zHIZeX+&;K8C4S$^wN_;Esoqcc7u$ycI=_g% zp=i9m67u)+w7)z!tOz)-hEsPQB9DLOy_6W^R|n7?$3>%8X+);Klx3x=-_PHLlzds= zT>M}B-q%~RvbUBu5 zrb@nggV!H|J{uh5HW13!aR)u)#vOy9B4&y{)_Rsa64^Z8*K7 zoo$sSxSsE*O8`i*`U%`E#sT3;E zQi&NEk&kymj(|@7zW!3Ha^bn}-=49u_>k#;Rnexe6&Vh`*I5ij{rvqrAxFUAiU5hl z(&5l^Na4ItnJ$^UTg0SLnzkQcGIMThug3dLLO%3ysOU^%H0=FylWKA&d4A{fdvrPM z4h1j8#A`D017Vcn(Q|ZrB*1CL`V!kj4KjO#-oF|??D9UrcN4GAfxE&?UNswnT_4tc zeWdsM`>5#Fw>LubDX%+!8pk>JLj!gp7G%%uPxTV=hqk0E%b2{`akA-^>nB=o_>G<_ znVAXClO}Id-?_#aA^U4R*Of>|;&aTiHHDYS-(wu{xcW&P1r)Qy)Y66 z!%D}DdR$+=*t&^fjh8e<`>dX6QA<_G*GD%P<4xy)hv_z}mMJh#k+jA_- zHCQS4ud5+TuH6eI-?tI^{}ci-<^^&}$Z&+wie7;8*=R_%ex4x=Rt|%Z1(P;L>(6 zl#gfoR%>*!P#*t>H`eBMt(QacziQvWP*9j%4exU*E2~AnMB{Tng&H5n!X)x@Ct>Gd zq4NUk<+x62SsJF>@-Oe#i{IRuxAhU;cM@_0d>`%C6;Qk$YIm;rqUL1`N9a{EHoL2U z+Xq!N5&*7068xmFxclYyg_7G*{2Q5d4k&I~h4M$rPl|uNu~BTjB_HBv3QK2vcM+c( zde$zhnFp|bQu(@$160(HGLii^K|M=gz(It6X%T*NB9?L-15K40pC5=rF(1JW%}_fK z(Pgs!;IsjfC$&e4(>P2>RRDhE|09bJ^0vot-HMc-6lWZJ9&5M^kFPWDBptehlaRjB zOhcQgcMFj})iaq4bi8=K<#A)*yJj{6#80Y!HvDwq(Z+92%|Mmqx&*cgTaafUdC$RL z8QGgnzWOtL^Y*E-IrGn^@1LgE9Q+t6v?>N1dT#5KW1;}IUFH0d8s$0Xcm!Wf4VBQ? z{C58T>3B))B{EDVdLQ2i9kk>g9dd|-QML`K(-$4)9KR-wph6<6CyM`l!;^^V3d;Kl zQv0$h=iv?Q0%Y$P&-#3YU3k5^;Fm;YOGmlxF~l}ys|JC%&KC@LzMR5Q9Rs8`H3m)3=iJ*8hG`2h4>HVJ*f1wQ>)1CVh z==bKN@rP(#*V^zy9mPjVFE2R7W1YJe(N8{c-F&B?9};|*PS`a+&x=EN`ZAwaAtJPh zzdj+7v!u`ll$~BT8Rp@L+6nnkc>RUA3FYz4BKt--wNn(fJXYE=H9oN;eqN)}MkP*I zo%blyYcK&Cuzz#e+G`1(Fs{lmzYzfzcqYV*i}?eSz|rfb(Z#SzbKa(aZZ+f|VRx>O zON6?1XEI(dg6Nk_(b{kzea}NgL^f^Phj3E8r#tvojw;%txLs9!$5*;UQ{*R=-+S+B zA=P$CBtLZP>&jDwyAdAkD`eP~i{G<*EOPD5f{AEE-#V(#0J3(^*&eC=w2ws*ORe!e z{Hych9!1G1bJq91a9b{oy8N8^N$r0)eLWiR}qGU%_cD zc6~Sv5PB}|p}xP;kMjA9;G+aTvNa1NEyMfIN8d)e`RC#D86`QQU68riiMJF#FQR>} z@a7IcmBxVQ`yw&gzLQddD8A={e!8)Gz6g&SdtYHxuYmCNW}lYi+>}T7TnUL;9Iy;ZX^sfA19$wo^i8 z$o_)y+Uj6#TZC^tF>sc_JrVKeY4w@BSG7Vo;n!T@Z71bN%dD|{z1H?RsX9C#%%(@S zZ(!|A5PO7UkdeH=OgLKTW$EdVcTA zdpqyS{(gV@cjx0*?Tg;IHCAeK80GI{TGI=kMqCe=O`q*6?eMWH7SQ|H7rKftEZ3#0 z$8qnr4XBFnyc^EE8+<>{L_dEOdB3(-T{ta1&j_yl#&u#>hagz+Qd2HCuM{rQH4xYp zYXfWaB?^~~+rYfQmG>bt`8m9U#w|rtHMp)##N`-xN}{_l3&Nq~>F!L*by$L*6mAjQ5O{uA0`5!WkzXf?>n@~xl<@wD zsP=zb^7@{8eqIwD>6Pt>X+ju^ze?{ zi`gLk>(7sn|5Nz>LZ5fha3C5-zp9V-Md#l6(v>z5roM4#NBloN{%6Z;R$Xya z_;fPr*C!WuOswah`+ikkXxTBw)(9luT)Bo``)M4C(~sBDS^OA2e``L@$Z`IT?*rc? zo*p@Eum$WJ40x(A!w0_iI(@sa#1!dMVOtd{Uyax8dj1{e)`wLQz3=gHZfQ4lgb!=l zRxTG%LH1{h|E-t1THg?UujkdonB#|I8A#u3@_Zd;wsc)th+j>^*4E`)Ji=#_&lR4o zXM3gr&NZYOZ)wp3GwbeO-uPS@+>t37J@=$!Ry~_bdcMw)@QDf3VRS)_9qg_f# zZND+XXVc%+aP#d|AG|I}?RMe${BiF4jHW9Sx@Riv59j{uBxyyS;8+ zA&O@<`^)&xIK~gpeZD2t*P|$t)E~VQkhcF*xTn)D>xT<9QM^45pwK}rT;H6{k2RdI zrRgai@0ALAG=mTEdVIhsQciK(b{M5!>RcK|`JR0?JG0SOC%5LW?Ak?^M`}?i?epOl zaLZnm>FCe6jyIb=g1+bIxS_$TXgHuZvYBO&yzUb`l6^jbOCH|5>uA;FCyVk($X~I9 z-x^n?k+l=_zZ+7QY}(S{T zqT-x^5HdPp=T9MxRwG-YIeGnWr#0iNv1*f_-}RO~Z$1is+x|1LW8;;=O&qdp$yg90ZU$)|R!MSD1~a*07spfzU+_59pwF+}zMI5d=<2 z^6Djdf`@VR2h5))ljZyG&YL^C#Qan5@&4sc?YUf@icx3V4a}V~IN#(1LqcEv9nXhH z)~Z8=p^8HRhw%HosdbG})?VcEC?Q7*-K!ll4&2B0v-@`}xqYEY?;p<_sb2(s_``|i zt_(IH)u7LjQ%fK6(eAN-BIy9*6KjpTGaW!s?WV`8zKcV1ty|PwbP4dPVEFBi9DBg& zA2pH}!V-Z_eqLr-q%!ED+Q*P~qZsPa#nGDF!}r;jJ0@Q4al_|3(D`(0c`fcY_e^vQ zzFztLy0`eG_|pasKH#-LZN;MbcF?x(G0zp~i1Pbwku3jkL^jy0uh@3*=l=lFuH}<_ z){%mqvuh`fpU$WWVAD*q7Ew`$r6N4}1~Ymv&u*eQOT-e4RBXR&^hpn}Rft6iq|3u- z#@8=>;(TE68@*nq9cnOdVaUMu+yv-2Rv>rqC_fOfeKB<7p(ae)tWW#UP8U488&+}j z`~hU|(VIGxu@5;2XR9z~eY7$flztf#%3!rc?Zoq$4MLMTdgf^#uq7ra*Zk-Iv?<%a z9NWlEem-ne5>n2sF#(t3r?g+krol&s8`7-WSAp6S4`R>n1@KAJ83}D;QBckGdI>9) zIM}Us@ZOU9yTSIH1^tgDya5}%%%!IdK2V8Oo<`xi1-R9-{(-`Be6F0$&;L}q=U3?q zfyeb>%x&32tSd}mFz53{BHNvTLD;G{=kmQlkK}97&nbSu|IRz>>n9FDy_JdgKRio< zexn5q_K$qPsIl;s+#|SdZ`W{=BdXmC{JY{kIrYqfX@AI^?en_mn9{jBz@+0;>Ej#x zU>M`I_B{+k{G@nT5g+IE#Rl-%@O7a>1EyfKaGkiGm?e7tL4N(h z-XY!yCzXG3E7Pp|f+ack@`aKV`APL}D~+>beL(s7 zHjDny4saAg{(+8Q{^e9ANWWfdk@lhVEeMa3a9pf=Sr+lj3$O3V9@j>6Qu{4h0Yl|A zG-Y1JRlGaz#%r!gUPOw@=OKMey&12)zW1 zqSC*6E!Y^z^N4*6e)gga`TIJy%TYB+5%Sxuu;n!I2kTNhc^3-rLv#nH@~UG;Hh>QW zo5x;zIKf?hTwKEMwjzBsIUCp+k5jH2NXQ1ud8Ez*hL@Mu9*oL@qUYYS=2{|} zfUgY*af;14;Rm$bNr z5KgM^SMeiv=S2#w@}Q_sQgCPTm<1l+X{9ShV_5NdU^aU)s|vhaL`*2gcfy@Y!&jg3 z`{!|dwQJ?3l#%?sb(cPUlhs9d9V63ZhXdvQ6o>b2{x|pEx~y`?{gYJQ-*iLlg?M}(Bc<(y$vRBz7LNBm~5 z)8B+Y#plh;427b3Un#GD)N-$y^rR@Sa~ZmZ4Prrl$Y0X3Pk?x3gh|E zYGse`oz;CK(p@_dzIO+Yw_3N>ob4IBuFaVFkc9Z-1G81BmWCsJv-#K0(#$VoZUJ6K zDTcZ`x`OoPHHR*n%YuEya#E}ld*-ZXHanE)1Cmo+i95Mrb+BQ->ZA`WU|o1`}=7c^Ax2V zHKd=E|33{6$&2WCa~}V`qDO<)OqhxCd`Ig4T`r3k$`*zwf3>@}Hc5-leZG1o`23E> zOIu)51KvFUA_o?4wiD}prHS;B+WS+yqMB>L{Uu81`H0*&lGv4F2GX4zlFp`E0%@Db z)x{qv*Y8_TwIys@h0jkb&ZJc_f5H7{Q3K6*?m2v)KAXR0k@;n|A+ygjv+4O=di8~3 z!%y`1{62P&?zv$L9*@7Pmym0jt28IHjnRUWPx%q z&foYn4#2qOy0}f?8BHhn!T*HI`2)D$c6Y1ox~!%HHmEMA z;{Op1Rq36W(j^>#by~l8$d5!|zJ{NBJ~&%JU6S_MQW-L-XU>FvbxHji|hife%% znZKlR1TMAsDwmun-iH(l8wHuBWC4Oc8;s1RAIi$yj_mESdA*@`z+qPTk?yp>$(mL0 z%h{UuJTFxcKX1F?m-`8`?_)+P?Cv*Q^#mp*jOy{G#&yzlMr(e|*i+s&lj?hS#8h6SA{50>N}mn?dgRNk zyP??s(P4ku^Gfz`joiKYg7mYmC$>lG&hGhW4|F%>K9)-fh0Gzf8+c`GX3gKXBlW34 zJG_8zS5lW-fHCmw*fI#`Wx|a|SkCUqBA;(4#gP>}1)lQYy7v2S#=x4#CU8;WuyA9$ zDPXOgxBKL^m1O>W@e)|G+u0XpFOD5(JB7!u$hhs1F^PH7D(tl6r zky?z;Tl9aSyXW`ni96u&LCDe2DTz!DI)DqG1NPgg)6GnD~!fjcpY4k^RoAH`rPXp#_#K1P%p>l zm6;jeh)vSJKb|g{KAUp9Irsjfa7X2v1`YP#?rF;P=GVXfUZH^G?a7NgP;6VaDo|y&ZDtAg519 zdzG&PTz@O;iparm@P0ssJtI^f9y!SFtJCiXY^qLHgzziFwiX{Mt|D8oIyX9)raJ+u z9jLUT_3;LWeb=(2uF(Z4`9~G%zxjigw`BWGgaELVjQErk6bLF89G716Q_t9!5cf4y z-oSe(l}BQ!Jy^2p>;<2n=kf)wmkUUT;eLK%O%HQfdK|be<7~Ji#}lmk@vOaTY6tAk z3`h?hjz#^O$Y0x{i3?dBN|6J4czRHKRTqc_8z(tv4~WMCn}{PZov$51Q2W8IA1yB6;)RLW zEu~ISd21PS3ZEf*4j~$SYqDxwfCu9n=KF2|uqf64N_H-u$FDLp7S}y=hj-JK$@`Y$ za}wc4mim^(c8kNnf>K&z6JcY}E}$*b^$D+cXB*td7hTY%m{&GAhSpM#a^w%8_kRoI z>VB3oDZm5M7QX3!9JupxgswRbfB@=gk1^c>*kJI znq|DQ;0V*=n=ca!;G^X+`Ay*|&{A*3BkC2}00wt}=Sc}LDObN~!Js2BuQgfIcNO2S zy{@Er+fTg}o^ashEvzsACB=6PqZb^4v32Li2jTaGAK|V$^`^g2eN6^OVdKzAAw1T|O9G>Mv)9 zaDwl5;m3>nUl;3G0H+IEsixE7$@KEN<=HZL>jGNw>HMwC{@{1*P?Ed9FJ@rL6&p~v z5~K=M27|U~>Z6kG_nvGnJVqb<-zrjExgC<1X2QiIbWpqDdB5*S4J(_SA0UCbid*aAg1H$t;R_ z%2FPzwGyQT!7`W3_%)4C{sW_5@`8~#xbi?Vs{m908r~DEi#k2PRfpv&jNK97+dY=U z6$cU^^ZU!oQX`U(9I5@J`t5QtPZKHHKQEL${%*(?S}AR-3NzV7rYG;bs(kP+F3{#_ zORxDa-?Jj@D6i#k{CvTNOpc(xor#tZ((tEf|Ms*6os8el|I^xjK>X4uzE{}7TBual z=?*yK65Mh%W56*rj>>k%PGz#pwfyYp__1OKy8)dl{l&~eZA&s*;lL6_bO zYY!#u0f#Pi4G5X)z-V0~T4yCWKzF^Q=Gg{cSk&G5b?lxZJQerQ*79*8Mf>|hwT5Q4 zCs6c%LH`al*BL+XV&7Wn7Ax|7?5jlv8dLrOux+F*>*iHUigt*2{wm0rFt5$;g*VK+ zanIoP34CrUa0i(s52B%1CKr#+&EG$N&(+V5nJae3Hc+nLCzrb_?JBuN@qbrXdWAC! zRH7*6{~TM(hm|9SFr&&W;$1yHClUS?99uKevRn}cK0o!0YCr+B@m6nCxf%{7)W*0b z|C{&n2>H5at&O`c0RD6v57WwdD249qn4?nT6s|X>pX@N6u*AfAZMi z;s4@0HH1B%PU#~hEp|XNa7^a5?oJ%4MsEU!JsN%hH*opD6BRJ=xBfb_mBUjmW(CG?x#M<$j|MBJga_5*U=#tz?Ud}FTg~Eov04uC`M;oE8!cjma7uFjRJ&UwdscE#Iwb5#`Y~mQ zma0IG*p8##HjzMN$w|))HfPxMo`>r5dMnsHF14_-ArLqklqtvyI3YPgUM1VKCU6SZ zL;94bO3$pahoW6j(RnLBY!k3{PF-UUSO+2%ro<`NT?u`EH*~q`>eX>k3q21r8=2zc z|MLIQ5-s-0X8ya$;w1bM*H|F2#%=|YhY5EW&R)r+$WO=-ko}cm=j-v6aFgZa?xrqt zn6h}^D#12xAf|NnbF5DZYzjC@zhM!s_w@UlrQPk(K=y8mXwce72qJrK^fs(nm3Te6 z_BQ7}zai!Q6X6E|Cu#5L)E}Pvy%Hh!yRhS9#{P#cX2}0EFQ!hOa$J9#O|MVFcB2|K z{CuXb=g=H`7J%|IoBZL=Oq=S`@&1V6s!$B|7vDL{E0$Es$e3m$|7WvDO7{-9ZS*Eh z9vE@vk6AMLz^wv-KgwT-Ap4b>E%gayl+Q<^TBC6cU)_*>oxoimC&lo5Pil`8e=zRK zc+o2Z$&=FmZk&FoQeyP6BZ_z7@SvJPVa1&NQDO9WSGYEC&ithDXscJIHu%r|9+p)8 zPjQ8hSH#i}$fu$G9I8sOK;FEb`9rO3#{vr|?=1pVdiTIm8Rmk7>l0ta((@!@9H{t%g zC7Jo2#@cV_y_wnkjNGQ$%3>Lc^v%XkN`HSqF=P1!{9fWtzfRNZ7x8?<%+_CHWEBHQ zV|+iNptf2J6|rkxy0M*1%l(I1~mIXGwew)B|V#k6=mB>eiP zVE0GEYRB@4U&ZV7metum{y$c?k!{=+Z;kV8=+U9<~`A^T!|8)PJEqc#slIabeI5>=- zJ#7o$-ib1lmNc95^CgWxL^;ue`yhE;gp`lK-zJJMJ!KFA-AReJhQpj;{Et@y`L(z{ zNAOXCCM^#0?be%R86EdzDUN4SJ!kj5XPH)x`|Ws1_5CWIkf2bpw*bFSBa?7S zF8d`OFSF@88WFa~br|2zRzLUT)1JpO=lDy4ug35F`Fz^2541{!ude6ys?YW3TSaWkURx z5gX#!-|{1Vz8KrZGULSv=Sh%sNN)5(_=<&}A8mP>3x_r~SKc%5MeUUQC2ePYUC(GN z9E;>^IXCPqxL=BJQhrig>4_1TIEK&vK^%ela;xzEl$4(o-|6e9URr|dwX%xLy7qbz zfRv9Ce&P0lO>A2fq5K}}Z-??VU=8Cp5y=^=Im=VhJJ)vLAhbtR$s2y5U96qwp9-a) z^Yx{KlHX%f(#L%jaK81z=a1w9efP2;Jf0}Y5$#GTR(#C01d>j&neTdC!HtZdD~e0zzCN3uOY+g)H~0Da zPva$(zqVwjQrAmduMIYD{(dV8?>9;LN%4w3NyDuTSiZNTKx^WY-JJXHI_jc)j>9z~E|u4`?oP!G?C znnGWlZmh!f=nrW%b%n)NNZ)DArPZw~DBmNkxO`!t_!+M65q1f9{_S zzLr^q{xPn%u>@2m>eRVHhS7FoiRs-SLpUsQfk`}I;$X3Apo#?pEKawjS#bVRBRO9U z{LA;=2CBN+?(MNhd2jsAikNr8mQVi$d=vo6yd_6lv@Jg%V*NS|A&pZ-|igfp0;naG)N@dWlO ze6;V3;B{*4IL~S>8XOO)e9rLksF{-m$i9jQ&m9{MJij&vOUYK)OF@N4dkz=f#m|Y* zGx{;)Y54=n=X+_azh_e`9w&sndg!|82M$gsu3Z{FJ6`u=e=ARfoxa_x zhx{Yt2*?_^_x|w)4J1#r6EN2BV40j9<^3{C+n&0<7+s3`Wdhxe_5~*=eV(E4mpM)NyzgEobJWZT*Hdm@%70`qU;o0Tj=> z7u-%a7<@rFbw3v|fyz?Gu-BJr^meC0Q&!%roQ zs}Nq`|;1V@__K;uZHzUE=et3gx8&4)qkI==p@Z3{+-BNjYkR2s`DEJl5{a z^@2swoXN}QZ$kXe)E}i7^>-lrTAi53u|d4gA>;|TGB-!nvMUHqCLQc8Zgf(Z_1<;0n_NvLn`*ea{AClMI zamEQ2zPL4}drS_+Yc|w*nz_>rn)s^cY!S$Zk*DM?FZ!|)(Fu71roNo7S-0x&FUL>F zOUG<1?6J=MZTVu&_)R|^{CfRkI4*jdy|gjZwXaH6r_rHk-~Tno;0mSRuPYI8Qi2S= zYQeXf$oC(V_(|I%_MSVn?Lrwv|4I1?T<2CjcelwNc)?;@aqyw2UzR8I{JUU&>ipvZ zS+?N&a>g!qGrUfb`op#~`%1wC`8i76EmnBrN^4NAVOc|~xC{_-vaFeJZ37KZKI&^W zNAk%fBRauHKu%-El(O;P-^cWyZE8`LS3&j&y|cjrP2OA{17E<%5XC3=7}ps;yt^fH zHwW+ce6*%%-<`+%dP48W!}`^7*EWMVqX&YkY83&m^xkJ%E@i`>qA}Q$_V@L7!af02 z+S@fXT7)U)m*69XI>W{6%}ews=ItxBVZ@tjG8E}6r4%J%itu@mRR6Mtjm`#%FPFSmT&Q|U)}Uq{MMikl>D)vTVx=R;DujebsA z%>;hGlayYxFyChXRz-^blk$_|jE3{}_FlvFBU1X`jo*1i^?dVcPKy2Wh1s5*!#{)6UBEWKIp4ta6BOF^zQ0mK6_mY@G!MoAKm2yw+xtmO1vqG=*h~#l`Q}A zf0S(5uVc97BM|+}cOAKnk8%Hg_53-l;cZ+u(LP~yUNB3#3rgWDbJs&cW?NfY|}vg5PJWwz~irF-f_TPD1V2F1vy+-;PpU*=fWH6 zAC%|cnb8~;_adBsPBmwFH{Bql@9)l|*cTD5^BYBe`}rMve)fl%r#JFf;v7e8(ko+x z|Em9MQ)E>FBPpLFe^uY#%|D&Qy6B;#5U3JoYIpkiT~xov6Fd0~BvJf7cJttH$aA4O)>XHeAtA`2!(!)thASo;=1onCSZS7+!B1CZ|to~)d$Qgt3^w)@=&}{>(VcH-?c}$$t#iI zkJPw7Gdy=ci$*xnPQWFest?XN-IZA{T~_c#^U-#Ih}Fq*$IIB{j@q_v{Q8-vcJ;d zx#;bs$p|O>BA`EGwB0jvoG+rC64bVP$y$HG7uoyVq}JAS!xdJ4w4bo^jfOOLOS)3h z@x8z>L;MAKb6jWV*uoX&ljn%!du&!W>R-b7S+(KArPB-X``@H~Xr(>dwuxF0@|~Ek z!1MDxK;6rwS3e#ML-C27npBZ`><8QOoC5R*oS`3`1>1vN;oxM)g3}E*Z2^Pkz3R^# zc)rxhj~qzeZ-D%%x~gyY<}Th}-FZ~g^<@&T*ACuH;@?f35I?DZe>a{m^6sPjTD%`L z63}fb=EZf;sM2Zf{_jv&l_zX*1S9?h3xMOjH%yyLK5K| zV*E!>GDf0!N&R@Uw*USXS8K$7|6^nC)2lX!{|(!!%!ev-A5WzEXTv*MtVHh7h#`BQ z)k>s|mVQU~)q9xRx3<#b@dW7Col%ywL;R%rht}TZxDud+;vuDz;!9&R?XI7-vqAptzp3!@l+2pt>vM+{pzhaImK>`CFwH5ZKPX)LxDgC`cXh_5b<*?S!5NE1j!P zzv?1;$>J-$XiM?^_I69Tp2A zQ*+|rMb^~yYh>}g0WF=@l4%`0U-c&ieMlU~bxWSqUB2I-pP1=<20YiTxLPJo=oVSE2KYWUou*U_h(zdiMY*; z`1vwZpE-HrK>&(}rKEVd)ho*5J1}r(fqBc^`)k=jGmFG<2UwEgKr1Kj23}uOcrI}v z3|3C7#V<+lg+nV6R!n~K1iHuf>|QFE2o+D>KE5U+2bRc>sdM%Cz~c;I@9b~j=V8$u z*2i+m4xU;O{=jRV1-xFo;aob858U-hN5af27v(i<$mo_ES1=qpQ@->jGz9@+k0q?G z;5do=MP2*x;Tl&Cyk=qRVC3uppBrb7_b$tYN)I^$*KEh>&WxElgR6&HWAzOK5S{REolr-!PDUm?cBTC6(9gQJ zb!pk1*hcboXeNGlipOPLGI>JJpF$SZ*U^Iu{D6r=QI@V^Ea*9tx+#oX1N5aX>Dw5Q z3Ryhug5RY3fPMGc?IrI;1I94BZbpVsNM|nX>@q(b$Z3Z%8nuQ1!VeGA0rOovk)ZVL zi+0{b`(W(@2=1CN*g z$vg|NVbzDC1u2fOx{6n!S zuLx7vo-b27;{b}A&F`P0HGw;|WKL4sMS(nL(}k)c!XS1gZ`abbK46lX%bPtH?>FP^wV4Tp{kuX?Qu_jOzoAAX?(&oXRTI^MP&db$@|mUrOy+$j0~r|l#3ACE^l z$$<^qwECVM2nKuI6yuNmaDdi#F4?~Rd9QLNt<$S>UIh5?Mu?&4i70g8u#uLo@P>US zw4~WvMT$h zUgC2X5yzzi>6hXS)nRYCBD=y0@;Z`P`xWE5cYYx9@vXJlRQQ}p$OmeKWC>gEfG)v@ zpTCbIA3p>i0e#tIKJ*Lw!UNZz3m1A>gS(YFZB$;1y$Cj9yOUX zqxk$Uv==kQS@R*fYH!}tNo_4SEKC)}zRni46aEsgN$9?ne^5L{I>9#^R2RsOa9V`V zTcrH$iMqoyjaJCc=571W9CV<3pF=87w^zGIN}c>U5q#cX&f7Y9+5!UaPd~a8geAEkD)Bnj;iL-KWu5zMP>V!e_I;@ZEUZL1!b1{O<&#vQq@&kUdg;m&W&{ZA!%T*us-uetNg? zev_1+6pv5t(W-kxdH;W|_)wy6Vk|}bcN&=ou3K3ky8l7B=%8Bs9%SnHZ3Ep?c>I&v z4_hnfsWSTe`?sgLi&)pylFyT*c77MnGPmA&a8v=sx3~GpmFLBHAKqUXT`=#e8Zh|b zOZRHn4xUL<-{@SA`8Rhe-k{drgXBs57+6vL(JmdI#}8TDTFV_^4+y@%ZB8SnCh+qt zU_EYEcg7Uak26l4pk~JXpYQFb@x#|W5uMOO32MC`TAG%N*MU%mz;7;JDv-amYNc12 zZSeTsM17)C`$Z_yA28a%EKno_-1nc6Pu*h#u8fS72v^~CZRL6w-<EarY&rA>Z_2ReIHj_hbkI39i-O?mHJi z7HfZr$81hWA7Ph(V6Wz*mNh@WTis8^qsgKKI!+w!;D~la`Wb9GSbBrqk-UNa{;|7b zt_W{yf68&-#cG7#@q2LW>LSYbDTG}DCMTT?&G(N(_K0=@(&c_tDPRtRfz79c54ER5 z)kz~8b9r2EZMfa3|1HM=Fn=Z1J>k5o@@veQbR*xHW_S2lVCIpXv zQhlWOxR4R8VLP4=mOm)ex_Ty!qCcea-WSik$>%kPol^H$E=?E%fgtAKqIdLcCV<i@r`J`_0qF}2 z-OwX_2G{S7Ty|dD)w>hP58JKfP*TA2GognP6pILb$&;Cb^k?^%t$6br{OO>}J5O~q9wzx6y%o_C0lobdX*2FN zKA6PPRNbzI_>a&?-NI8@_=YJ>0%MzY>9EAof15rw=P~5 z;`KSbqK>a@4c>nb`tYZ$-=>ZHxqq#wdDCeHWRH?wqP;Um=skNC?oUKJ0Vmqa8Xpbm zBK;}KIhRLTHRi0}kRyIw(+@n~^}YymDbdnI`UpFepzes;_LK}|#2;{JAbGGB*Z;=d z9A3C~=_7w0Jgq$I#A=85Dd{EJxf#|P?>M{=$rJ4aGy%LKMP~N@^`5oa~ zjcBmbtex@L~l;(oUCyP>qS zJ>@*_wv^=w{P|rY!r%8FpJXn)9E$XPc|z09SDJ!wg74oAZycMZ*&r7I8Uy!@umTns z$9sZlwMrC_x}x(n@RAwww;`NvnVtpC&t3_Q07GGKkjzKFtEs>riazlUT&p7lyZH{P zT(|NEMWvB8VUuzwAHmOBE{k}VB7LNB-%P4n%&v;xg9tJo>)2O`=K)fFQv7;h6X(Fp zA&UA*`TrCb+F6^)5uujiXiOH)k=r$sz5^XQlL;N zt{dsaul|%Yi0ApT3Xys{HdnBhF1kwd=kKSE*lY5$%e%s>&!V?&d}jgJk0*BTW!Vic zw7Z9UW|*OVKq(H%@2UoA0`C9V^CR`+(Xyk96IMAR`{_^V1f8R85T3RF0{eYldlbj^ zkV2N%wX*O-QS3_@2@`Pgd6L&R@pwch^rt_u>{}j2zMnEaeVc3lHB&NvLhhe}?1%WR zSbqMWY05@7qfS*t@ILzZzCFV>;9JUVmX;&D$iJSFQ`Y~Fy*CfXvg;m(g%BwT4G19= ziptD3PnqX=p67X(Xn6LU5~f|#_TrD(}GT*$TC3X8;>#A_@PcY%^?YV zQI0PgRRWS_V2m;i!~^* zyLlh)3;3>0*F5mU=bme9VrMp0CjYknx$JY7Jq$k7kI!8X`<`XYBnHg8zrC)zx$%gg z-*5ZJ_IcEkRSD;xUhdzzHQgScn{F++dCj~NpYKWQ<59IB;nN*FACl7N!f%zBTQxW0 z{K!#G(6sXt@4V}?n4W2;p-nQV^j#x!hf^J%II`tfj%+cE^?x0(NHrGhwpTNoaEpS& zeka(@CJ2FQMyj-nSJGgL@!L+t7)Q{$Xs7*$y*{vJ>4L_J0}?3ig4d2nzf=r_Qx*o( z($=n^Yz-Z)jBx_;D_vz@vFJEn7v`$xKTQ`FlDlpcoPhl24_K|Sy)+TweF>Ez88NB| z@2}Xy@Psu5)#q6l2e@{#2;u*qph-BB$B_td;B@Tz!Bz*bnPn@-w}3oo<5k8i zF>VU>>|fFrwJ;kdAFAb?`pb6|3U_U}<@i1ve7GhV-ccY1x>ud2Kf)~yE1V)%v{$8p z*5Pe=ZpCgWUVqBpe@aj1ZR_e`ia_YUHg6Tv)gZX}@X6$yUIX~8b6C38Hyt`Nczs_f zk_4|>-Z3)Ih=e}oAJb{X@x1d-`Sq{TJAJ*pE-N1A9Xq7b!fyWMcmD<=H#T(_sG#;_ z@xP_ecoCmpwH^qx7$un^dQIzr&5lx3Xgsv<4`Fat$Ll~9f6N}20DRsPe|42I?XW+R z_ut4O8-8hL+zPkSMHm=Wuj_-ZK75E+iB!oUT z95O`q?b6c4KDId^JpFU*dzD4*2-hq)>SBBb&j+l^%-16hwLob20U+*`v1Xw zyV;As-1Cys`NmqNsQhufNa??dD~P_ovrp9u`7b=`-0XLEGr|QestsT3X(L>|hflja z47cCm4HohX1snlkH?ZF6Qjv)n!YTK<9PiEChvW%90v_2_WOCaF&ljyW@)7$O@jVy8 zN5HKYuc=s8;C;2y$rIt*8-70?I~I#?{I-S*`PZ}S-V(_NTaf=p9_$Qq=ED1I3(Kb} z?;{Nmf9?*7raIf-KgX;}d`{7pE{gO8s4m@f;Z``ZPt==$#|;^5zI^=q>u2SziI@j9 zamX&A|G)5mMg(pc)i-wqXrO-faYy;0!!a(XKNPPPKWHA44XxR!tdC0l{`evnO;g4M zej`zQ|3=DZEA
BRn!(*G`A?fhchy(`YBzEi`)9tzE=2>-kK|I_8^a@Qx(h%O;p z-z;<4J8O%!gMX?XbeS|w6;INT{RB-2{%dV<#z^>fs+i_YMEvO(4DFG@A|yc@iV>jtlt1V~iVN_^p34AqL_ zrGn(}KENbkN9}}w8rb{j7+20MDG-^yD=GfAHM|obY;k{889Z&y5-zNd&(|K`wIi&z zX`ue2|1ClyVGYg)Na?l5-QmFfsm<!U-yp0&6?{;Ge1C)sV%`tZK>P^G(@g-I}&uO72F(Ja0Wd=8qg9Kq+rxvVDr zMkcc7^L9yO$JE|=$G`uL-Mcl@0!V)Cs*-5V>q$?^TCmUE`_3%&cohvHqgc)i0LAv_<><+t@G>a}G)?kN6q z@srXYTbtL{S{z39N$GRp8FJwzg+}&pxtL_|2`zK*VDGvewFlJZt?#57<1=n4{9bD7 z5zS&zuDRbM%;nerDRl34b+LqrAk=+39kKQBPUfq*Y| znjdP2!S4=;a&oZUVqupZ|2ouOx^c-ab{sfwb$F}o4ZCL^(EOvm43D=H;v@8RIm-wd z)MmhGUdHcT;`knvPv?|;j}YE>J*BsNVdj`POFmDhe`yAdFStIK?NB1?i`s2DlXm6T zNLPf%)3(0%A0@voO*n6-TDDCa>7Q{+sa&C?jN}P_N#P2U`yKf!%>hfB=Zwl}6C^+0 zky`cTq#w{~q_|vrj~X}!=BUVRnLRExYd!54JDUnpPx)S~IG>ENVp*4Pd08fWq!m3^lmL%bDJU&G~B;HFLB8m(FwWyJCQW~9`SI^R<0I}Sov9W zg0F2ammHm&3?y&@y2@#=%7!OEO4)0SK}T?WL^e>0I4R(B){DALY~_#0_b&?;PWH`N zfk?lQ+0LFGOCPfS5cUbU>QL;_`$sepzvnK|k+%Y|2p3wruifi;48p6+yd+p!ihlmT zlQjsfNrrpN4=O-*3rOfAphlckt(apW1a5cNP^1+idX~bS0^#u}IG!P-M%5h&@;JoY z_Q&G!n_|Hb`ltZsU3@wZbA0)WeL_D0OL}bEWvf+?exiIXIBeQ&cBjw-Co>@+rvYw~nsA$*tUj?=MO1G7L@+uc^oVr_4uJCYQ+> z#q$v>trK?w`Sr$=8s4T@iSr($`j>s*+W+Vvd3jQP0$*FqSx{V@03^jGebfUvkv~H- z&&E>toxu1fH7jXGUC31Tm?~A?5XJZYbr~(EXkQr2XU;C?BLQ8mKcXoyHb(M~6e=8E zXlz3`VUHAEv!ku24bXu<&bjOB$>Ti3T}Pv_j+^*C!MdDnm9!dO4+uTC4W`HCcx7PW zV$YiHD>LX_4THrRfz%?0IqegA(jQdDMo`Eh{!9bG7pg{_kf6Vt645vlZGq@SIRPWp zj&Q5&$VL1_`Fvrt?uOolpYb^~TjCVg({6l!1$$bd+My(}_BSz9+BV$_{cZa!OAW3U zdg1#8alO}qMd`EW+iaIs1m^^6zWoc6ZzQbnI}!|k@y#(d$oK}S$1M-L`g zjo~A)+QvM|U>L(V?sQ>q0Niu<_M6d%4&Y8;cyOM&2HE=VjW%sxH7Yx&ebV^9i1gA- zTb%{B#_6mo1IEBLese|512JR=9v>3E`z9VFKQvjo%6s-X=+Nzv?^=z~;E*53`Dq5n zIqi#cRyL+K;`3-;=|bA$T|a-n>O{cQn6e|Bd@>?GN^b*3IIVBzSUP}9TVit?E(gLv zHA~?oFC~#*r1h9D{;BJDtFQ!aM@c=I6}#7~Lg6o=pEhnt0<1$98sl!`bz|M?`tJ+< z{lH6uU5iV;hm*BW#gS-~zX1Q=D5*auQ>h17PVImrhSWcbmxRsI6E>u>M%={!sPucd zX=lqY+JI#S_gLe6aGZa*?c2MyA{P{FNo%sD#rxrZRlVkGCwKFT<6B=v&RKt* zfYPX@3`O{CaKu|Kssg65Yi6#j$^$FZg+xtvsKcQ#ilm~S-;X$dLuGYT@k)4E>Eo74 zJvQjA_jsC?2d_7zeyN1Le>ak5KBxWI(Td}TB2o|^rRrl>s}wI-u5m?1a=-;1Z;AH1 zwagz>hO=DTurM0l7+h^J@h}PunswOs?RG+Pr1pOo&-A8Nd-e18WVfD8R$gnU1^RD& zt1)|}0%~tAf1jY~1JA6~nF^S20d}|jpPmmUzn}bF`=oM)`{-sbo zHH$mCi3fU8FQdLG7EHGOU$pL)NHCC}pG?*|oq3U8MpoXKb}dsr-G2Dc?2BPl3eGQ! zu9dfFdan!zr}HVsu9CM;T95zJaM2Y$;XAjPg6Z=^k-{{=AWLeEUbT8Ll>7X$gi6v4 z)_Xm?YW-A_Z2bOJ^|cd9p6=?g0{em3il>JR0bw^ZF7@vBYYw1%soqh$?ZvP)*Dx^f z-6ljQ_ztRc`E=-c1CGaRyqW{}y+`#?9#4U4GeBp|qIxqZ9$Y@UM|);D&i`C`{_N%1 zZc)hMb@cF&3#I_LT=LgM&kRE z(>?7X2qvB zr0|j=mo}g>Her0Tz-tzr(9gTRz7$7)~*=!79X$tUTc-DYQKpLS1*& z7Z}^KjM~FMvhoV?0uBsk$@|B8xGV#Bg^-mewfkTE&qN^SerQRgd73a(w2F27~^vYQBJ_R_8I}I9kwWbL^%O3*n4Lw)X|d_~-%I|d)$e!xIY`CdoU}I`hS)x{ zpw11S)xI<0Je#XM^JkS4_OfnEHb3Xn1WVdE_BHzJ&f@$0as8scTs>%%CDT|iVh#wo zKLxo`HcR{vk%KGcWPe0VE6%c?Zqskw%Z~5=NbRPEluZeUdBgb8joQ!DXZugePl`te zCt1Yqc87ZdZeDrAoi(d|Nw;>MSb7ZS9ZBVB-MXfh(9Pbrkn)kkbC)X@Hy)(Hnl(&Uu(OB=B}?O$vJ(tXTx=5F>j5{V zI}Ge=GoHm)vg4wW&pPt+M&+(IXU8A9g8f=Y1sz4oX33Xd;Sw7X3IRu-2YjWup8`nh z!(rh?AJAbstDKaN6xW#eW+il65-1Po={*jRCM!Rg8$0@RUlJ5~AY!P{LcSiP`sa&t zeOzun@>v}=it@jko>H9U#}kE{$LhS5z`LgUv%-EJ}%jay-(fU(Sq6IV3}3A@opFg?rY6-=HDUTPkz^rOFxcXTC!IkvhhT4Wmb@(y!*(g=;&wI{>@%8f8Goe-;&Bla-et)}N)+$#?FOW+B&B^&& zXv7?6+1JT@bULLh3a*J#YwYR${dtyLd{P@5@1AX12Q<6bGK1<}X4w}PU!iN3q5?Fj zu7__Pc{a;#Jvre3;Z#x+Q&8M2`+-!k3Q9VmK%-dYnq&R!@nLiOhJSMr z^DH^S{@|e{d-HrHK{Pz_{Kz>YDA^ao6Gg2A*S_^iULL+55`5&~z^xl*ii!C5^_E7h zbGwrjGN&A&&oli?&`E&`#9x;&qq|L?{5=UFM?mE-oSl<3_}=s4#7pgzZhTH5_{hQI z(>E03B%)D#Mm45~d^sHuPAX@#ZKm>}zaH{eiceeV43`32{5{1;q}2tkVXW8SO~mhI zN##j#FGWAC?+NzEUO9~<7q^xP(of1yiaRWna^#x9d4oOUsvV6&Uh|fZTX0V>bd5K% zAG*{&S7%Q)!b$Cs;#ZUH&3hPe9{m~1cWUFr?0MTK)i-9ldNW%(`8)$D|KG(s_}RI2 zZs7X9jIFdOsUW`}Cbc)V?R}x3tvq~s;p35`4_)AUxdRsxHh9cCex&+-756VP54?X; z7ui!wDS7rL+6&>)^>hnTwedOsrBAvk?U{Y3eH=RzdmD!FzI`C*@l5oV^+89(5^S1PQxQIw{C^s~i#1uUbp`qJF2lw?qskyIR6mtE6}gcK9MAu>`ux|Q z|Je~pjCL1^`=o*TD`_+qf2fN#7sYv}CJTPCO}kAJiKUCtN4hu>+D>W$qR?(oKe0kwZ;FlVIwx7m3D zQn^2cs|$X;uvuYtUguBg&&a$sH2m{BCWO9&vQlRoG!qbB6fawPaJM4N)qWH6Auk^q zOI+LUr>+I<3ukr@MQfqB5b~t(eqW%5}-vY9F{PpT7Dq=JdJ)FHW zPjVl5eWd!C+Z+d|)pj9yQhH4=jmmh3B`1wO^g+a!jGyJ}A9~95|I3dN%{sZYTVwW{Hw$UY|dokyl`yR3)5{9VbJp9-Hmp9xLru) z{uI8@YT~YKS~c?bPw6G}WHE>A<`M}&`bWD<_Vifbb2NM2G&E+x{bOC{-q2k8x%)k# z|L?-HWmJ=iI{5tgclD9VXWXLpWH}}Uj%o|i@Y{HSL@v33)K#)D-}&Q_dJq6#EeLvg z>*-MQM6^hz;iq&6%V|u`2G9TeOp`ztvLzJXJME!(Cn;*?1gJMs)?Ifugn?`i7YZN6 z?~GDXQzW_lR-yJF_4oRQgso27xB+Q7IXokSwm+NR7U`Q@rp?Q`Cm6{)54$~mZ)gaB zy-AyGzqoOMIc(^@Xs61A?>$3Cj>tS1$MePd3#V_Ny`P5s)u2k58l0Yg z{6_~TLoXc1d74iZ>rc0Bv_$&qnOpAA9N>eE6&0s*aw5(&(=XH2KS(b1k(CDHWd>oM3R$KS;`)smJS2O!$Ges7W9_P^rTK+285c&2)DQu0Oll4~kcO_J~fZ z?^kir)dCJ{4e*_5s`~LEMHcv~Ld@f!OI6mO-=*^DbHUFwza{PWH zyZTS@450I1gkWOyf?K$MkdynT%46=urS_HL{EY8$*O84Zf3E$f_sAb^lE&>_G1!uw zZ;98Rf2v;d)!XNK^5q$Bji2lK&NBZ^67T^#rZZ- zntEZn;K<_3us_%zyn9jFC+5LF@5%G7 z4X&LnU&nrhE`DSm489{aG(43Eq-cQY;}an;@4|vy`K9Fh4XGc08uywQ3g9rr`-VTQ zpU`)FKAq5qYkgkH&;$nphUBm3@1!TAc==8GON)%FllA+^sI9(49d7@&>;mWT z(|cf|#iZwWtv^_Dc{K8+iYhdq8KsE}3I@siO3>mNKCkc0^b@@^ph{N%2misPVimLB ztrBra5HEWuCvN}?54pTPJiZsY?HV3ziH-&0`#ziHd2EN_9KK`BJr3|rVb3|Q(U8>`T;^lMtw3xeT@X)AS znan2*(Ep%~ZvE0Cc<;#dqDEI4IQ623+awa#i}0gwczdzR#hd0E$!i{l0?^L_$yF@i# zvhgFebNKnPP2!RBKmUd7kj**FZwYyszil{S=?!P3tUU*7lu`X9-c_wW?1|S=Eyjv9 zdwzZwo765jeCkMhtW7}$eutef9DU|$=dNFxj z`>|Y}4j}AA)Vxqsenx(O!J(KOEok8Y5r`+=JyD+=ks<^8W#Qn)`6as)JLGIb0H4S-r3FZD*rM1cMT(}X5z ze4gM7-kLZap8!_&de@FTA-3#$~x}flF1#Ari6s(nRChTy#67n}xDZUp<;`#43(^jqS)#TS}LXLnh z4_n*czs>PQ&^t)YXC#)0lIz*kKFcw8Q$6MB9Z z#&u@IDax^egFkX4Liv?}ROkW!mCShlSsJ0CCc-QWuf?y9%WScN^65&im?8|x##1gZ z>SbEJz`XqvvR%|9@*VFlj{3f3>Tpp)`lGTaVyJTQ{G+rEs;M5w`0v)6kgL)X-E?h1 zF!FbeQHpKMO;3c=xxCklN-0M;AxFRmM|t0#We-Cq=cse!^7P{}G833K|w zk%b-bS~Z>zrnbL-^(4# zpg($w5AQE8l&9x=KS@FLEKg6rW8J+S)cF=&+Q>h9pW~kzP8GH%3Hd8={Pe18;&>mp zaoDwkGY;=ZiFy%`eSt7#PX?BkOthluH?~Cjw96KVN`~_we|q=VtS+vLM!0BZmxVMP z-gj>*;dJ>@o{8w4k>_3n8zv!~uuDL0b!B$_XLvsl5S#wnkLGY1->~g#B(VWu) z1U=s|b3E?ZCWI5^MAsykQ{G_;?*vtO! zhWP#4D!Phu!h1KjfvK7v>8n)pe|%6~4dn3!-;vm9 z0Sw-%f2tZ6Ms!vto>AWODhL;+v|ATa)P?*Tes_M?S92R=Z^1exO8dGZ#Lrf6m6Gwc z1ELSVo>}3bB8ccp?~mVXT!63dw>BYpQhrkW!iGnJZa?sR zcJOU?-*_g@|LQtf%-mdq-;>Y|7%p9Y9@~p*xF}%q$rSnj^ka|oB}GSsKYs1|fnyV% ze=>tsUn`|ALH_X-uk_`o?;Az9p?MDZb{`%ZC){%E;cjjMsrXuH*ak2sQQ3w*%au=KdU_>XiPoI8|;}kufe^_5#?NU6UNtT~f zzT2DSJ-s@fUksJIwme$shU!DgPl`9(P{^3<$MWwl=ol_^#Oog^KPg_f@E%X=QE%k$ z7=Q0!kC(XpNclgMKpz7{$T=&H3@u)6;4tYDoXrhNu0(3;B_LQh&(dOjD(`tQ(WSs%2gN zr~M4T_{o}(hV0qfq$&|lU#cH?{^ ztZQpGa^D|6XFVExUAvhL@c1||EwDn<(H6ynoL!}%#_b=ea{92BmMNUeEcsaE?jd!vqt_{Z`}N}PYKWWr8n9&?lGw$dR0_^CFLZZ4_njY zr5Z2Jem6qSf6{WcaK_JXX+z;*VO`6@BlF*9k?NT*?l~MgmVRFx`V3G%Pr4uoCzvB% z@G|1{h1YW5lztK3&yecn7T4+N4B3L>?QAt%>z;x86Dj{(xa0FD%;#%zk-oY3|1N#4 zY5xyyD?HwPWR7=P#o_h~vU<5KB5oUMZ&G`q6rP=7nm8|A75QBJ>{Gm-Qtsibz4F{2 z$-5oD5Fn*df$CRF)g^6}gXb6eQ<7D2w)3z5REy&~o({afept`dc&20i@(0%L96efK zjQl%$Iq=W$c=Bif%X`KXp4 zu{Z?jCyn2Vafx0hDcsL_Uq;^9DnXu~R6ZLP4`?#t`InSF7j8deMX#h{0(@+z-@kq^ zd)>bAp1I=KXb5~!`$FMxu_AaFd($n^AR27YIJMQh8IKF)%Q4i~z45-4u)F#rlTG5j zAb3nJF3jheB#P^!UdGg%GD)&=A>_ph*JR&h$%kHbpWI`W@VjGoq%8>{I4EIWX+zfN&NraB$z| z0E9<;Rphq+nK#-Z(T6iJssCi%wG5P`zD?0WJ17#yqxFlR5D1u zS}*XM+*$1Z(<_ITHrGodeQ)J%)`)20{^AO6EPMTy8_}Qhe=*DOo`0Ut>4S;~7tl!Dt z&X*8+$-zmcB%yU-cpckqyx{Y974v!f-_1J7RGtJ6yra*%v= zb>gb(NoRx;eh^Ul>R4ae!uhv<#N@L5*(^9;{o2#RQ+Z)9@{iC2iU^ z6&I=(e~Bb(pOE`g@H^i%o&A}f$lg6)em?aVbLXuH`yY<&r4o}>Lh_|yAI8%f@p+8k zBL`n51{()G$LGVXt9kt4IlK=fCr6Y=IkfM*(2d9Is_ZWenjNvoAA*k@l$hET?hKqD zLHDw}+g7sGW8U&d_wPMPlVF1QGr2GMQ#IxzeS}_eP;KFdWUrgIf$% z_ovETHf*&|aY_DddnRVom3pZ-enL_^+{s2Ny7Xz=}ffu8G1OI0eQ|K_VN@@3EP_>ha& zjAYI=MRcn7*H~6M;rWKtUs61tl8f)eRcj4prF!ipx0*PQWm*l%pm!QQ+tI3pCo9I>xx6Rt4?e4^oOyTWwg| zW`_}|mGs=pHXI02A9sH#8TNo*=srm~7np(L*(IW(w8>!4hM=&qXWrmuX7WXY(;nae z%V=4UqB%@d-XXdt#{&#}?Ty}(6a>tZ3fBIJ_=fVN^bh#6MuTRL^OA1(qmkYhsxh2j zBs?(xd;t;HBl`qPxwLn}fD;-qF*oPGuO#GC`r=eL46LEXBl$vh5xg%X=!TbBj;}F^ z1o;o^c0TRY0HZInMN+p0p!U62vZ=}Th6hMnm{!%V5CM3U*zhaA>No7m$evz@Gez{a{GL%{49dzj47R2|`qBmga4qG4- zf0JpOs5`RH6(;o|~iCNUF3ff<_BvoIJ|Je_OR^*HupnkAysn+6~Yw);?*y2aWFPe`VmYn2_iz@d2F|8gj3 zYUsb`=Rf;gE@AWNK`Z6#^11AgqxU}KuaS#zLiO`J+Ta~=8`o$3O>gGG5U84NN)w+lrMh9T7q9d5+5eAN_ervDt!R^;p z|Hg1xkOT7X=`rzyy+`o;M(81+lsapfyJ0r+k0>Vx{bg^mX?ROQ-S-8j?q!*P65A|k z2@QQDfA*aKZO3J7{}ji@JCoFkh)(r2;PBHu_`cGuOJ1^k58iK)+NEyXR#yS=`P2wZ zO{T9(K>Es7OFrxV><9?CRGn7$SazIW;apd0QLi0{_&sEWPit-1i*U7?9~=5Q@qA0@ zAz=Mu^)6TW62#x;w=Puwu`!@CyXE#x*$Z&mF>v-M;{N}pbgKOOO1%H{d+|m5sjC)} zSL*IxuRU#!a9T~t;nS5kK7?IzFl3?8qBmN8NWM_#W2j~y-rr@MYi)0;um|Mi|86-) z%mIY}ppW#A+z;t}u|x;?L#m(hhPL<3FSx(+ERy}UamiLBPs%?RPR+nqWJ8bJTfvWI zrSk!NKB#ZwmS#SK?`i6qi)Xq_=YPJO%l_l67x$}ZZBTt9Juek&>*4lT;!^+W#3EovL1+j!sPKSn{3HIDi3t<+#R zEQ0BC*(0Tg@HYw@2pA*#TRLo)?-0S`y|U+|!+IN>A0B<55%*O*1hw~Pm-2+QLwLTk z&y@W6zr6T7LTZl`?|3h}<-$#To;txO$i)|i&o9F1JYPjB@c0vc#P*`*bSCnbRG(%T zPfell|K`0MiGXLM*F?7wj_12fvCwcBnpMw#8SXDjf zCL3V^y2UOvJ|EYGCriy*o^{}HO4uWXp&7@;4G+45mrTdHw>=I6X)XFP3F|}Pxwx%S zdA{^y>rLosV4=*2stpE=_cQt@e9d4Wyw?`f=L&K^M8s!1&Rzd2Gp(PG9K!XK;a0Wi zzvB#!YHV}NnZf&Q!X7D%ccQiS@abu;5>E}kC?yF$(Z`+0`IrFZICY`OUED88fJn+o%Q3&U_nKx{`eV50C1~ zWlb>zlwHAwK~omcHIF^;UV|_EOmos8?dNx0!_7we{KnmoUxa_8@Z^k%J;l%an|MYE z%k<6^5MV?{(Rx@FH0$X-7-PWmB&i&M$2gDYW;x-0O7D1eso4O|9};}zp#Irrsm<(; z&}B%vcIJZzC|C`Q$q#(xr@JITo}H=-21 z{~^o8{aAsV98n&td*sTJ3)_HAZvcJZWi`}acK>MFcE>sh;RHV^WN?~#&0=1R;;gW6 zPdRgdF<{ja*JPZUd;VarQr_@bLk=Dmv)#FBSu}jb?49#qc@Sil_hK(}i=5MbT$_Bh zxQQO9GM;HNtOx}%_f0xzvQl8I;pa7_0eZ+UQojk@&s(^kcZ&@OTO$9cusarv(x=lvDu;~^)1Wg@_O3J()R?nK#{$9zcf_P0s z;CuS(>Pu~bAirhectea0JJ|Ic@bpKb1(9PVoJo3h662)g(u_ z!A@GnY&~U1XvJQj@ZHS z80@)zDYw{75Ae0V4P+WI2Bdud6izn~$bV->bIyAHQ}+MedUpx6^4#6dhU%Y_ENU^b z8pmU`mtceN7Tm7fqQ&nzUdE&Lsa9Q?B0d;_@L#QupL8E>@43ZD{^{%JCojTq{y|Au zWQS`sKHq2To>tt?Mn1pttM)y+z3MM6$MyR(H4^cr)J5 z|EfJw{tGc#Pd;a^MfE48ulD545@slXq~(7Kr{3%)MMK!l?cihuH)DwD3ObPuU@-XCZ4w19*b>LHi;fd$Yt^qIl=(>5Jbs zA-{f+(>vdCr<#Hf0kP1!9iB8gFEct5>d;Ug-3kmlxG(mOYIfXf@gKRWT!onp!!4t3(wE* z5Sl-H%<$CbExI3^v%b_OOE+ALgc*CIMP8}me9@e8|6vTrJOxSPLO)ctUWkf7 z{qtAdkIG@PRMo=wDYos+z7{`!-}ud$e<6vEdApF)&yH|IVV z&E?mB3jJG4;WwdZGgSZOFkM~uga(wNWLiVpX#;-M&kwhEI+=Cid^Y>X$vUkeKiIjG zSK(QMI*|RGrYW#S3nc7l7YuRr1A#08OGj10!CQ_>3#(&xz(g#8A=tna*;N$1t95o+ z0HmK@r_xgy2Q>A|SY*QZfqdoNME6KLaPh0ea;^#d|4^fQ8tF}^jF3EATHP5ow-7*` z&u~z_BN$rxOvRm2HwA{FCf!qt0l?qv_TcbSQ>d?Z`|SR)2%yotLg0g&8!StH;#V9R z35JC^KNl9~!ROVnv{n1F;KR2*WB!&nA8o=JQo6~^3h2L1uKKp$8yXH2?adFx{WHAj z!&{9{;h^QYSWxrr9e}U%=6Hx{6zp5GH?N&43@j?$A-ID!7`o}moz_cLhDT|#$NSGN zM(x2CqN;nc44=c2jV^U`T)_9Cbn6Sxnyn6lpI z=CCOM*ab7J4E}fs9&gZ~k?#AVAT1RjSKWx)XSbH#Oxj_8!0YiTQDj3Td~D*7)b-j7 z#Xn-mtw=G$7E)F&k#u>h2Q<@Z8AcY+(Qw&8E$MIkW^|5l^H+vP)pvtIn_6MSuI(=1xnihD+bKh2$D_W}^2%vf zz~4R-v|Zj2)&E0^E_`&x3hvd9%h(>K2!^7T&b;O11(w%a?S8OngPBln#j40{fJI87 z`fc@MxHqTh;KvLLcrw`<1hr_|M((AHzCZS+v z7uS!#$Vkv_Fd6+rb{kCY4;D~Oc7@w0zX(KJ;D%E1Rj!nLp^zfoLq(y&1x&fTd);+o zHKe!L;9Q;mu~{;u(^oLi2!OC8` z4Se}X!Bc+H4cOCl#l4YpLi~loH_8GQ<)QYOYkpbjdW2t43+I<_z7+G@7ws}{d8^Z( z&J~tn`JAcLoAkNvNdB_ahhp(Br3feWrSjc zMe>K83O31@nxJ^irLQ7jndZnNU&OyPX?-i-5T5_$lJCu2#k@M&2+13UZA&un#P@r1 z$#Y$}7kUQZ{GDpob>7}Hcz!08XIqoTExiZNOQiH)#gm?{brBl){r)uk_RdX%9j++; zziOXp__O#6V}sw8ucf{0&$Z7G#sA=A*SCSR^Y@4A?iP-%r&W=CYAb8TC0-t&<$%Ky zp4hqL?DkD{(~HYt$bQne)1sZrsld7E0G`LAbN8cm=RGOwQZ>QTv_q~wyY|8Y)n)7K z`8~kRHRe+fpLoFHp`I;*CvZN=w6~tYLCX+`l9l*@~W<5S%k;db=Q1msDG=bbF=-L^SvT zb?$@Ewme%nUb$MSV`d*b#Ck&NjUf+;FKOHi7O&6B)TTl7cTe_{XJWN^Ho^(Me+n+~4ed+4lwxE1G#Z#W+q3apy@UBRFB+ zwo#68*fTk!@i!46sR{3tAb+k}+lWi8PyiRM7R{{E5e2=*=AnXu;vm~(o3(TMKFG9s zrRl*`y#LE~WH^1lUjxa%Pc^;HZ=(d8O!=SKhQ>n43!~4ejRcUrx$2i&a!RZ&AOY!{ zi=UMK_|>Ut0Z#lLBTL{r=c3%AdFu;)e!HA6Jql3OwlKexa)Dn;nm@!IOqjR-le>$9 zwq}GP{wB(G0>|R#e)mJvBcUh$BK=GJ|7Ahyf=7h3Jdr+vj~tv?lw88F-Wc(3FyK~H zn+ZfXIXR-7qwDchvmjnyiSqfv>@>e7;Hr-7eJgliCzR=i@cHWbpUTI?S{f)dB=Otv z>Jm+92~bdnzZ*Yhy{feIv-ti+?#|Je&9?&P?T@Qk^Z7UIcz$~wxS=NF{@m{_f7jpt z%Kfhqc)sYCKtg zk7-+B&lUm?UTnHC;t>RXTyjxp{a{s=iT{f zXNl+dMczE8HcmF`#14FfctXU^F4<2(&vzp+NNS6vV1 z--vPoauqMI6pQfy1WpR?GFd1jgyQ{8rh(qE6AIy=XKgOMU}+Mh+YqU&w=M)~`Cn(4 za9arY3+}gev1G&b%2DYxZF8R!!fiTaYwzOyPwkUQ2HjD7-$3dQDPB%}%{5Kqe{R08 zwC1aQ{EIlU?ME7~x$vMv+a26iWT5e9euSfK%SR`8siNkqt9c42(v4p9zSkDqu+rO@ zxZMW2iWEP2FK-IB*@bdEQAz;3JLSKNoWk$%N*=5M8KclY# z!j-}vJ)P76%O9MU@AejgUm2@q?tB0G`2WN;JaqTTpNqp>aU$r&Nm;{-!|{IUZmcfV zr?GMrKZ1`OY>pLI$hUDv?H7G0vU-^}zF*yOU^wl3+ZIG8r-vwCa_9-=(uu@*^ZT=M z4;Su9MgE$LH!ZnPirc^0Y?05&H;Ra!5L}=}D}c`@irb7GGS=h!Y38Lj+@&nE;N2|G zgO+p=z~p>(%MSK*B%dv)%WLF~_otQYri?dz&Q zI4s9?`4H#)&$WczB{mFzoyr)rZ!-`tdWYW?5%jrW5tANoqX-_)bMceXCmI62eEa$R zm|*qoW_m#~Ky1_c?#LJay8JES54*v)%uB~{9)&3Xr{T)2#f}AUbD;W}zy#;Nd{=3{ zcDrWfGA79N8dTt{Bj&A_lt)HZ}04L4 zjP`tH97+EF3o3Z!c7VgQaJx}E&qbq6Q0_}%|Lsyd-#KtNO6|=y1B5@M@MMhY&BC!A zWc@pKe`>?SH(_Mug=GU4FWFZ{R$g|>oZh;beEcMy4IIDdLH_+u+wP5n5~s<}D`CBK zTYB@2;jng%^hx&!zysnVOnH+9eU?E%tW}iC<{^Q+Sm$Fjg^`9swpquX~mp!*P;(;>pT?WY$!2sYrs=ehzCm4b5l8 zsiSn;#rFo}`}cRML5c@lmSpw6u5Ejqbe8;n%WB;N+0~rdAZXuiFF&tDuxgQ_xLUXx z9N42$ey7$1$oN|N)rs>XzkIuIm5#?8Ksf8MS1)U`ji7{%$9>(v0C?z8O2;l2dyuVj z>+`AQ(V*uwz48|3M93WQCdswH9z<;zl6n5I7<$j#(WK7a51UNHO{8+UaOZptteebJ1uA_-0!~!Tw$?cs+lqcJ9ybz01fzllrHg;Je)! zSF^Yvu#CQ<<(;GxxLdDR_n;k^#pucvC>{0frkInX6&q4#sC?Z$lxcjEhWeQ50zP ztV-DvlMAbbwX(`@;d2Nfzjpfo-AIK8yn21pn{!Fq!GqUdlN+W}fzFbLqNc-n(0f3) zFV;E<{HQ;vdwE74`ANugD|z2Z%Swk=?(k%L-?akk>I42C_TD-!tL1wb6%+*olu}9s z1QnGMkY?!a?(XjH6a}P1KtKtx03}pn6eJZ35m6KrL`6aY0~EM?KJVRopZ9t0^TBhz z=ls6sz4!Xha?R>BvuD=KZ1&^1m0{z3JtF^gQD1?BNBpS`)utgP!GD71fUnNox zL-`V5Xw?C^L8eH!?Iml?rMK99l5|KYW2ucHbkYlm>`(Ip2I?LZ<&n~0*NN>}UxQuY zVyB~gPGLBVsoC>7;sRY$33t%Ppf{Sz=m(v} zjz+wD?+GG1qKmCw+kw*oHowX~q(Pa{VhK1Lhsskww<{A}hyq?lO{P@Of}ylP(ff2( z8I+FLL7*PB;l)+U2)L?FR%W4+04UN;+}zE4>6AnmQA9Xr}w08_LgXU0GX zDB@%0QlP=+YxfKKKcZQ%c~76Zaq8{USl}yqeeU&|E=;|_&c|2k0O%+$melUT_GO6u zB7n_HF)$Wy0&n#UIX<3esLMl)ii0uW?7{oh4!NYQ_+TIAvBY zv6_P66Hw#$j1&0sLipk9pY~ulJ!*>eu`*B+eMBa|*$3)I+|Iq;=M6tTy(@Q9LIKX- z@g8{4p$BD;kX(8#q7UQ5SIiu?WB1#L-w13C2~64%8VH2B^S`~f^MMN83hXr89AHQM zkHM?jvat2R<9;zqePAbZ-?n3uJbbPlV;0mD0{gz~E?(`i0Wq&ca^0icVa=wn3t8*$ zlV@pb#WQQK*FW5JW)5*a8|IHXKO;s98iCt4LylPpeQOi_;LhoNg%ZAzxoIDLMjL!N z8F+=FN(FYBx-cE_^g#86dY9}w8lM77XNHCohL3@onsjmo0bjrtmi%I|Nf2CYDHc*G z(1kVnGbFB3HbA62**(Pte;q5D2;E{ga~R(DIPPWX=m98)9xe}(seqyKw}&0O@W%_1 zHv~4Gs&x1@?E$tE{TM0#;0iBKi_P805P-sh_D8s%+Wp3Om0I1f}Ci=f&+gplZL+h1M!Fz;g5J9QRINXb>p0Wz^3HO02AO z$Ym>n*3_Ft!$O*1A3uNKW!(a_Uye0pQJxUAgQNk};bd73fXn<*F1rv5fZ7yIOVze0 z9}<6fKNn+;h6viOb~C!C%-tIGv$92*N?48&rK{__>X18#ohKoB2to1mTiFM_F?|or zJrZIHuzE}PannbEJZ%2L)l+Uv=b@LZjoPm}{M=5Qm+4v|A3s9wI*_{a~ncO*Sm`?9Dr>JP$4 z2r8F{kE_J;q4I?I2(|AB&Ss$ywf}8Efl9k`?oJQZJwFi2JJ6f5AO3Rw^&j9*Z}jQhB|g$^U`Er0nk8ho_?8uoSuC54khYp4)CaSGqL(^!BD6*Q66_7m<5LX?uc6U0vI|(mtgl` z6Ke(fSh5M>^~2k;%YpLWG+|BDe3LyLz8^un%-VCdq_9WgS7^Ei{{74~QZv4n;<1p8 za(tNjp%$M0^!wuvE2wk`#y8=WAZXj*2XAa0pfBHyUzbJPIVU0SD-XX)x?ghNPxu@H z$=ClhbYL>6^_p`6C7V|3m6&zF)7Y2KoFlL}`k5IMb5CODWshQNzu$7io`;{hD>aJD zgzKvSg>&Z3R2F!7MdEZV7k^8};{xceK0jReHXPh2Qe*WV#Oz)E*}PQs$Qj(#oUdSC zHifN4n!>Vl)~LSyU-NEj>S=)VAI2p03O1nsoW)lXGh~5@%YU7coKmMR=*;0|3Ca+86XO?KDS&Y27hX69)14>zb^1P9bG(f+y*bN zNdERcVY@>7#1l4km5JY-j>O|jEd`ejT(-h%N8~prFDEKQhJ(7brUFy%Ab_+ZaBZG{ z#)2QKpOAJ0ZVN~yEqkGkCx_5USdW|U&A>nRLg@c(=wC}o_q<4-V1738@6^?fbcHN! zg7NG0dZvC~Ki?*#C%VLc&n{j)Kr7ZWw5KEl8fi0AW{jtz{)}@z$RS$)K6MWJksJ3L z_|bMm-$+^Go`f_Xz^F2`?VCzCo*n*o1u|Pw^zhmd`Bml3+ji6$f}Zxt4`Gk(@c6!2 z1itSKuz;KCDrikg@Xt>resu;891jC)irl9QOtF1EqW@7k(cQR{F5uD4D-U?53Ex8@ z{DdGol|rLw8UAxndn_oJRM{PNcyhjF9E`=w52BBKb&o~=`n-qW2#nY=JFfZN9~4#? zijiKz&r{M=-PfcKO3?cid;Jv!{Ba!kRjBVv^IDs3y~*Zbx(HN%o?7$pE4>IPsMx*c zxC6kIl7dZ)VZ0zKcEn3F1l!NHXv98x``I3}mh`1%x0~bnZ(^91nl@nzsf%`eS+NMi zqw~%`GFvb^3&o?4u@ov}=g3HWTJ|6H-t4h~%05d4H18x){}4LK#IuhAEjGaC#@Dd; zSGsug$DE9n<4UIBL|#ZEeFWw=BLCoK`}pxm1IS57C*#C%7|*|I=B1`L0w&O{j6Ih@ zBLGi7qKAHVflE#bfBp-!j6P+y-v$Q$=v%z-hu^tJwt#ZYddURN9ZZux6f$sC_|Y%2F*2fY@dbD`M*~k$uTp7^QR*&)J@>mFJfaUO=?Op zAY5Lk_!1G;|A;*A`e$PKYI5Murq4$kCM@y%)TaK$lCZ-Xlu*Z}wOmQSqf-`0-nlPe z0goDq6nq2t^9aq)6D6-M#Y5)%ORsXy0sM2T$x|vG+n)!* zEwc>m3ktHR{i_bVn_Phd{B+O@4%Baf=bY~C;z+>u{YZR=oVKej3cG@j=7uLP|0KN5 zPmZqEKvw+yKBC8nA*1l&cl`P5x@4w>>Zl=P^RjS`+Kr!|$k52iT?)al*U-99S+)Z8 zAJKd0^uC7c8DUVSw)>{!Tl{s*{x;*m;g7z+X9S%YQD*|*`aq&9i|my=}RP|)V*jF1y#OH(Eqt4n|s z`qZ5Vi}T=z>1Y$H_3s@n${r`u1ekyCA4`%*1ADmnSmGgCCnc?~8a+@W@ zWjGj8I?QJHhvM(QJ}y+49B9>nr@3wHpYp5V$+LDQw$+^qfd>+1xJ}z*@ZuY2kRQs9 zRfBd<&v$R@JB}xhBnNV>ayR>ud=zhCBUS#%7}fa>nG#Ik4j=a^?U z?~Z@cCILG$<%{MWv+(raO44_Jyj=`xH@iw_mEqS3rZrtp3oNmI!}V|bEu-;dc0agm z<(YUE5}yBJIA;%@@rlB-A9z~LNWb0+eop-!qk9j3{+*z4unv8TKkp!Z%r>6ln|!4L zt{GMd+Z+qQ%TMEc!{}i?{JI^Hm$2IVKCDv)^{aQw+`|bTtUf{b{wn04D0&{(f#0ux zRo~w&mp$}3SoJiPx5DPaKd;d0lrMGX1Kfi#@yS7bYf#v-Ac;o2b=J(&_ zPr5OO@MrZDw7&}~rc|GGN2B=PW#_-8=g=Kzy3E6dppGp58cCoX{1`XG(;}OW#+SLX z_r2E1LlEpZX}BoK2L(6|Q1d;M1h2h%E*D!B!4E^{-%C!Eq5QaUbbG1Tj6AjkNPFnE z&ne=4*gT&PLBM&1Kb((b2QHky$&d0QatPd(sV5&@`}fU1-&meMEBK7RE+O`IYRHRP zCyGLudml3x{MYBn!*(w0^1P^jdWjV8R^17Y2Zaef+gE@1-l0lo(9I@gH&kAUg=t~u z_7aHL`?tX|?x$wNYZj=#4L^PNT`gln@qe4Yf7YLnZ$nElmr4odpXx)d>i1X!lpd%S zLwrL3zYhDecKqz_)}d@94d!Xv=hy9M)j;l>uw!za zE-;$9Jz*0k{yvJ3-?xNxHMn=G0lts5g@;S6;jQn7F1*}i4|u1Zh0NJIL9ZK|+NL%6 zp?Y&}!}nc*@VV;qozlx#{dL9XQKb=WB*^inA(f>&4V!|WJ68`Rp#BoFi?laRw&Yx# z#?Br5t-py*!Hr@;YizCrl@DL8V<{Vc~Lb~OHf%Fe%4?`$wd zh5nE-?0W3w`Ek({&4b0QS0B!I5kAkujmL07ZQqGQyr}%77S;W}7;N8&%a7m`$MV^4 zJaLAPkC!Qv_sfFfOCitWH|asIMzVVTY-3d4F{WOtv!}3g4omSi37J34(?B8^iapCi z`4RmHyvNv2USN9^kIw#!_p-PC`gg-MVt4fg2Lpt^=SifWUMyBOgl@46cXP$=Rc(I^ z7x-xseoh!dmz(!EZCs~}7kJ9o1(<-APmi`)x+kIjwMB3#-b?%abqw+Q&%$}y*Y6(M z8-TPV)l6sU7%)ks(INgx0OWc7@GJghi~93t^?uTuTblmDxS_m#T-0pr`upOfuQ-xB zqdZ_kuu|KcVhFq@Yj=XyA{6b9KWqPY`42wufCAg?K%i}3>Nbfm@Mf&;;AuC1KvOU` ze9Q*h2WKzVHd-bB{`2s=_DM9?Zj4fUgME{t6&(CwaQr!krJiRXplY_Dq>YUMH8apl zogo==38d$U662qbBX;r%lXe{r!00#Ki))YY7;l)ryp{8hR22My1hp`Kc*+~Lr`jMGTe`?5B=eHb43DMlV>Th_$Q6de|Tvv3Q`W8%e=-Z4Z=^jjBl;L?tgQm&g9-9 z-T3)8ap}nuEcXECYEzDXpfG?=EgGxe$&P{yuXMqgKc4HL8T;S=(?=DR|2o+ayygu6 zsie{%QBhr2saP9wn>_J-M3#!??|+xVuxCL(hI&}N;i>sCN*P%T80D9!x@cqvViKPQ z-#g<4J5)H1e(qs{yROFZN{;(L9R|a2mh)I1mQDQ`HYvq{*2eiWzU%MVunQ&1-`*b% zE;X~Q{WP*h?b8N+(Az~Az zVM}{xMqGkm(No)Txr=gO;st|{aXYqe$1?iA{-@wju>0XBH8lQr1&o?o+6__MM%Db> zbbUOkA6Fj1Z~UY^B5unF$xgQ~&?YLN@_WA=Fn`s9?c=CiJB!s{xuA4jnOZjHL0c5p z8kDxuDRxKgm8+4F=DS;=IARZhuchoZNmF3?M%oeh@@q#2{W3PcBkc(E=6zWzzdr9G zI4;~8O7C)q4I3Z0^goL~q;t~LQpEOwk=upYUNsp*0rz+NZO)0pcgw$wy+7N+$mfs6 ze{!k7VXF4(RYP?&Ke+a&xAWKsa%7_Z3+-Qt_^FD$$6Pe7O#G|~JD0_k=YO`zq39ge zAGq|7Arzy<9@?mWTzch=f~wU+*nM-VvEbp8(mJTWDGaSGmcjV<)rg+ZkcHkbk&~$Y z*?`Z@?&O%i_bGntc5$ad>4+QxOHQ>czcRgr6_kSdTQHwLpULzKUQz!%DdLpO>-W_o_|+Snk6PTY=2QIm)3OPx$*v!)PCOg-U$?);p|`?r zz|dV~`LrlDpN2*o=*T+|KIghoQAW;HYeP`~&$rKie%|N(x5e)q9X@}W${dWSe0E;# zaD#&PW3K3^WBnWP#`h+7zc}ds7Sj&yS%dGB$@f#@#0mO$$SU~y!){-K{)zW=`j168 z5v1expT!eytp^v)QF|^ER(Z`+o+ysX|7UT1nV#)CUj%{t>IDnICozEi{+%~Y19qr? zGiGPOooi7jF1%LUk}^X0oV9l(h*L%C49bt|Px`nIn`6EMu(D^S{LZlc_qnfkoaz6? z2W6I0qnQGv!Bo$X#^)Q@ei&E&@5Y(lKTWebn2-7!cZ?%wq&X4A|89GTzVF3yAI&eh z!IJK-7xabN@Rv*v#||lXKu$?aMYg(e{KThDnI=m40{Zf&cGkVvdQwrzI5f2bTkjBi zxbQK(KHHbmrtlO|0nt#oE-(T@J_aYf zIDJrm5WoH@c(IZ_RnpiDepNfEImqh>DY~ub*ta|X+8=x52<*}C8l-z%N7mir!yC;w0Nzbp&SXjyO$Y_VFA zVAIHhWfHG6elU2!f65-B_eAh77N25Mpx^4|ai>%chW4+9@vYgxqzG4^ejaCn_8U_^ zJ$~<>2*O3KlH~w>pf!I)GS5{Pocus6w_JW4^0@4jintX*P(JyV=&`8DN5$* z1mbT#uPlu)1EWtGHPtQzz&Temj~dC1&iW^x|~;d|ACfgLAQlCgS@%@tUDaN{Ou zABjKmO7Y5%aSLF>Vx8i*{@oUj==5Iqnt>{)XL z`+`GWr;<59&A0;IxqjXrx^+Q$|UcQi@Jd09ZkOEq2_RSaq42wxG$VO?AJ1% zC=0fKj%!rw)P}n}2Pq?es(>%Fc^dL+z6Ve8keE7B`3Z>}p`JPH~0jG+um&&SZ1L~xGuauc=;gf1+{m0Q;fvLwQ zNc+wXh~0U(&v3yQfa?~6TMaT{&kM)fK%NDP9(j@eMKS}r?p^!Ykl_Itdb6f&GUcGw z%qZJD!? z;r%SG-4J`xbfykjG=JZWMU2S$vGW{Uexa9#Y~Ai+_W@fiuZCT0wg;=_VUhLD!BAfF z-9E)o15}^nl`(@W`>_2uuD(BuUkjOaIp>DG#}MnTLAo}F)wlcdtDjrznW6R_a(-Pg zS=a{;jJQ!`vj;#1<+q(zZo9)ZKH~MCth5I?{enKbhqX|C_54FdDZ9;J^qE9879M%P zp~ZE)r4CzP;tlut+kZ8Jh~Fa|8rd)JVfSivlldaaXP9WX-D9efyM?3 zF|7VhC$2aBHfj(2Y;*JC4U!>qYMGw7vIT62G2!W4358yr?}n~Zk)ZmV`mgTVvxfbB z18a@^oWVtDl-{{zevPaUe?9+FR`_IzB^9oc_v&bGl1KR;OZ6l~mj=SMkJUj^X_269 zN7>s?pLF3n_j-Om8U-NYB5QJTMj9gi|EF-zja)X-GuVEa$f@Pp`xevn@4pex$h}N~ z1(nuip@m#Pm*1Vwc{T=IaFXKF)saE%4<`@G{0hOw!*}9tm+v~tXgm}Oy~6RoPNV!S zL)9?`E?B<#R$_b2W3lG}FLVep9b}ig*71@c=H;SL; z${cM)1a=-~QXnD-wX;$E%fq623_oK~f1Qh-h|P{+=SBTXD(bb4hA3TiI>Gcr`biYu z$o@uj$%!S) z*wwIpFaB5cZzSg-)Jx9gfz3O({AEz*jc=yYKW6{@uG!l1PuM($YyXRSH1B2AjqhP8 z%QT{n9U$C~xOx$Mk<{UQ?$>mJ{j0L40qo($>Jmim?}BIZ$T>emNC8HM@Rx3>Hjq7< zRDCdn9nE*v#8a0DDZ=YAdE%`8ITC%4$dC=rZNa}UeqW)hezqAKPq=;$D#m{FAIJ0y zzBXg^_rdghV+lDgxBgv6T)Dpsx63*)@a&QdJaggaE#FA|^=LsjRWe^i6+Q_Nxy!1K zo&O{Da|Og)=NrRd8mRIAT(j|cAtHxBBZX%{nPega`@h{~&tHWME~~PN zrZSMKI!#oQg&&B>6ZeqZ#@<6%$$P^}a$w_g01l6{6Ys7^|7ky!8S4%(J}0~mlJ)7j zZta!*(|Uh5AD$;y+16xRm)DA9|ymy@6Ym`_!YL+#fa_8aOHi* zZ;!N3|M__ZSI_U_du*OnGrKXMd5~gxp}-UAj^Z>caU7-(w7`{;5|F+A-F=Qn3ryFH zq|p4liqv13f1`l%hX+a7rRQV!uszddbQ*4K$A8@(i@fmqVF9auH zn=I`DKjaV>j5pW|JqP_{4$lrz3>1 zdI#|f7v^@g*InntUw0CIO#}o!(ZKT?SDp}F#X71*++d3OQ@PZ0-+BH08-(BXnm2oR z{7w|Fd%E}KMIY=vji|;Z$@+3OP_+M^yNuZ$sNH9B>(cr;5h5S&XnTA48YAlFNf&mzVp&lw=4kl=gz~Csh6qP zy&iozWs7JG)<2Jyes0;a{{3o1en3XwtfSuz#U1WeuUx9^f#1Fx9~r-m)lX&X zAC+agb`F(C`2Jh4_lgWnkUvgu<@b(SlE8zjU%e>M4g(r3$ZbnlL@(M!*d4uoweLL(<2Mtc3ZSHy;ef` z5dA+3KhZ5F+v2q&^diQ3^2k%6uqP>d%3L*Qph@k#Cn+Q4QG zI!=+g;LCen`M`E05^e9?dAF@0CjiCuuV^{mtHQdj4bKa=DLO4yYYO zej_-S30uD?oBU}xMDNssx0!jlE$a7fRiP^5BiMW*7uHI5RoDjAe=;?dcVYeezKHzr z#jJb$JDpK^q#c1*q$}#8K4IrzdL&#HRQVV#5V2IeDFCyN$o;n<=@|92`x9&(tf(T> z@UF$?Z(P4r9}cccQ(F>@pK>m5$l~;rS`+a!NBV z^hL)DuDp%VPGQkfb%OEn9qaT{y^4R1fUAcP{>eeJE|vqEzXL|J!JphBF%1v)knB~T-(%Jd0aZ0V197@^8F?wZ(>st}@KXS*x0Q!nBg7I%X zyRxW0O!)ay|Kcwup>9M_9`WO!f*;e`6jCBWNF&|L?}>9=L5geji)EI23|Xe|*5|-w&Gxo@VH|1H^7u zL~Az5nIP2P1OetJ;y1DV6~c!=-GZfYIXW+(^7s&`8CN8#2cdIWk&SHS#r9uFI|9#E zhA&bpVf7T!j=+lpcW)n5SpPlIF&Zz4n{jyhyN|!WL!FWjV^R-`vC6~%>a~|10oF07 z|CwE5Cq`;8xy>qy%eU^qN);~ zGYqi$n7B844@U#Ge|UYjU8}z<8r9ePwPJu*oN)cQb~S~W=xze)@1YOR?Qgqc``f!+ zo7|2&oP@;tE{s-MMu1nXKVv@MP(t~ySvMrB6zik7s%KB~%uVckTuLc${G*IN>Q5T) z;Rifjjwnu>&0MWml7z}1{V2-!{s2Z#XCNZCV8G4`l3>ZX!;E>T{FB?fZ?7t1&)?zD zV{MAGDk#0qsK{J_(-c^^Gw{+MZ~-x`JU)fBSiQ!0gummXVk~NZ$^TKR9~maDdxM7aPB~XBnAx7Ge3h==%4<5dPTWr`xil!cly>myhJCI2O-$b`cHN z3=H2Fy3@GZ1>4tdwrmErEHHMiIg8`@cTU7?Caxt3Mh&Kp9M|&4qYEuc zxE!VOf}6h!ERF`?*9nr|pFRR|Cy4lq3+>{!7ss5zuWJy#!$>Y z`&MD=e_!CSJQwi`fqLW0NtY`4 zP<=={0%PXb4N8f1QM#kvNQiQnA&T>;?34>TE)5X=>`uA#4_x*rJ+p>M(Pvr+nvkoj zUC0VT`4E2h&#!&NE(Cz8O-;l0CHVPKAAhq&F+v7U4w3I%a2%yFOhoM=?FbBKV$*0P z#vk_xosFrf?d9EYg8Cl>&R?#*PI!HDb7B}gEv-P%{>f*rQL|9Cj@bm+4#{O%PidhdN8s<&CbrMUB!F1VR{OGCb!A9nF8#b0N@&*%1~ zkhZ;P`XHzg#;B2C>kg8S6=EI9Grx==y}~dsI)Tkgst?M}JlO9IK39Bte7*B9L4Ti) zzNp$4eh%h}vuWgnW9u}cpUqFALPo&}jx0|vZBxSLNrb*Mwjv$-BVzscVU3x1(-KiT z2Q-s{IUag~wWE5TuAKp}^T(rxI}^lc{4G66!an^nuxv;4V(9(@z~GEg;ie)T$lo<6 zqOd6%1kTaUl66R;`ljn_4(+$b-jk>^uu8O9OouT|JJ~*+(gzuwwgYFqYXeVN ze&T5@JOL*xcqqUT{CX=;>P)53_5c{^uD+!q5`SG2*k^LtaZM6%XLVoad0T+yr|iwZ zbG2_eAl0PV%=&yZxOa6#DtEPVWhXv8HD}KLmTn#t)){6LnM_0DN96z8kcvrDm@z{K(q2AY#%|`0=ikxn z!%Ls(b%Fi?62qP`Y~E@kUTAbTkVW~gSL}26@F^ZIj}EMOif(gwH%ym(*t{i&wClqWXR}FX~pBpHpglfB-M;WX5~^{xu$-9osJ94bB{* zc=_ND_dbYzvN00sv2QMLc2)ImJ7oa)bTGM8%smn&)z~cawAljPu~#~$c8Nf`$zbLC z+9sg6z4=y5sW0$%=Xh3i(HZhPCySka;ss4!7T;dU#-E>JY*s=%;w53sX?GKQFKe(? z;UnJEZv;HMV|ga4ZQ*p(z_%F9B!Y1pDJ9*mZ&oBo-_$xlD^=kQ{b zjgtjHh^5CPO+Izd;rG3>@u(J%s#9HR-((6FiRhBAVAZ<2RzaUSR!!`19X_Q3vcobZJdNXJPf~;oC-V zi+BfDaH9?CuZp-Y#Xb2XU?Fut=Y6j+O#R&a!u|lZ-lQ)b3=!I7f{rgKrC7Ey4S?c@ zIfAM4#<99+r+HneK8S{rqB1H1J8Yo8Rpau6mNZmq+oych90pU)bwn;sD&@Qaqj^~OQP!#-j zP9{teR0Q09#OcWm*W&BUN>5<*ixlPFv6YjA?+G6~;oCn?j_rGK`{hul;EwC$5zw92 zgZ|A?V|aP%(|zq8La@H?Qp4SPX{ZJ5ic$;#ge%Gci`zq3fBL=xAbRngz!aIKvyh&kj&m3Rtvf8 zNK%u7ypu}X4{o1D&q==yG7LLVvO$-cw%N&2ACNGuIqA7RE>u`nB&@~o&ud7{jbpFw zD}bb20na~AB|)JZH@J7UxPp{zZ(fgI08qff&*N?p;eCR;^ryj~?I+QA9we%WM(gVW zQOnh5sp0mJ>+6To(!1Ia=@&Cj_E{xUtlrswWz({YR5~i3pn1w`=b#g$yC>(v^Dq?L zaKFOTN`}q1b3xi!yzGuBztP$Owcab|QT?5t*X}E52ZQdQ_m!rnlwq3lti11e?D<9f zK;UsRC%wMFN>mfOiX(avnEzGi+SMDu@Z!rU`s1k~&@Z1zbxPI_o^AHBNG+#^MOFE$z4WoDf7Fi^ z9AQa5r*k#|6Ttvu4~bb&Ht*5b+>qI zvtikzX!5S^1Q=oq`F^n~0Lh@2T~9USU`5>I*heD{RK9DA=H%ViC%~rMDaRBsW5BU_ z|Ne%4S!i55U^8x&0k1T3Cdy@E`=boU zJ+@y`WD)P9s67nkW=jvT_F2HeWY0&mww|bcL=J&@yaDjr8^Y&I2;WA~X!o?`YQ8Bb z^>ex|^F9SCT}p4Zc!<^Ueiu#0nQsVz73E$wJq$0lg@zU^vtEWvQYt($ z&qCHKf{o%E6G_?=dRGJG>w<=tlgsQ-Jj8R%t6$v~#c}oRnzZx=s%8Lb|0?64J*f5j z`?wwL7dIVg!p6-h3ohoAcm|Ec}ntUc?0(5Zy#$JO^SeR;CwEgMR|t?RNwA&Bt2hAY1j&iC$J zNMtQm*WmI?OxZr&C9n;(?=(-W-OtYkaQXf!e$3OFDxhy4L4E9EtX~vjqX^Rfs@>m} zW2lxjlsK(}#$TGbJ>c0}Clvo(J%srDloCjPjrkFbkL`6uy@k2#-)4`HAB5V2a+Q|g zO?%Y8lKqDouW!NjorL86u6DIl+v@>y*n07I>HW9p`EaOiYpbv#nqOSM9_$$7i?XFb z`~Pvx{oo7MhT!9=HRD)rd3cXTHi2GMji9`N#_KdeS8Tl2Z`TdkAsB+{!?lNtbHpq5 zwe(~61__l~<9W=4=L=l|D8Jk)$Fpw>5h#w!kBgUBXOXmfd!zi(_tWNX`(br7 zE#7mjuU$c5qvA0f!BGv(1a z7xUZvxO$eky#`8l+5TmDTz$Cs!<>f4dLS9#wj;QD^@82iICa$iUX{v>sKHQ>T(G1H zd-w^;A@U{k#-2y$usR=U|6O>QYbWDH6YL!4{q45P3i^NFd^Dp)zjdbxcHfHY*Njo# zfiZ0%s7>WOCfZ*BuMSYK`j9w-%=?>fTb_4?n+y+ckvNY%Ke+M^mi!AAyRiBp!1uay z!FPW&pNfVje;kO?Kyh4o=2zhonckdep1Tvgo{q17PXOW5bS6ouIFIc|kah&-rBOO> z4N^e$ch|jdm(a;VapAr%hlg#j^#zebpiqCa-N-|2l;7)wT?0v|7m6c%gkUeV?BToN znEnF?JZu}wv2z_la!C83n=7mx=Gc6LwEr#?V+~mQ%27nnUpi|wC&3F?f8grf&HA+d zNey<6l;H54@43`T;LPl}NkU77pk7=(2p)fBujOzZHXkDG|1IdTP$*`tn+^!smmuzl zI38Al#@TXFf!7>Hqc|Zsr2Vmqk1JHkuI?~?AR^ZCl>(Zdn)a0&IT=(aZuVezcybRnnh%rwv!$Lo*n0WiQfEGY z6nj3;4r(egGh*X0=sW4nkzf39C(*-Yl8TcsiYs-vqfQrCNlGzzduoC}yAy*eXJkVM_^nG2eNRGD`>upf_MtOMVe6LH0S?;&_arJSSCvN2t#(&QV zm+#+-CuIk|*?LYAApXf%zWzwwgzYa$;L*+ZSTP*oLm-I&RfLlyw!Ue_Y~D0ol=0j7 zoQ_=@Y$kI<`7`6+kUg9E{pV$}J<;$NaXjij;@9uOd7{G;wl(^w{oj@Qe+%FK=b1CP zf?v>k6x_IWbZAsWN?`ZOxby`3_L^`Zk-wZjS;Mmr{ceckw{UpL;P=4A8!EVKDAK+XaM#t2$9pK7eZ)OueDH;qUY(EM-Dzyb192!A8zGLh6Hbt>v zz+nMM%H7*pf8yW2cpNWHpzp)>13}%r(Ie5S&^^CA$&4}&`Ux&*kSZC25Pn(GLn|IY zT)Jm$>aZQ^2YZfiQLKyul-p-r0x9U>?V8pI0TCmVj_dD6xb(FgQ<4Tu{~g!C$e>Hu zc>%7xHOXV+@d3i?IW9jgUZlDGSvUo#0Fe-&SLJJuGdYL5DctA}eM_3g3<;d)`}Q{6F3vvh*;O#yX#K3od| zqxbbnrKybJBadtvrqeMff9H)@(s4gWz_dN?_Pqc*5Y68_Y{Q1#OWs=h8g`IN9Ilx~ za0xcU?vv`GgDz@?9EEHZMqzzrY|!;lr`GizcKGAaiz;~!c`%^a z-uJ#98{hBGx~P{?uSj(`wI1uegY64m38}lV8F|BsNUIMcPTmB^58}s_gB+c6RrcUs zVS}dqEoYGSnJ%)z*Az|^MkR_cXu_d;Uds%lqQL3Nm&=y>u=~Wvu`DHDm>qz}yR+@= z+kQ$x)yQi+)jFWwx|OwqH4?jdX-qG+-faiiqUX}W=LC^QkG+lZRfdfBUpQ(3>fT8Xr`EX%b zIP1*eG*Dj#!*A|*&tPu_wzS#2%sC+g>)*6y5!qRz;|Gb4`+Y~NR0($Pg|vU(aasJ{ z9e*@G;ukE6wtZie>hs=f#+xY!PppnAUOXEH4RZaxOP=HJ&&{9cK5Cg!fKxJC{J+cS zqIOjyxBKnN2u5+l9xfd59;fX4We2YB^5_fTwu75LnE9Qp^hM=X6C9IA?6rVPgSl4V zv4g0eh@Ru3B+a(uT5z8v>DZU9DT2?xa-sG912eyWPJ-wUZypGRq<*3 zvv;a;U~25GhKf2V5ceXZ>w>d0)JYb)e(^{gYKKWD_A>c^7I5D(6*lGQ3#u*{emcGW zUJqh_$6gBoPXzeEwlg)^9!dsP)^gcm`vB8eKcZP{9f0tC zCc3>(K-&P%pRQxMM7zXX!T5Qhcpmc*p!2F~t~%BL9QpNY*y@xA5DHBG!Ks%kb%b zzC?K3#QfllV<;$FtGzM9a27^oCLX8W;R|j>da6H5Rs{j6Vdf+&im*SV!+r6vGfcft zM|pQjj9~m(Q7dJfSFGVc#K@JsX+DsPi>B7UNeQr4n#Q_*bAZR%kr- zZ!gSihZjQIvjb}T*S{Mp+f6(h9!HJRd-7fs(v_?af&cqoznIog)sQoa@83+)t-a_3 z$ur{|5=DZbWbE33ti#DDeRU*7U7E%UycShBQ#Kh4><)&}nCfBk9HN)8yYN+d<6hWB zGO{hLOdhp=(Xgo3KiC1)_s%H7;;ab%xs;!{P8Y3l1ng}ouVq_~2Vg+eZ!F3W2=vQ~ zI3*;3_tmPVxk5g$CSmfxmarITUVM_CZ$1mM84UOBx0MEwovYvbtDV47kSbT}W^A1$ z<>_|a8j01#8OBvZNe2(0{u_iqSI{DpEegoArv1^J6 z0O9+m;AY!y^6m1`U~D#iw^+g{)c&8fC+cYU(t;us^;blfd3xyn#__92cI8Wp8iM`K z{fvitq)_=kYhP<=wK_Qd8(L3fUw5T1+ZOiQ`aoP-#)pQAzZ`$yGv?Kg=av4nyv;q` zfc^8)!1BilP+9e&c((njdLeU&LFlP6UryEwnEVOrBHZZ z=Jum)WL{w5Vypv$-)vid0%QB+q4npNSoezOlf&Tcjp#e^&-BoIIwy5p&L}T{EO(#w zT-xalbAqVzKFevNbXBLq-xreyeBu*Wl>n3O;crWbLcv*`nd+Y{*1*CeRzcS|4XBvG zqE|O`L7l-BT z)Sp;>5jq05Xj$!IN%4XonVJ$l_Zq?C8p^MnQ5Ha?oB6iqnlFg^II?(4*8(E^xUlif zYG!?nDM30eKQ4acQ?Yf9iaF}PM&PQ)@y(^EzRhf@qe@=sD2^+Cztpz4g*t~I9hV=$ zx4)8Xp~yZ37v2o9^8KNIoH<`fT^*8uU)t@1dJk`dKc}Y7@V_7&|9j2M+o$I7-vfDj zA+q+CW+JR4&8xqziSOS7dHvahAAF$FG@UEaDGH7MlGcgqbl>Ap{L$LI_e`;b-*4al zz3TXRO+S<$i4TF^bY)81cd&aiq#c3tZ@}%EY*|#EN~SFJ$2Y8ApbjZq|6nGz9~!ts z_4&rvT!_fm#@@V|lgo$Vp1ks+E6=fd|I$O*KDu}K`9b6v<|+D)>0#$>iT?77V`71* zKB*$bIP=4V=NGQckv<*`J}Cc8CVOP`9)A>1QehN`>BZJ(#IBd}s{e~HXFzt~n^3oy zEXr@N#I9FWgZbn4Byz^fQ3|E=livAIeJ>HkQ>qhb{kzoxqW8c|+fNrV6vST+mVkLzLeYR^?g$n*iLhb6n@QUkr@Q2j~ytCWLH zr%`9JeYxnJd1!;Qp5cjxEHtkeBEEyVs#As6IO1 zr)SKTu>1)mHr5HGVEZjOIueQ(QiShOG+Y{}F6!8#`VhYm_~IR;-9asZ#&h|aWt!>w zc@RP$xuki0%OC#U3~46>e_dSsoYHmym0x>N=|@p*jA!4TI{BHFU?GwB0Blw_@#t>_& zgDL9Y1sfg(;UO)Qf5uKdM|B%kU*YPX)>ir`yhhkQ|4!q);+}9+A0i(X?zoj}iy%}v z6{CELT@UKrUX{tDG=&PO9A5*UIRJrSjhnMc=V63)erLvwF7VD!-m@>+4c^JT z{V0Rh3PcHCyrMMe3uO&^(=Qp@fDZD&E?vz9snDmHa|u0!=L^LDlC9zgSxpW>;`@>% zdmiWjU9s;h_BwW;0luKmY32dt-=2w|-BSjm(w*jiMfyPn%Zs|)#7<})Tr(H<&KAYM zmaqr9=Yz5N57EC7)JZ>kc5i?XpmMn4e?>$L*6bRWd=VUg_G8+k_}H)k8+dX+$mPpd zIiN5hX#cwW5ZHcQa^)LLLH*ds&Ob$8(>g{Bh+RSci}zyU=D{<)sT)nY+wdeK-h+Mz++;1=vBUDO-B^uj}V%0(Y7@*MD~? z-r~~oHmVfhA=8~}Kx+%hD(hG7v3Y|yrNbVNKP!S>YX4P+tI^=J^6fdZx?-4aKXmeK zW(I2SMf5J$@X-?}PAL98$3|)2x+Q~6$0^OI#S+$k-z3pFVLTC-n=uvoqzM5;PSQv8 z!lsY*V0+l?57ov%aI>(y_d!PhXprpE^eZ<4_1-@`l%Lpx*ZGZEbB@?NhsbXPo9Qmc z&Gq6xe>WrThb@@Hy5-cK&X<5;gnD)EpZyCiD2LIj0>U{l0_Vk4g)a zWpuL?Z*~S^e7hC1FRa&%`bK=UVYVQ7_}BU@lzKTk~aF?us^0}d>ls{J)(1UT7K zC%e-Oz^qjYU0iC>oVBvFa7^F3_%TazSIzCn$ zOp2mBwV?juP9nNb_;!)JS5zFJq)pn5j!S>msHqR<9bvX<|Cj1SY+mVADEX02f~~i& zc8)#?oU%aeA^H$#m*={A(i%IDN7^@nnJPP-jw#uq{t)87ztH27BZ9yF6-@H7bDT+l z?ahy`DWtz2E-#6doXZN=vu|1b9bJD$t$e;mhKMuQR=CE2BrknDqu ztn9t_-g}chE32f83Lz;as~pKlL&->~WHgjeDm3`|=Q`K(`*{2wKkE5>zTU6T=lk~0 z{dPb1eLv?q=YGz)JT4E|>NFv@BGm#0ACerp)`XuYrrONs@!oqS^m_M93SJz3>J6c?dh zQuxh)a%O2$T@*hlzW-C&WBMORKBWtVR8<8vA-#CLh50XD=>4-yJp}Ccy&P=mn(*6k zAFn!n`>tvM47HE$6cULdlE32kqJM6`3kbUXI?yYCc>e5sc7LX(DiX@D^!|F5Zbu}K z`AG`Sf9SAnur`5HgCFg*%lIH6-}D!YCmyFvK*97*mNy!B{eL&B+oMQbACyrZ)m7(m z0@gw#*zUI8yjh{zgcHUE|sI8?5gdfOaH)ZGr68V2%NA7!`i(YWtaf?cE z(=HGj-&*@Eoy(J2@6>qL4#G^0yC& z))dj3!rcd7KfV0f7v$V3U%_yg59v9e^~;*3%m?o3Hmp72UrHpeYf{gs+lKRF{$Q{t zucATlY7BU@m)X1c+cBgUqkC9WZioqB15;C`5xP%J0JGxfae8qjWJhBpN3vH>7f6N5 zJrn7{&j&F1Gf=7^=HcT1jZ3tgFc>GMM>%zk%-mo6ou@>J5#A0jBKw8c8+l4wc_F#+ z2%W_~Z7@vlLm|YY3AD$4{(Kgd2*=6lB9AYAcU6~yx>leMpZ75P82nnHA;@2&0pv&hpBi3Xq&{Te0 zap%qxa7ws}vRY6S_R+1R@%gBQ{O2(;3gaEd*OyH{YN}luiT8iZC&^Fc`fI_L%KL`% zxD=5*<{t*RV~VbFicBJ|8N9xzhH75Zq#Fgf>vN!fQExA z(Sf9Y@_jsC_B>W4NyqdNLg8i!zqP$GknzaIe)gSs-GK2WblY#C5_JR*Si?_G1xFI; zuha>Xt5#)$r^h0-onwigUrY}v$kmgy>KfnT{Bqng(=sv-67n^knvkKPs_sjU5!NBne{26xN3fS0d~v5!V_3wJC_X}e{#$W+ zKc`lfPJ{or|HIk`rs#}w;f%muasl6^?~@4gcZ;jmttiwTrrlKk_TD*~r2ju;Pt5Q4 z#rM^i9-gn&A0;n(!$IL&P7gPT6ZJ2~M+yolRX;fM)gIa$-I8#pgLr@Y-G0;ej(Qug z-Se91$_gU@=6%$ho@g(=&qp)%#V+l)?UC|>R69ed+O~)D__|*7q3B-i4_A_TBGfZs zO1aDOAbu~~$WF_*@>l4x<@0GRCk)fw5I=2nBUR8_OIX!;_|*OsbI@3RoK9C0ulG~W z-49_EISj;4DSUS|+w-^cLsKe6-kZsa?3e9zeIjv%6Y15@8c*KvBMH&jlTVyGY43;d zVwc^*vDCH*ua4GXnGfB29^r)fqgkbGec~N{Zk*Dt-4S?H6jnJ0*sP2= zg7gx~{Zn|X-{uv?6~~Z&f%2Jk4Gn6b>dS0%yYABGBQX*u?|r)A`Nw_+l;|*D+@b?i zP4epJG8fOIcq~O6^q)2E4BUSv@s~BQTkS0W+}#h@of@SWI#USCzYL|Q^;$l!I+Yr9EMzxaKZX^DZz$r44Fcpy{A`vf`C zA1!Cy*!6=PTKlAGUwg2$e#{Oj=uj27VVxg-Z)ZFqI`sIe46;W`4r}*ueDnTenJJ3% zg5<*4FL}X0>x?&tAotSabDX~Ez3~*DzXI{?P4`E%pcJn)@0$CnNH1oO`MK_WRUThh zeUQen#gTZwhVd;I=CIAF!hC;ZNBBJTX7eC*xK_Eb;H@C>_+Cn{(d%+8JM!buN^a(O zFZ1?Ustz`8lGfAb}{*C#$3MO&N$TW{{Dl3&dRI{-SH!uph58j`in%w?2Tcf8}kg)@NNv*WvY>bnHLI z;Q9K+DOytTD+Bo>AK}0}yZAeCn7yUoiJhf;x;EnLzxDJJUghU_oi7};-mkF48tEt0 zce&F#Q%u<#P-$>5?l@`#uLv1m+Tcd|o|91Co9xA@(xaIq^%L^{(|9-C?DVHY<|tot za|gdl(}W}ZpSDM+FBfXBZx^yh@zdC>&$^^XWCLVV4&i`9$Yc0>G$E;RfOpL7vU z$WMs#r)mujIWr=DVaeo6bu{=s{pObU$L-AV=YvpwsFKk%D{}{ukE1@8;PRGuA4DjJ z;X9}37O&;Zkv!J^pMs2!qz>QSst5kmo`TfexanrcKbHSfyZ@Bj(4BNUE=570)HOW1 zB106^2UWb~T)dB@JHMbObR!pz>#EYvZNS%|To%^ls}( zeEbM`+5}HX$0B~W0){ik|9`xP`%lI5yL#FmJ-ym9AAtO;(i=5$y9E%wl)iM<@nfUf z%b#zT()XXH^Rf^0#Jp35VTE0_?Jc43wq$Xtgu_aJ`QaeBDJyQ9Ey_n#FYi{XDR+dg z+ZZ~bF@>M&IvyG8l<&je6W$VS9bllIgZO>BLgLc^>H8hb?tcn?^OoW*YqCc1atF-4 zZ#jj}r%U;9*f5pvy?FqVUy7fQzS7!+C%0b^`i>XYI9?Tm#rw=A#(Rxmu@S{S%HR+X zBs!IMZ1MMyWfvkkxNL~uUlCXSk?P_@e6CMu=YR44&J0*{r$7Dy1>mO5wL$vVx#4S$ ziAueThTx%f8O5(lF@RZKXkP&}Imq2ae#dk;Yk)-$u z+bJxv8Jetx;o8+8{WFs$$>%6L^uc`L{mn%5=E06%>WB3}*RQv$*#`4b+=O=iTk+Rh zO;3bm*nvH>wG?k2dBOH;NqT&reBigNBbyd@V?b`CGUw%0o=_v2GN$Q+6j6Mm7q)7x zeVPv?GD1EdW5n0#|JHc_*RTI~20BHh>gmR$;jNsXwz9Un=!HX5xbOYEJ7!)h&SWjBMM+Dmyez?yi@}(B0_@ zTgv6{gB>A6&kuchPk3uwAyl*$o7(i+l!*TS_LdkcJUrO%RVNGXwO`*E1D1bojA>d` z;?Xs_b3a%qDMG)W;mB{>$MpQ43XfMnZWbSU5crw@G@H9E+`0Jg2nDJ^P|?X+n<548 zM^f?a3^jo)n{?sB6HmKC&iDYXj+8IXF*;CU{DREhW@o^5p}BT*iaCr6_MKyWnvKQ< zrti^YQn}`aNLZuDc)Rkw5r~((5^YR{pXXzIq~IAtelDR?3b19%+Y;wUcR-(E*5bvY z1FSB8Iqbz|3q|A@tVc%`fbcG%@Q_LqqW*oI(VA{3EeB~f?<=}S76Lu1{X51sL;@aY z82PHjo`|2&57#;R;u*dS*difN2z$@)baj!fOevKCK$~)``X{+?N zS$GobJDuCJtw+-hD3+x$=^cM=LeK z=K*z-+M|1bNYy$&xe^1&V*VmhzzeV2F!>99Eo_;CiSWWgMbW;3<)52i^6U zVB6hSGmiFMW-i7I-l6{$Xa1Mpk=h|pwsJC^_*{uSWbnSazYW-?T%^rtMf`kX zc1XeJ3{HM*z!cPUOP){=n~{#LJAbyILI}3p@o}jzk%s-<_nk7{*}`x4@2klu9VN=I zmBj4%ctu}uNqu4C&9B7cB%|-d1OM4fcve&Y`3nbpd}IEIbPC69YS#odUx!3$^^~Az z;e^^`z73cVSgpLe&I7)oUP+x(xgVwlA8F3kRVT9l#^zc^`CsneFulu#l&cet)Ev`? zzaqB0Y09;P?sOuBOq3>IQfdqPOJ584%4KhKe4!R#3ExHLJfw@B3rv4n65Zxgta0E~ z{$(cKwfH>*MqdhsB_~fMb=$yT#eFR1z4$(RDLIVpmzO5SO)mq?TC%-^wi^@qhw+hu z^i0pz=J~mS)$HG;%lj7hv22E&yFa-A5$Xdk6YcSHAX4%n58sGyq=*DMVQ2PJt@T9x zv)leIy-Ydrd`+mQuV!s_ScxSF8l+R+yk8GAKhugDd#VXG&-8amUABZ$tE@ipvTFhN z#7?EKP~!aWUGv(qa>xgaUGMKHug33j3GI-=qnTMM!gs1dB`9A37k_V}J2SZ=eBhi^5>`ZrK_spsfhlkcK=)C6h2Jdookgw z@iD4UIjmggjqppazZ zko?`pKlS@REjQarb;^$18g%fiyfZ{TgW?(8%IzarNqQe6zk7`dbC@gr?KNMw&t{$T0`PYy$CsD1uC z!zwQ-sMGx3bYt-E*Grh*xA8-x%mMg&Z(M~cu^ApA$bN1*kLg`5e7i0qaq6=Z(x1CVNz*U~*FRglQ$TzGpYMm;BBTX3;q{w=#JZdA zy~;>_xpqnM%_eM?c^sO6^xXiJm0bP!dubQHC(VA}qm1JHaZ9WuL@N{FE%i(aN+zGXzj9mEhwN;V#^`l==0OPNH zMdy(2QAkps3;!nm7)tzH<>UdMXnAAO`*ch%2A@Q_-aOkGgY1n5u{{w_RzkRr44VRw zj3H^ibS1sT`>EyE2bf(9N@trpr7EQ({r$TK+w)itAROZ(1-A_x>8p3e-)E2FcDP_M zgV)P<_3FmTx8dvQ#jwvQ;@mDEz}(}Wb5j^l&R+XiW}h&!r_ssZHIj>u&m)HtRwj7r zB6f!#hERe<@EGHabeK7~KOcMtKZezcN6^s^r?Q>8>(BKeG-`S*eyiU|L-w{Sm4 z4t^iuP_NLS5hRW1m>mon@lJN==0za=Z#NuR*m{@rdBauCLqap#@cH~mOYUlez<8uz zqxDTkFWcXL{|obv>_TET^Ba6V!rC$DlzxYmWd^U`f6R1PoaI{ndHEGKsR)k&UgSTf z7lU3bCqj131tR+K7>O=E3m$~q_#Akw%^(h_3ENb7@O5zMkf~mU_5e zJcj6e-F45&?ePAt%8J@qtY?q(RlFE~tl5j7-wWT0b1kvniRd4S%5pse@bURYQP{wf z3LjtXGN)U=3@-nE(Ue0{{}NjO@(+t^xzN=|^4re+p2$CzI7gQw?6`k~dg4Ef)%!30 zp9j|ddFR!SrLXM}j3ZuhA`kUApUMedDW#Cg5a|yGD6E3jQkg zW*!L;0=wLr#24l);mFSw!vjiq+??N@ME)`t$$B!swn8}O*K(mt zm*qV@v*oV?%hkIS--B~=UZFujfMQVZ`h1TOuzD(W!SLqt$1|osuJ8#*PL>P+-UADfZ>=Dv`(VU#UpsNX8FADHZ zK6ZfSs5^pPK|ym(eg=SZ<|mnAdJHI@^?ReT_mH8d}<9&w_j~cKDPnS|5Ek{=>=6ymQ(Ut&`l^oMXTKkX37WLu@1-2J1bQ0#>Z0newSWe`(sIPwQa1m#m5UFzb%(a&IT_Y z;HY-MC*y-I{Ni(QZN*k&xKHHgC#L9J7|~>@A+uit<)`lqRrSq2{5@ow>CqELUU>a; zP@H6D&Z??ye4zCkg@cBOZ^h)(F=tmkXv%aB9zgmiB6(H-Hpy%F7!E2bvB z`wa4DaEm5cHv>K&$Xx%{ZO4*{=v^flsV9_lwNoiX&#I|96~Zao^fS1!q=!liQ2%fW`vPuWxiU5&fUem-e%a%eJ2Q zenwC_Ux(rmUa$OJd$vB&H@=B@ko4c2A%4Z^>E*BYzw6J3YXV;k?h@ivg$I$Gm>F;v*J8#C6NEN zrov}MF5>48Ir=u#@>*I*KcPRg8lqM2_gNx&yDq^BrU&?V8$DF0q3?&!KZ0rR^ge#h zLi~jK2=Od;v8RnY&XVL;EqK4vna=A*}$K1FH>-%k_L32~+*Zl|NyN$)Rm z${hIuMZ`(=AECbA#glDKPyY}KfYXQWnou(Ppg1u-9TxqUQ|6CBA5WLDd-=8dkv=E$WuRdu|+`($f2FUY+3KEL>K1yv^H7~~Je z|GQAEagQzK(@1dVlgNT-y%T)$GXC6-WM`0OIC$fZlMM2=B*bt3+e&=jbv@|m$iqfo zL?>lepylYvTVjWx!Iz$s-|pLz&@Kq#NcI%S@WRC;p-|Yy-@3)gbo*RH^hY@`#JAH+8{@=RT5WlVG<#)Cx ztidYbBIeO-PoSu5B9-e9iRh8>6d~eV`w+h8*XH82>G-+8QucnAzF?;M?uRu#j*}+d zHrANy!p8%AuB!vhz?H9E-9FR0$RECU4087)>;YznRbg09{cSd)W9=Ah-KiW9A*_h_ z4axbgoLPKN9HYN6e!5FCL=)lhiYpichJ}&7QzE5YPj_)4y5Y!A7xMtzUrY}v$PzS0 zA5=sN%5hFc>3wGH^ zcRw^h{FooR4PS-5dq4|LO3LqIJAVlAe_`v`!@VsCi0PQyu-h#D?)9psqC+y)FnMV2 zSqaNvP&7IJr6Xkd_tTiYKZQNhQkRBu@OW6Fe^Qm%hoN{v_O3X!`viVY!m_1R?3(}b z_sJ2NO8p1u52E;6ztL_y+^+@qD)#vgJ_v_DN-HSKo?9b2VH}^NZ)|(kN7|l^HU9!I z3MMH}s1L)}tvV5u?-~cEE9M_l9?(JYGLq-6(62E>c*j@U)6e#AM|c6%`>uDVcOiR) zkLS}K`^6$$Fm~ZXV|@nVe`%8^aQEkSL?69h>0cGE4H?_l+NJkfAwJBn<-$EtmED~b zcwX-*MeZ%|#m{4MqS8-HO5pq3v4FMTBmc6#F4qnzz5~(>0u<8tem&ObV`}DNy+DfJ zw(`7C5JM=U_xKd^dG=VMyae>blJ5{FUB8)Tf5yV_wL^-JZfq`iuL>TYAeH8i z^p@rCk4Wjo+Et^TJbb2Ui}KURqt~>D!x!O0L8?{(udG1EmvOQ6b;00l;RgZF1UeL- z^(Lhn&j!4%V|hC)d3~ccqGNWJ3ug^`Kcs%f>vS@?(_IehEs;K!y<3lLox|^od^y*| z325N?_tO7WFy1SQ{9j+wJe~iS_a|D;)o`@w$RmAA`DJmaeqy(c6yjH8@7?jvRTJTa z@{9oy`Z^rHKTi_s!SL?~cTT_GA&BDHYkvHU$sN2d=*yL0w*DA|=$IS^Z=cW~@8JqS z^pWPg^6>$D-o^MZn5n?gEP8bV;_qH>>qSqEpT{xrsUPZ}$Lowc>KRS9qw({>h4_)E zF(Vr!kLg`5T+jQ|qwt9llE16$QDdxuzn8nvkksxnh0iY)-)AJ`ZSZw#`Y79uGyHfR zRhYwGrMv=v?|Zp^V0=DZO>CO(F7P>w_%e6e6HISh9ie?M3S8WJZFECUHZ1=|^E99n zuiLk_c_s~c;PVfr=TG6Z=<^u`ZoIC4G3@^8g$Z7lm6=6v&e6f=%bT)3)BCA1A!e_F z-Ezz~7@z;Kb_|YKl$(p*!^a2Kj={FMke^vM{gA%vQiV(>xdIW6@nO)!mFnr?dVu;5 zYhNzZJbob7@MI9uuhJDq`8uQ+;kf}nduAfI5Z+e!Ucp-zuX8I@&q>-H!S9zsE!1d^ zR(T?S3RKJ`*OTFO-@!PFW@zsSZ*6P*5aNXIH-a5jWF4+g24f)`D__}=-WMDK-Gw)5@pC3knUrIvUBketbG8(#UBf|ip5X2v>I}G+_p-f}wHl!JJm>pDHx49w zoJ!li_`4D76tcM1QrW{dybN_Ir;o#;#5K=P3y`*pgAaX6PR{N2IA9x9%_Cdl>v zG~}(V=5{+64*8Vh8+7Ze0Gnv&bDqoqu;vXX{n3Zk;M!S!+p3QzVJ2lG<9WLci{J61 zYZ>W^1eRMGeb0PLf*uWZUF2dh;QLOtae6O&e%kWwWZrlY{yaW6wY=ud=?$zCd>Sv= zX#@TzJUb_2@$=ds(+~N4s`z^PR%G_uz$S0V{ozpHfyPMi)Bk8ZMN&B2%wekD9p{Dm z8OskLjO%>gQED6n3I*ipf9R7Q_k?oG#pxA->mMuP_YKr#x#EUDj-v6p?$x{7Pbee7 z7tPs8)hjw6WL7SX!ym6F6L%D950poN!rg{6#aZP4=MN$!z1GBe+judB$y^Pu*Gbu%;7;YZ zc75r7!sI^>t&ZP($qc=7zGBd!vTY%KA=pmEJ=L)Amq5%tGd?5 z2OKWd)TOLE3pq8vtdqC%gy%~dM0#Q*0R<0tSOR$zpxsWc;&RIkjBj@D3{&z0Y13N!lwaknJzr<7C+ZW^ScdQxem~0Wj$B(M0@FAIa z3t+j*cKoicCwP0gd~EM5-alG}-$sh%@%oQx&HI>RJMnteQ9z-GR|(Hkc59Yg{48Fd z_#H1Fs^atn?7wnCT66Jv-+6CY=F=EAU~~O?mgGYt)IVja67h^2`14Ab&)>zP1UT-tcHseToy3Z%%J|sS)l21_Zz7Gk5ub za|iGCgtJ-0h-aSyuUQh;y@GFp(nWbAp~o4{sI&Z5KsNZ7Gi~SrIP2JQIi_9!vfkan zZ@Reun9D8DX=K3jfcb;LL#@m&+oEyMwMb$-Ehy z$|YaN8wn1`KPl3E;|6m(-`Nz(YXQ@)TVs2w7r&cJ5t7@pH5uv0>@Ed4H`T0NeBmFS z7ijp}tfE6we(To<1>Y8QK0V{Cj#rV6JWD%$y zVIM>tssLem-=Q@HIiif zn4Vrem+!at`GL#1$_n5FzV78`jfFF2M=U9i>4|ZCUgm6f0ydgA4B6Lu0sX|FeJZ^}7Wjl#*QgT@P#@Msf zAItD{0BiqUxIiYq@j;CRw4zl5p(1-o+BfO6|B_no1$HT(JF<_<3VH}Pyxsf86_mOD z`X(MW)Tn+TU^PwI^6QrLppZV7>rOD7f2H2J#qUD)-r>G;E(foJ3FFw#CNga&;sm(X zk26H$iTS&v#ZMT=j|cqvGd3%UbhhXi{YbLF|JPM zBf(Wh^5==EQIMvFBh+0Y07~A_5te1a&*d7{2T|<_q$M=Tjt@E*7DbxrSvmB%HcM6 zzX`lzOQYUnrvO!a`EFNnn!|#Qq`Eaqx$wzkz|p`A()%RmVu#v{XHKZ!<-bUtp17%i z#^I{&XBXC+WFg#OZ?}%X;S_}1)~Yc{9`XWfN64zB^Q_=6&)qlExI>Y?rTpiktLo32 zJ&yEUKkKC`k%8|gmXeRQ7m^Sz@j>#XT&a{{D)@df+{idv7QKkvFH&B!KDoy2DkU&}Ik-HBZKTm#b+b8be7M;kxSVoCq9 z_C}1=vh8Df{ulm#GXo2;_m3Rha1c)IZ#8}KMG=0CGxBfkl|$oavz=dRC66t_3A1D< zC=o8+E)TGFZKm$T*6n-b~#zc@M_7TsZJV zTKzt&JxPBF^;oPNh?7z?M)s#eIiF8^E&uu9jjg*oQtr~{vsZG{~-@bi!R@4|+^*=9i+ z5d->u4?Fl~bhl#Tal9^wD)BY`EM-74-^=xD=MYEpGYUP>d7GWXaeNEXSC|y;{|xLv zxTm#iNte|jfa&GC8g_CGwIss1hH8AJPg*1Xh{WnK=S)RJKN?SW{T2=Bx^APqxa&dL zFVdKPX1VR;LOm;xzV{3(?Ct3BdV9j8|K#@l`iOqaXrw~qpf18OJFc?}<*n7+!iPpuaD4xqF1Bw1~?E%FDW)3}K~&fJs-lTB56t_6w^^^a_d zL;o5lRw!+xerEkGe0;o8k@{*K;tHjd>mI-8!|yvWy%-#Ey%;a;h~IBw?f+kcGt}t{ zk#SlXUyOKR@lFbestgE z({Je4~v@`PM%Dg~&KY+>or(l~Nn@p|*{{92=`zI$hJK+0wnILD5`{p7b%fCQz zfz}eHsqKBHnx+hjBvbeJZp?+&S*6Byk*=uUbRM1LFO98k7j%Zm9z*2I`^uqq2wzIy`CaB5G_s1oBl03^jl0-y z`;pc7T}dn(zgJw!-hUgt-Jef^b>l%$@X=JkLs}EgeySdqYcpGR9>hN?N0?1pqkJ2u z(Y<`6xcvODj}(yP{kadx|89Od8b7mj&ExlhzbpU9;q%bAExx{L+&^|tTH(a9;~&Wv z7-yQp_wzo1rq?TiNWUNWyZ-&#{GLV%H$a;#yw1Anq!Xh)pr+US7~_jSPfOW%czooh zd?a31KZxg;SrFn|cK+BZ^Y^kD;``I3>=Dw}srH_|t4ROHniPv31JEU;& z`Sh*b85frwFDX6$Q|+p%f!bV`@w!`1A=fd7MswNuJp5I7ks%wIcmE|sXCD0tU?Hr%ZV4ekz0uZQ^l4)ZI)vgL(GyeRNs)l*tA zg|F+o-A+Gep_T(oKK`G%OUwa(?D6yZ+4z3yrd#Ezb9Zcj@}-*=1z)v*1!cs&ZRy14 z_MR@^f6W;xz&mmKnY;|}b3#qpC*s0}T;Mdv+sv24M*(C6^D&9Ms9*9=wRv8#nQ7by zz3N35|DV56g*{Cmix>xu`4k)teJowucRXJP#*GmFAU2|3LApUdyB+siyxDg%n9T4B*K6%g~FvQ27Q9?2IybiWok z^!L}Hz8kymKW|M1UcPG55`T%GFu(scuJhG=ntB%BKPA`mtsS?-pBF;=qSE}6x~6i# zwLLQM%tk+OTB18y{If44{ol|f}+5zVytD9P${Bg@ht`4EnJwqn7tqLJ*IlM zdygah6reD_H8upSXw19Xur?m5JT0|au(pI5FAfSSZFU8x?7nqc)}|pl+q7=X6gh^0 zrhXBdhc{A*;=}A~sl^ziu;J%JSbI0cp_rnCP;hBdr>%MvKPT}!c}gLP*9N+mI@N{? zc!Pzn0zY~Cm5}}$=R>(Yg}k5-h2xG6T1%kk$`LLcNnF=STfQlg{~QOLbJiVc$ah8Z zn0=kr?1)26kzm7#hy5Y0ZbWo@F^!@DA1#na<`m13rwL?k<%*r!l?^d@49Y&@X>pi| zAZo|xwFQCloNU{O=(~H*s9pc)1ZvspY4UVT5uJN=#F{v3V_;bQ;IO;3HC%W?b|Kcx z3*OnjgF`*s4u1J^u0+WV=f~{)E-XI7@8aES25H+KF4 z59Y&0-(8=xLgbNu7#)N54MRyKy3Qo&7#|^QYn{}wQVs&<%p=sS3#9Mqiua0X#_)ghOr36Ltew*j#zC=Cm4-ejQrQ0huSZaG*_)#&+8;^nDCW&!>P3Mip!E zc@b;JAivgB-zx7|BE1-$%4=U+NwE_M++!5vR&<<*FLR*w?o6c&T-b4sJeXVuVsZu% zr@v2b#^)ofofO=6O*^-w4!Q%8Bri0dyir*Vt=xFopl{?7cI7x?_yXq286 z@s@bK!~9+@47{}~%l3o@@ZLdXd4^|k-68l>ze-KhShV4&sF-sv zrNn09bA62QQpVzisdyYLt2^!1&1*$ue=wqyE2_l^ocdw3sqG^Ee01eLi#N}6B;v>P z62bUK=HY+YM`84cA&FO$J$4|!HZp*toVY&kS4$q~zRC{G-{10KID)Sqs->Z~Qp`{PP?F@xP4@R_U{qpTyr^AXLcm)=PN%F)NVp;j47%ZUsn4|CJTT zcubwR?#B2wZRfCWekn&JhtZ=GooU&(IYCv&!iPih#N*%O0{4sWi{DY*&_qvZp8V(Y z->CS=v{=YSqW(%-KdE)wQWYBRj|rWx#rGeWe{0qcyVG28f&MLmYZun-1)aBigT8Om zhq@`c5%ZP%NXCW9F9k0SeyyAOmJQqWpXA(EG9&U&n*BktE2A~k;1J4tITk}iUrPT{ zbit@c4TC$q;B&_7$?4n7iS(B~EWWd1zcJvE_eymOwnp(bmbOQ9?NbJCKIO&MRv4jn zo5}+YdN=Xs=tYc<{ZlGs$W=LRpw3L3-=+NfZ>Nt1liAt#yPD!OAFGUc>Vdg;=)fGKgRpZ|34JMcvcoP zr5RoQ{r5Mo2`p@I&(wo%+|#T-OrycoN@giZWjqhUVP%II(sSWpu$QczWDu0En+Pyk zQojbnddr3P%dx~O=LIKj~s5>46Pgh7qZbGKU=CZN8rFvCce1L?=)Io-eC z$=%}wHc6?Qt$SsO=q<1AzvJxk0?%U%KV2E~0jhBe(fK>@{lzh6N#SV^Iq?3}oAo^& zwm|&c3bHmOJ}AFRs8tH5554^7^vY)OaYrq_eV{7M5&5@?YHiV-hkTGcn9tLqDgX!z@o!@h zNv2ep*%!gkyYh%0D`%*671j;b9YR5hK9p;W<>EVhw$N=018;odj8a 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�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

s)n`E$(A?!eZSr0d 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 I?(b6mA2Q?RH~;_u literal 0 HcmV?d00001 From 29662d2cea3aed5b3f45186aa1c7e6eceb536311 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 4 Dec 2025 16:20:17 +0300 Subject: [PATCH 126/161] feat(Urbanomy): 1. Featured logic for urbanomy f 22 economic metics --- app/effects_api/constants/const.py | 23 ++ app/effects_api/effects_service.py | 365 +++++++++++++++++++++++- app/effects_api/modules/task_service.py | 3 +- app/effects_api/tasks_controller.py | 4 +- requirements.txt | Bin 722 -> 836 bytes 5 files changed, 389 insertions(+), 6 deletions(-) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index c47a258..f1fef90 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -390,3 +390,26 @@ deafaut_cfg = { "population": 300_000, } + +discount_rate: float = 0.18 + +URBANOMY_INDICATORS_MAPPING: dict[str, int] = { + "Объём инвестиций в основной капитал на душу населения": 152, + "Валовый региональный продукт на душу населения": 154, + "Доходы бюджета территории": 368, + "Средний уровень заработной платы": 170, + "Износ основного фонда (тыс. руб.)": 367, +} + +URBANOMY_BLOCK_COLS = [ + "geometry", + "residential", + "business", + "recreation", + "industrial", + "transport", + "special", + "agriculture", + "land_use", + "share", +] diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 513c4ae..237cfb4 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,5 +1,7 @@ import asyncio import json +import math +from pathlib import Path from typing import Any, Dict, Literal import geopandas as gpd @@ -31,7 +33,12 @@ calculate_distance_matrix, generate_adjacency_graph, ) +from catboost import CatBoostRegressor from loguru import logger +from urbanomy.methods.investment_potential import InvestmentAttractivenessAnalyzer +from urbanomy.methods.land_value_modeling import LandDataPreparator, LandPriceEstimator +from urbanomy.methods.socio_economic_indicators.sei_calculate import SEREstimator +from urbanomy.utils.investment_input import prepare_investment_input from app.effects_api.modules.scenario_service import ScenarioService from app.effects_api.modules.service_type_service import ( @@ -60,7 +67,7 @@ MAX_RUNS, PRED_VALUE_RU, PROB_COLS_EN_TO_RU, - ROADS_ID, + ROADS_ID, URBANOMY_LAND_USE_RULES, benchmarks_demo, URBANOMY_INDICATORS_MAPPING, URBANOMY_BLOCK_COLS, ) from .dto.development_dto import ( ContextDevelopmentDTO, @@ -81,9 +88,17 @@ def __init__( scenario_service: ScenarioService, context_service: ContextService, effects_utils: EffectsUtils, + _land_price_model_lock: asyncio.Lock = asyncio.Lock(), _indicator_name_cache: dict[int, str] = {}, - _indicator_name_cache_lock: asyncio.Lock = asyncio.Lock() + _indicator_name_cache_lock: asyncio.Lock = asyncio.Lock(), + _land_price_model: CatBoostRegressor | None = None, + _catboost_model_path: str = "./catboost_model.cbm", + _urbanomy_indicator_name_cache: dict[int, str] = {}, + _urbanomy_indicator_name_cache_lock = asyncio.Lock() + ): + self._land_price_model = _land_price_model + self._land_price_model_lock = _land_price_model_lock self.__name__ = "EffectsService" self.bn_social_regressor: SocialRegressor = SocialRegressor() self.urban_api_client = urban_api_client @@ -93,6 +108,9 @@ def __init__( self.effects_utils = effects_utils self._indicator_name_cache_lock = _indicator_name_cache_lock self._indicator_name_cache = _indicator_name_cache + self._catboost_model_path = _catboost_model_path + self._urbanomy_indicator_name_cache = _urbanomy_indicator_name_cache + self._urbanomy_indicator_name_cache_lock = _urbanomy_indicator_name_cache_lock async def build_hash_params( self, @@ -1134,7 +1152,6 @@ def _clean_number(self, v): async def _load_indicator_name_cache(self) -> dict[int, str]: """Load indicator_id -> name_full mapping once, based on INDICATORS_MAPPING.""" - # если уже загружено – просто вернуть if self._indicator_name_cache: return self._indicator_name_cache @@ -1171,6 +1188,67 @@ async def _load_indicator_name_cache(self) -> dict[int, str]: ) return self._indicator_name_cache + async def _load_urbanomy_indicator_name_cache(self) -> dict[int, str]: + """Load Urbanomy indicator_id -> name_full mapping once.""" + if self._urbanomy_indicator_name_cache: + return self._urbanomy_indicator_name_cache + + async with self._urbanomy_indicator_name_cache_lock: + if self._urbanomy_indicator_name_cache: + return self._urbanomy_indicator_name_cache + + indicator_ids = {int(v) for v in URBANOMY_INDICATORS_MAPPING.values() if v is not None} + logger.info(f"Preloading Urbanomy indicator names for {len(indicator_ids)} indicators") + + id_to_name: dict[int, str] = {} + for ind_id in sorted(indicator_ids): + try: + ind_info = await self.urban_api_client.get_indicator_info(ind_id) + id_to_name[ind_id] = ind_info["name_full"] + except Exception as exc: + logger.warning( + "Failed to fetch Urbanomy indicator info for id=%s: %s", + ind_id, + exc, + ) + + self._urbanomy_indicator_name_cache = id_to_name + logger.info( + "Urbanomy indicator name cache loaded: %s entries", + len(self._urbanomy_indicator_name_cache), + ) + return self._urbanomy_indicator_name_cache + + async def _attach_urbanomy_indicator_names(self, df: pd.DataFrame) -> pd.DataFrame: + """Attach Urbanomy indicator full names based on numeric indicator_id.""" + if df.empty or "indicator_id" not in df.columns: + logger.warning("Urbanomy df is empty or has no 'indicator_id' column") + return df + + df = df.copy() + id_to_name = await self._load_urbanomy_indicator_name_cache() + if not id_to_name: + logger.warning("Urbanomy indicator name cache is empty, leaving df as is") + return df + + def _map_name(v: Any) -> str | None: + if pd.isna(v): + return None + try: + return id_to_name.get(int(v)) + except (TypeError, ValueError): + return None + + df["indicator_name"] = df["indicator_id"].astype("float64").map(_map_name) + before = len(df) + df = df[df["indicator_name"].notna()].copy() + logger.info( + "Attached Urbanomy indicator names for %s rows (filtered out %s rows without names)", + len(df), + before - len(df), + ) + return df + async def _attach_indicator_names( self, df: pd.DataFrame, @@ -1212,6 +1290,43 @@ def _map_name(v: Any) -> str | None: return df + async def _get_land_price_model(self) -> CatBoostRegressor: + """Load CatBoost model once and reuse it.""" + if self._land_price_model is not None: + return self._land_price_model + + async with self._land_price_model_lock: + if self._land_price_model is not None: + return self._land_price_model + + path = Path(self._catboost_model_path) + if not path.exists(): + raise FileNotFoundError(f"CatBoost model not found at: {path}") + + model = CatBoostRegressor() + await asyncio.to_thread(model.load_model, str(path)) + + self._land_price_model = model + logger.info("CatBoost land price model loaded") + return model + + async def _fetch_land_use_potentials(self, scenario_id: int, token: str) -> pd.DataFrame: + scenario_indicators = await self.urban_api_client.get_indicator_scenario_value(scenario_id) + + indicator_attributes = { + (item.get("indicator") or {}).get("name_full"): item.get("value") + for item in scenario_indicators + } + + records: list[dict[str, object]] = [] + for indicator_name, land_use in URBANOMY_LAND_USE_RULES.items(): + potential = indicator_attributes.get(indicator_name) + if potential is None: + continue + records.append({"land_use": land_use, "potential": potential}) + + return pd.DataFrame(records).reset_index(drop=True) + async def _compute_for_single_scenario( self, scenario_id: int, @@ -1401,6 +1516,250 @@ async def _pivot_results_by_territory( return pivot + def _sanitize_for_json(self, obj: Any) -> Any: + """Recursively replace NaN/Inf and numpy types with JSON-safe values.""" + if isinstance(obj, dict): + return {k: self._sanitize_for_json(v) for k, v in obj.items()} + if isinstance(obj, list): + return [self._sanitize_for_json(v) for v in obj] + if isinstance(obj, tuple): + return [self._sanitize_for_json(v) for v in obj] + + if isinstance(obj, np.generic): + return self._sanitize_for_json(obj.item()) + + if isinstance(obj, float): + return obj if math.isfinite(obj) else None + + return obj + + def _pick_single_territory_id(self, parents: pd.Series) -> int | None: + """Pick a single territory_id from assigned parents; prefer mode if multiple.""" + s = pd.to_numeric(parents, errors="coerce").dropna().astype("int64") + if s.empty: + return None + uniq = s.unique() + if len(uniq) == 1: + return int(uniq[0]) + + mode = int(s.mode().iat[0]) + logger.warning( + "Multiple territory_ids detected for scenario project blocks (%s). Using mode=%s", + sorted(map(int, uniq)), + mode, + ) + return mode + + def _urbanomy_se_result_to_indicator_values(self, result: Any) -> pd.DataFrame: + """Normalize SEREstimator output to dataframe with columns: indicator, value.""" + if isinstance(result, pd.DataFrame): + df = result.copy() + if "delta_total" in df.columns and "value" not in df.columns: + df = df.rename(columns={"delta_total": "value"}) + if {"indicator", "value"}.issubset(df.columns): + return df[["indicator", "value"]].copy() + + raise ValueError(f"Unsupported Urbanomy result dataframe columns: {list(df.columns)}") + + if isinstance(result, dict): + return pd.DataFrame([{"indicator": str(k), "value": v} for k, v in result.items()]) + + if isinstance(result, pd.Series): + out = result.reset_index() + out.columns = ["indicator", "value"] + return out + + raise TypeError(f"Unsupported SEREstimator result type: {type(result)!r}") + + async def _compute_urbanomy_for_single_scenario( + self, + scenario_id: int, + scenario_blocks: gpd.GeoDataFrame, + context_blocks: gpd.GeoDataFrame, + context_territories_gdf: gpd.GeoDataFrame, + token: str, + only_parent_ids: set[int] | None = None, + ) -> list[dict]: + """Compute Urbanomy metrics for one scenario and return records: + [{territory_id, indicator_id, indicator_name, value}, ...] + """ + s_cols = [c for c in URBANOMY_BLOCK_COLS if c in scenario_blocks.columns] + c_cols = [c for c in URBANOMY_BLOCK_COLS if c in context_blocks.columns] + if "geometry" not in s_cols or "geometry" not in c_cols: + logger.warning("Urbanomy skipped: geometry column missing") + return [] + + scenario_blocks_cut = scenario_blocks[s_cols].copy() + context_blocks_cut = context_blocks[c_cols].copy() + + preparator = LandDataPreparator( + scenario_blocks_source=scenario_blocks_cut, + context_blocks_source=context_blocks_cut, + ) + prepared = await asyncio.to_thread(preparator.prepare) + + model = await self._get_land_price_model() + estimator = LandPriceEstimator(model=model, blocks=prepared) + blocks_with_land_value = await asyncio.to_thread(estimator.predict) + + if "is_project" in blocks_with_land_value.columns: + project_blocks = blocks_with_land_value.loc[blocks_with_land_value["is_project"] == True].copy() + else: + logger.warning("Urbanomy: 'is_project' column not found; using all blocks") + project_blocks = blocks_with_land_value.copy() + + if project_blocks.empty: + logger.info("Urbanomy: no project blocks for scenario=%s", scenario_id) + return [] + + territories = context_territories_gdf.to_crs(project_blocks.crs) + assigned = assign_objects(project_blocks, territories.rename(columns={"parent": "name"})) + project_blocks["parent"] = pd.to_numeric(assigned["name"], errors="coerce") + + if only_parent_ids: + project_blocks = project_blocks[project_blocks["parent"].isin(only_parent_ids)].copy() + + territory_id = self._pick_single_territory_id(project_blocks["parent"]) + if territory_id is None: + logger.warning("Urbanomy: failed to detect territory_id for scenario=%s", scenario_id) + return [] + + project_blocks = project_blocks[project_blocks["parent"] == territory_id].copy() + if project_blocks.empty: + return [] + + potential_df = await self._fetch_land_use_potentials(scenario_id=scenario_id, token=token) + + investment_input = prepare_investment_input(gdf=project_blocks, project_potential=potential_df) + + analyzer = InvestmentAttractivenessAnalyzer(benchmarks=benchmarks_demo) + summary = analyzer.calculate_investment_metrics(investment_input, discount_rate=0.18) + scn = project_blocks[["geometry"]].join(summary) + + total_pop = 0 + if "population" in project_blocks.columns: + total_pop = int(pd.to_numeric(project_blocks["population"], errors="coerce").fillna(0).sum()) + + est = SEREstimator({"population": max(total_pop, 0) or 300_000}) + result = est.compute(scn, pretty=True) + + df = self._urbanomy_se_result_to_indicator_values(result) + df["indicator_id"] = df["indicator"].map(URBANOMY_INDICATORS_MAPPING) + df = df[df["indicator_id"].notna()].copy() + df["indicator_id"] = df["indicator_id"].astype("int64") + + df = df[df["value"].notna()].copy() + + df["territory_id"] = int(territory_id) + df = df.rename(columns={"indicator": "indicator_name"}) # временное имя из расчёта + + df = await self._attach_urbanomy_indicator_names(df) + + return df[["territory_id", "indicator_id", "indicator_name", "value"]].to_dict(orient="records") + + def _pivot_urbanomy_by_territory_and_indicator( + self, + results: dict[int, list[dict]], + ) -> dict[int, dict[int, dict[int, float | None]]]: + """Pivot scenario-first records to territory->indicator_id->scenario_id.""" + pivot: dict[int, dict[int, dict[int, float | None]]] = {} + + for scenario_id, records in results.items(): + for rec in records: + try: + t_id = int(rec["territory_id"]) + ind_id = int(rec["indicator_id"]) + except Exception: + continue + + + terr = pivot.setdefault(t_id, {}) + ind = terr.setdefault(ind_id, {}) + ind[int(scenario_id)] = rec.get("value") + + sids = [int(s) for s in results.keys()] + for t_id, terr in pivot.items(): + for ind_id, scn_map in terr.items(): + for sid in sids: + scn_map.setdefault(sid, None) + + return pivot + + async def evaluate_urbanomy_metrics(self, token: str, params: SocioEconomicByProjectDTO): + """ + Urbanomy project-level calculation. + Return: {territory_id: {indicator_id: {scenario_id: value}}} + """ + project_id = params.project_id + parent_id = params.regional_scenario_id + method_name = "urbanomy_metrics" + + only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None + params_for_hash = { + "project_id": project_id, + "regional_scenario_id": parent_id, + "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else [], + } + + if not params.force: + phash = self.cache.params_hash(params_for_hash) + cached = self.cache.load(method_name, project_id, phash) + if cached: + logger.info("[Urbanomy] cache hit for project %s", project_id) + return self._sanitize_for_json(cached["results"]) + + context_blocks, context_territories_gdf, _ = await self.context.get_shared_context(project_id, token) + + scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id] + + scenario_results: dict[int, list[dict]] = {} + + for s in target: + sid = int(s["scenario_id"]) + try: + proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, + data_id=sid, + source=None, + year=None, + project=True, + ) + + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( + sid, proj_src, proj_year, token + ) + + records = await self._compute_urbanomy_for_single_scenario( + scenario_id=sid, + scenario_blocks=scenario_blocks, + context_blocks=context_blocks, + context_territories_gdf=context_territories_gdf, + token=token, + only_parent_ids=only_parent_ids, + ) + scenario_results[sid] = records + except Exception as exc: + logger.error("[Urbanomy] Scenario %s failed: %s", sid, exc) + logger.exception(exc) + scenario_results[sid] = [] + + pivot = self._pivot_urbanomy_by_territory_and_indicator(scenario_results) + pivot = self._sanitize_for_json(pivot) + + project_info = await self.urban_api_client.get_project(project_id, token) + updated_at = project_info.get("updated_at") + + self.cache.save( + method_name, + project_id, + params_for_hash, + {"results": pivot}, + scenario_updated_at=updated_at, + ) + + return pivot + async def evaluate_social_economical_metrics( self, token: str, params: SocioEconomicByProjectDTO ): diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index ce4a0f7..367e28f 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -18,6 +18,7 @@ "values_transformation": effects_service.values_transformation, "values_oriented_requirements": effects_service.values_oriented_requirements, "social_economical_metrics": effects_service.evaluate_social_economical_metrics, + "urbanomy_metrics": effects_service.evaluate_urbanomy_metrics, } @@ -115,7 +116,7 @@ async def create_task( dict: { "task_id": str, "status": "queued" | "running" | "done" } """ - project_based_methods = {"social_economical_metrics"} + project_based_methods = {"social_economical_metrics", "urbanomy_metrics"} if method in project_based_methods: owner_id = getattr(params, "project_id", None) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index b1842f0..2e172c7 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -160,14 +160,14 @@ async def create_scenario_task( "**Task id format**: `{method}_{project_id}_{phash}`" )) async def create_project_task( - method: Literal["social_economical_metrics"], + method: Literal["social_economical_metrics", "urbanomy_metrics"], params: Annotated[SocioEconomicByProjectDTO, Depends()], token: Annotated[str, Depends(verify_token)], ): """ separate endpoint for project-based tasks (e.g., socio_economics). """ - if method != "social_economical_metrics": + if method not in ["social_economical_metrics", "urbanomy_metrics"]: raise http_exception(400, f"method '{method}' is not project-based", method) try: diff --git a/requirements.txt b/requirements.txt index bc651874f5a0c64f4c8e5a50508c005c2d004846..4cf76065e4b2609816a87f68a3fd9ff39b6b90e6 100644 GIT binary patch delta 138 zcmcb_dW3C)MSVI$CPN8>HbVxGEMX{MC}yx?&<8?@cm_i$LlT1?Lo!1?LoQI%lfi`{ z6v)c~i|Yf~ML_vPhCHx*B~T?qp94c0Ln=_b1gIwwXlf=yJVP0S5d$v+7to9ppzdIx MTs}k5M%P{@0J&ouZU6uP delta 10 RcmX@Yc8PU@#l}^$m;f0I1YrOG From e7b943f7282d38453c6cd9ebcbcb5ea2bde2e11a Mon Sep 17 00:00:00 2001 From: voronapxl Date: Thu, 4 Dec 2025 21:23:44 +0300 Subject: [PATCH 127/161] feat(Urbanomy): 1. Merged logic for economical indicators into f22 --- app/effects_api/effects_service.py | 133 ++++++++++++++++++++++++----- 1 file changed, 110 insertions(+), 23 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 237cfb4..273066a 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1,6 +1,7 @@ import asyncio import json import math +import re from pathlib import Path from typing import Any, Dict, Literal @@ -1435,9 +1436,85 @@ async def _compute_for_single_scenario( long_df = await self._attach_indicator_names(long_df) - return long_df[["territory_id", "indicator_name", "value"]].to_dict( - orient="records" - ) + territory_id_hint: int | None = None + if "is_project" in before_blocks.columns: + proj_mask = before_blocks["is_project"].fillna(False).astype(bool) + territory_id_hint = self._pick_single_territory_id(before_blocks.loc[proj_mask, "parent"]) + + urbanomy_records: list[dict] = [] + try: + if territory_id_hint is not None: + urbanomy_records = await self._compute_urbanomy_for_single_scenario( + scenario_id=scenario_id, + scenario_blocks=scenario_blocks, + context_blocks=context_blocks, + context_territories_gdf=context_territories_gdf, + token=token, + only_parent_ids=only_parent_ids, + territory_id_hint=territory_id_hint, + ) + except Exception as exc: + logger.warning(f"Urbanomy failed for scenario={scenario_id}: {exc}") + + records = long_df[["territory_id", "indicator_name", "value"]].to_dict(orient="records") + + if urbanomy_records: + for r in urbanomy_records: + records.append( + { + "territory_id": self._clean_number(r.get("territory_id")), + "indicator_name": r.get("indicator_name"), + "value": self._clean_number(r.get("value")), + } + ) + + return records + + def _json_safe_number(self, v: Any) -> float | int | None: + """Convert any numeric-like value to a JSON-safe primitive (no NaN/Inf). + + Supports strings with thousand separators like '12 438 136 946' or '2\u00A0339\u00A0984'. + """ + if v is None: + return None + + if isinstance(v, np.generic): + v = v.item() + + if isinstance(v, bool): + return int(v) + + if isinstance(v, int): + return v + + if isinstance(v, float): + return v if math.isfinite(v) else None + + if isinstance(v, str): + s = v.strip() + if not s: + return None + + s = re.compile(r"[\s\u00A0\u202F]").sub("", s) + s = s.replace(",", ".") + s = re.sub(r"[^0-9\.\-]+", "", s) + + if s in {"", "-", ".", "-."}: + return None + + try: + f = float(s) + except ValueError: + return None + + return f if math.isfinite(f) else None + + try: + f = float(v) + except (TypeError, ValueError): + return None + + return f if math.isfinite(f) else None async def _pivot_results_by_territory( self, @@ -1489,12 +1566,11 @@ async def _pivot_results_by_territory( continue val_raw = rec.get("value") - try: - val = float(val_raw) if val_raw is not None else None - except (TypeError, ValueError) as exc: + val = self._json_safe_number(val_raw) + if val_raw is not None and val is None: logger.warning( f"[Effects] Failed to parse value for scenario {scenario_id}, " - f"territory {t_id}, indicator '{ind_name}': {val_raw} ({exc})" + f"territory {t_id}, indicator '{ind_name}': {val_raw}" ) val = None @@ -1544,9 +1620,7 @@ def _pick_single_territory_id(self, parents: pd.Series) -> int | None: mode = int(s.mode().iat[0]) logger.warning( - "Multiple territory_ids detected for scenario project blocks (%s). Using mode=%s", - sorted(map(int, uniq)), - mode, + f"Multiple territory_ids detected for scenario project blocks {sorted(map(int, uniq))}. Using mode={mode}", ) return mode @@ -1579,6 +1653,7 @@ async def _compute_urbanomy_for_single_scenario( context_territories_gdf: gpd.GeoDataFrame, token: str, only_parent_ids: set[int] | None = None, + territory_id_hint: int | None = None, ) -> list[dict]: """Compute Urbanomy metrics for one scenario and return records: [{territory_id, indicator_id, indicator_name, value}, ...] @@ -1612,21 +1687,33 @@ async def _compute_urbanomy_for_single_scenario( logger.info("Urbanomy: no project blocks for scenario=%s", scenario_id) return [] - territories = context_territories_gdf.to_crs(project_blocks.crs) - assigned = assign_objects(project_blocks, territories.rename(columns={"parent": "name"})) - project_blocks["parent"] = pd.to_numeric(assigned["name"], errors="coerce") + territory_id: int | None = None - if only_parent_ids: - project_blocks = project_blocks[project_blocks["parent"].isin(only_parent_ids)].copy() + if territory_id_hint is not None: + territory_id = int(territory_id_hint) + if only_parent_ids and territory_id not in only_parent_ids: + logger.info( + "Urbanomy: territory_id=%s not in only_parent_ids, skipping scenario=%s", + territory_id, + scenario_id, + ) + return [] + else: + territories = context_territories_gdf.to_crs(project_blocks.crs) + assigned = assign_objects(project_blocks, territories.rename(columns={"parent": "name"})) + project_blocks["parent"] = pd.to_numeric(assigned["name"], errors="coerce") - territory_id = self._pick_single_territory_id(project_blocks["parent"]) - if territory_id is None: - logger.warning("Urbanomy: failed to detect territory_id for scenario=%s", scenario_id) - return [] + if only_parent_ids: + project_blocks = project_blocks[project_blocks["parent"].isin(only_parent_ids)].copy() - project_blocks = project_blocks[project_blocks["parent"] == territory_id].copy() - if project_blocks.empty: - return [] + territory_id = self._pick_single_territory_id(project_blocks["parent"]) + if territory_id is None: + logger.warning("Urbanomy: failed to detect territory_id for scenario=%s", scenario_id) + return [] + + project_blocks = project_blocks[project_blocks["parent"] == territory_id].copy() + if project_blocks.empty: + return [] potential_df = await self._fetch_land_use_potentials(scenario_id=scenario_id, token=token) @@ -1651,7 +1738,7 @@ async def _compute_urbanomy_for_single_scenario( df = df[df["value"].notna()].copy() df["territory_id"] = int(territory_id) - df = df.rename(columns={"indicator": "indicator_name"}) # временное имя из расчёта + df = df.rename(columns={"indicator": "indicator_name"}) df = await self._attach_urbanomy_indicator_names(df) From 7c3241b03d3020354112814a711e611d5aa50249 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 5 Dec 2025 12:45:02 +0300 Subject: [PATCH 128/161] fix(values_oriented_requirements): 1. Inappropriate import fix --- app/effects_api/tasks_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index b1842f0..ad4ac84 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -325,7 +325,7 @@ async def get_values_oriented_requirements_layer( service_name: str, token: str = Depends(verify_token), ): - base_id = await effects_utils._resolve_base_id(token, scenario_id) + base_id = await effects_utils.resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: From 239e540695721eb267329fa01f9161387f59d713 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 5 Dec 2025 13:08:03 +0300 Subject: [PATCH 129/161] fix(tasks_controller): 1. Fixed error in import --- app/effects_api/tasks_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 2e172c7..1ab8c58 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -325,7 +325,7 @@ async def get_values_oriented_requirements_layer( service_name: str, token: str = Depends(verify_token), ): - base_id = await effects_utils._resolve_base_id(token, scenario_id) + base_id = await effects_utils.resolve_base_id(token, scenario_id) cached = file_cache.load_latest("values_oriented_requirements", base_id) if not cached: From 1ca2a0cd806ee08fbe51c4b262b616a32c0dd51a Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 5 Dec 2025 14:51:18 +0300 Subject: [PATCH 130/161] fix(f_22): 1. Fixed behaviour where regional_scenario_id was not used in cache getter --- app/effects_api/effects_service.py | 59 ++++++++++++----------------- app/effects_api/tasks_controller.py | 53 ++++++++++++++++++++------ 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 513c4ae..2e49f3f 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1401,39 +1401,37 @@ async def _pivot_results_by_territory( return pivot - async def evaluate_social_economical_metrics( - self, token: str, params: SocioEconomicByProjectDTO - ): + def _filter_by_territories(self, results: dict, territory_ids: set[int] | None) -> dict: + """Filter cached results by territory ids if provided.""" + if not territory_ids: + return results + return {tid: results[tid] for tid in territory_ids if tid in results} + + async def evaluate_social_economical_metrics(self, token: str, params: SocioEconomicByProjectDTO): """ Project-level multi-scenario calculation with a shared context. Return: {territory_id: {indicator_id: {scenario_id: value}}} """ - project_id = params.project_id parent_id = params.regional_scenario_id - method_name = "social_economical_metrics" - only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None + requested_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None params_for_hash = { "project_id": project_id, "regional_scenario_id": parent_id, - "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else [], } if not params.force: phash = self.cache.params_hash(params_for_hash) cached = self.cache.load(method_name, project_id, phash) if cached: - logger.info( - f"[Effects] cache hit for project {project_id}, returning cached data" - ) - return cached["results"] + logger.info(f"[Effects] cache hit for project {project_id}, parent={parent_id}") + results_all = cached["results"] + return self._filter_by_territories(results_all, requested_ids) else: - logger.info( - f"[Effects] force=True, recalculating metrics for project {project_id}" - ) + logger.info(f"[Effects] force=True, recalculating metrics for project {project_id}, parent={parent_id}") context_blocks, context_territories_gdf, service_types = ( await self.context.get_shared_context(project_id, token) @@ -1441,24 +1439,20 @@ async def evaluate_social_economical_metrics( scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) target = [ - s - for s in scenarios + s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id ] - logger.info( - f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})" - ) + logger.info(f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})") - only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None results: dict[int, list[dict]] = {} + only_parent_ids = None + for s in target: sid = int(s["scenario_id"]) try: - proj_src, proj_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=None, year=None, project=True - ) + proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, data_id=sid, source=None, year=None, project=True ) records = await self._compute_for_single_scenario( @@ -1471,17 +1465,13 @@ async def evaluate_social_economical_metrics( token=token, only_parent_ids=only_parent_ids, ) - results[sid] = records - except Exception as exc: - logger.error( - f"[Effects] Scenario {sid} failed during socio-economic computation: {exc}" - ) + logger.error(f"[Effects] Scenario {sid} failed during socio-economic computation: {exc}") logger.exception(exc) results[sid] = [] - results = await self._pivot_results_by_territory(results) + results_all = await self._pivot_results_by_territory(results) project_info = await self.urban_api_client.get_project(project_id, token) updated_at = project_info.get("updated_at") @@ -1490,11 +1480,10 @@ async def evaluate_social_economical_metrics( method_name, project_id, params_for_hash, - {"results": results}, + {"results": results_all}, scenario_updated_at=updated_at, ) - logger.success( - f"[Effects] socio-economic metrics cached for project_id={project_id}" - ) - return results + logger.success(f"[Effects] socio-economic metrics cached for project_id={project_id}, parent={parent_id}") + + return self._filter_by_territories(results_all, requested_ids) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index ad4ac84..9423237 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -1,7 +1,7 @@ import asyncio from typing import Annotated, Union, Literal -from fastapi import APIRouter +from fastapi import APIRouter, Query from fastapi.params import Depends from loguru import logger from starlette.responses import JSONResponse @@ -402,7 +402,40 @@ async def get_values_oriented_requirements_table( "methods the owner is a **project id**." ), response_model=Union[FeatureCollectionModel, SocioEconomicMetricsResponseSchema]) -async def get_layer(project_scenario_id: int, method_name: str): +async def get_layer( + project_scenario_id: int, + method_name: str, + regional_scenario_id: int | None = Query( + default=None, + description="Required for social_economical_metrics (project-based).", + ), +): + if method_name == "social_economical_metrics": + if regional_scenario_id is None: + raise http_exception( + 400, + "regional_scenario_id is required for social_economical_metrics", + {"project_id": project_scenario_id}, + ) + + params_for_hash = { + "project_id": project_scenario_id, + "regional_scenario_id": regional_scenario_id, + } + phash = file_cache.params_hash(params_for_hash) + + cached = file_cache.load(method_name, project_scenario_id, phash) + if not cached: + raise http_exception( + 404, + "no saved result for this project + regional_scenario_id", + {"project_id": project_scenario_id, "regional_scenario_id": regional_scenario_id}, + ) + + results = cached["data"]["results"] + + return SocioEconomicMetricsResponseSchema(results=results) + cached = file_cache.load_latest(method_name, project_scenario_id) if not cached: raise http_exception(404, "no saved result for this scenario", project_scenario_id) @@ -410,16 +443,14 @@ async def get_layer(project_scenario_id: int, method_name: str): data = cached["data"] if method_name == "values_transformation": - fc = FeatureCollectionModel.model_validate(data) - return fc - - if method_name == "social_economical_metrics": - data = cached["data"]["results"] - return SocioEconomicMetricsResponseSchema(results=data) - - else: - raise http_exception(400, "Method not implemented", method_name, "Allowed methods: values_transformation, social_economical_metrics") + return FeatureCollectionModel.model_validate(data) + raise http_exception( + 400, + "Method not implemented", + method_name, + "Allowed methods: values_transformation, social_economical_metrics", + ) @router.get("/get_provisions/{scenario_id}", summary="Get total provision values", From 01f0911516375bdd4ef7915a30b23b7ad7d4a916 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 5 Dec 2025 18:28:54 +0300 Subject: [PATCH 131/161] feat(f_22_urb_with_fix): 1. Merged fixed regional scenario logic with Urbanomy f22 --- app/effects_api/effects_service.py | 69 ++++++++++++++---------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 273066a..e2a4f19 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1847,64 +1847,58 @@ async def evaluate_urbanomy_metrics(self, token: str, params: SocioEconomicByPro return pivot - async def evaluate_social_economical_metrics( - self, token: str, params: SocioEconomicByProjectDTO - ): + def _filter_by_territories(self, results: dict, territory_ids: set[int] | None) -> dict: + """Filter cached results by territory ids if provided.""" + if not territory_ids: + return results + return {tid: results[tid] for tid in territory_ids if tid in results} + + async def evaluate_social_economical_metrics(self, token: str, params: SocioEconomicByProjectDTO): """ Project-level multi-scenario calculation with a shared context. - Return: {territory_id: {indicator_id: {scenario_id: value}}} + Return: {territory_id: {indicator_name: {scenario_id: value}}} """ - project_id = params.project_id parent_id = params.regional_scenario_id - method_name = "social_economical_metrics" - only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None + requested_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None params_for_hash = { "project_id": project_id, "regional_scenario_id": parent_id, - "territory_ids": sorted(list(only_parent_ids)) if only_parent_ids else [], } if not params.force: phash = self.cache.params_hash(params_for_hash) cached = self.cache.load(method_name, project_id, phash) if cached: - logger.info( - f"[Effects] cache hit for project {project_id}, returning cached data" - ) - return cached["results"] + logger.info("[Effects] cache hit for project %s, parent=%s", project_id, parent_id) + results_all = self._sanitize_for_json(cached["results"]) + return self._filter_by_territories(results_all, requested_ids) else: - logger.info( - f"[Effects] force=True, recalculating metrics for project {project_id}" - ) + logger.info("[Effects] force=True, recalculating metrics for project %s, parent=%s", project_id, parent_id) - context_blocks, context_territories_gdf, service_types = ( - await self.context.get_shared_context(project_id, token) - ) + context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, + token) scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) - target = [ - s - for s in scenarios - if (s.get("parent_scenario") or {}).get("id") == parent_id - ] - logger.info( - f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})" - ) + target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id] + logger.info("[Effects] matched %s scenarios in project %s (parent=%s)", len(target), project_id, parent_id) + + only_parent_ids = None - only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None results: dict[int, list[dict]] = {} for s in target: sid = int(s["scenario_id"]) try: - proj_src, proj_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, data_id=sid, source=None, year=None, project=True - ) + proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, + data_id=sid, + source=None, + year=None, + project=True, ) records = await self._compute_for_single_scenario( @@ -1917,7 +1911,6 @@ async def evaluate_social_economical_metrics( token=token, only_parent_ids=only_parent_ids, ) - results[sid] = records except Exception as exc: @@ -1927,7 +1920,8 @@ async def evaluate_social_economical_metrics( logger.exception(exc) results[sid] = [] - results = await self._pivot_results_by_territory(results) + results_all = await self._pivot_results_by_territory(results) + results_all = self._sanitize_for_json(results_all) project_info = await self.urban_api_client.get_project(project_id, token) updated_at = project_info.get("updated_at") @@ -1936,11 +1930,10 @@ async def evaluate_social_economical_metrics( method_name, project_id, params_for_hash, - {"results": results}, + {"results": results_all}, scenario_updated_at=updated_at, ) - logger.success( - f"[Effects] socio-economic metrics cached for project_id={project_id}" - ) - return results + logger.success("[Effects] socio-economic metrics cached for project_id=%s, parent=%s", project_id, parent_id) + return self._filter_by_territories(results_all, requested_ids) + From 373e8fa618a68c2fff587e1f1ab70d7939c369cd Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 8 Dec 2025 15:43:00 +0300 Subject: [PATCH 132/161] feat(create_project_task): 1. Deleted extra Literal in parameters --- app/effects_api/tasks_controller.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 11e6273..411db64 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -160,14 +160,14 @@ async def create_scenario_task( "**Task id format**: `{method}_{project_id}_{phash}`" )) async def create_project_task( - method: Literal["social_economical_metrics", "urbanomy_metrics"], + method: Literal["social_economical_metrics"], params: Annotated[SocioEconomicByProjectDTO, Depends()], token: Annotated[str, Depends(verify_token)], ): """ separate endpoint for project-based tasks (e.g., socio_economics). """ - if method not in ["social_economical_metrics", "urbanomy_metrics"]: + if method not in ["social_economical_metrics"]: raise http_exception(400, f"method '{method}' is not project-based", method) try: From 15eadf0cc73f933943bb8b23821ee27e0c0feb42 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 9 Dec 2025 17:08:15 +0300 Subject: [PATCH 133/161] feat(f_22): 1. Removed unnecessary method 2. Remover unnecessary "if" blocks --- app/effects_api/effects_service.py | 110 ++++-------------------- app/effects_api/modules/task_service.py | 3 +- 2 files changed, 17 insertions(+), 96 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c51d591..7e09e0b 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -89,29 +89,31 @@ def __init__( scenario_service: ScenarioService, context_service: ContextService, effects_utils: EffectsUtils, - _land_price_model_lock: asyncio.Lock = asyncio.Lock(), - _indicator_name_cache: dict[int, str] = {}, - _indicator_name_cache_lock: asyncio.Lock = asyncio.Lock(), + _land_price_model_lock: asyncio.Lock | None = None, + _indicator_name_cache: dict[int, str] | None = None, + _indicator_name_cache_lock: asyncio.Lock | None = None, _land_price_model: CatBoostRegressor | None = None, _catboost_model_path: str = "./catboost_model.cbm", - _urbanomy_indicator_name_cache: dict[int, str] = {}, - _urbanomy_indicator_name_cache_lock = asyncio.Lock() - + _urbanomy_indicator_name_cache: dict[int, str] | None = None, + _urbanomy_indicator_name_cache_lock: asyncio.Lock | None = None, ): self._land_price_model = _land_price_model - self._land_price_model_lock = _land_price_model_lock - self.__name__ = "EffectsService" - self.bn_social_regressor: SocialRegressor = SocialRegressor() + self._land_price_model_lock = _land_price_model_lock or asyncio.Lock() + + self._indicator_name_cache = _indicator_name_cache or {} + self._indicator_name_cache_lock = _indicator_name_cache_lock or asyncio.Lock() + + self._urbanomy_indicator_name_cache = _urbanomy_indicator_name_cache or {} + self._urbanomy_indicator_name_cache_lock = _urbanomy_indicator_name_cache_lock or asyncio.Lock() + + self._catboost_model_path = _catboost_model_path + self.urban_api_client = urban_api_client self.cache = cache self.scenario = scenario_service self.context = context_service self.effects_utils = effects_utils - self._indicator_name_cache_lock = _indicator_name_cache_lock - self._indicator_name_cache = _indicator_name_cache - self._catboost_model_path = _catboost_model_path - self._urbanomy_indicator_name_cache = _urbanomy_indicator_name_cache - self._urbanomy_indicator_name_cache_lock = _urbanomy_indicator_name_cache_lock + self.__name__ = "EffectsService" async def build_hash_params( self, @@ -1191,9 +1193,6 @@ async def _load_indicator_name_cache(self) -> dict[int, str]: async def _load_urbanomy_indicator_name_cache(self) -> dict[int, str]: """Load Urbanomy indicator_id -> name_full mapping once.""" - if self._urbanomy_indicator_name_cache: - return self._urbanomy_indicator_name_cache - async with self._urbanomy_indicator_name_cache_lock: if self._urbanomy_indicator_name_cache: return self._urbanomy_indicator_name_cache @@ -1293,9 +1292,6 @@ def _map_name(v: Any) -> str | None: async def _get_land_price_model(self) -> CatBoostRegressor: """Load CatBoost model once and reuse it.""" - if self._land_price_model is not None: - return self._land_price_model - async with self._land_price_model_lock: if self._land_price_model is not None: return self._land_price_model @@ -1769,80 +1765,6 @@ def _pivot_urbanomy_by_territory_and_indicator( return pivot - async def evaluate_urbanomy_metrics(self, token: str, params: SocioEconomicByProjectDTO): - """ - Urbanomy project-level calculation. - Return: {territory_id: {indicator_id: {scenario_id: value}}} - """ - project_id = params.project_id - parent_id = params.regional_scenario_id - method_name = "urbanomy_metrics" - - only_parent_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None - params_for_hash = { - "project_id": project_id, - "regional_scenario_id": parent_id, - } - - if not params.force: - phash = self.cache.params_hash(params_for_hash) - cached = self.cache.load(method_name, project_id, phash) - if cached: - logger.info(f"[Urbanomy] cache hit for project {parent_id}") - return self._sanitize_for_json(cached["results"]) - - context_blocks, context_territories_gdf, _ = await self.context.get_shared_context(project_id, token) - - scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) - target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id] - - scenario_results: dict[int, list[dict]] = {} - - for s in target: - sid = int(s["scenario_id"]) - try: - proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, - data_id=sid, - source=None, - year=None, - project=True, - ) - - scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( - sid, proj_src, proj_year, token - ) - - records = await self._compute_urbanomy_for_single_scenario( - scenario_id=sid, - scenario_blocks=scenario_blocks, - context_blocks=context_blocks, - context_territories_gdf=context_territories_gdf, - token=token, - only_parent_ids=only_parent_ids, - ) - scenario_results[sid] = records - except Exception as exc: - logger.error(f"[Urbanomy] Scenario {sid} failed: {exc}") - logger.exception(exc) - scenario_results[sid] = [] - - pivot = self._pivot_urbanomy_by_territory_and_indicator(scenario_results) - pivot = self._sanitize_for_json(pivot) - - project_info = await self.urban_api_client.get_project(project_id, token) - updated_at = project_info.get("updated_at") - - self.cache.save( - method_name, - project_id, - params_for_hash, - {"results": pivot}, - scenario_updated_at=updated_at, - ) - - return pivot - def _filter_by_territories(self, results: dict, territory_ids: set[int] | None) -> dict: """Filter cached results by territory ids if provided.""" if not territory_ids: diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 367e28f..081dcda 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -17,8 +17,7 @@ "territory_transformation": effects_service.territory_transformation, "values_transformation": effects_service.values_transformation, "values_oriented_requirements": effects_service.values_oriented_requirements, - "social_economical_metrics": effects_service.evaluate_social_economical_metrics, - "urbanomy_metrics": effects_service.evaluate_urbanomy_metrics, + "social_economical_metrics": effects_service.evaluate_social_economical_metrics } From 2db25bd5125057b2c8bb2ef5dc7a64b4a8800424 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 10 Dec 2025 22:44:39 +0300 Subject: [PATCH 134/161] feat(features): 1. Removed unnecessary imports from context and scenario services 2. Adjusted sources priority for context to OSM 3. Removed zeroes from values_transformation method --- app/common/utils/geodata.py | 7 +-- app/effects_api/effects_service.py | 62 ++++++++++++--------- app/effects_api/modules/context_service.py | 2 - app/effects_api/modules/scenario_service.py | 1 - 4 files changed, 40 insertions(+), 32 deletions(-) diff --git a/app/common/utils/geodata.py b/app/common/utils/geodata.py index ab20167..60fdfe1 100644 --- a/app/common/utils/geodata.py +++ b/app/common/utils/geodata.py @@ -10,7 +10,6 @@ from app.common.exceptions.http_exception_wrapper import http_exception from app.effects_api.constants.const import COL_RU, ROADS_ID, SPEED -from app.effects_api.modules.scenario_service import SOURCES_PRIORITY async def gdf_to_ru_fc_rounded(gdf: gpd.GeoDataFrame, ndigits: int = 6) -> dict: @@ -82,7 +81,7 @@ async def get_best_functional_zones_source( source: str | None = None, year: int | None = None, ) -> tuple[int | None, str | None]: - + sources_priority = ["OSM", "PZZ", "User"] if source and year: row = sources_df.query("source == @source and year == @year") if not row.empty: @@ -94,11 +93,11 @@ async def get_best_functional_zones_source( return int(rows["year"].max()), source return await get_best_functional_zones_source(sources_df, None, year) elif year and not source: - for s in SOURCES_PRIORITY: + for s in sources_priority: row = sources_df.query("source == @s and year == @year") if not row.empty: return year, s - for s in SOURCES_PRIORITY: + for s in sources_priority: rows = sources_df.query("source == @s") if not rows.empty: return int(rows["year"].max()), s diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 7e09e0b..528c87d 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -21,7 +21,7 @@ from blocksnet.blocks.assignment import assign_objects from blocksnet.config import service_types_config from blocksnet.enums import LandUse -from blocksnet.machine_learning.regression import DensityRegressor, SocialRegressor +from blocksnet.machine_learning.regression import DensityRegressor from blocksnet.optimization.services import ( AreaSolution, Facade, @@ -774,7 +774,10 @@ async def values_transformation( for c in ["site_area", "build_floor_area", "capacity", "count"] if c in solution_df.columns ] - zero_dict = {m: 0 for m in metrics} + + if metrics: + non_zero_mask = (solution_df[metrics].fillna(0) != 0).any(axis=1) + solution_df = solution_df[non_zero_mask].copy() if len(metrics): agg = ( @@ -812,10 +815,18 @@ def _row_to_dict(s: pd.Series) -> dict: if s not in wide.columns: wide[s] = np.nan - def _fill_cell(x): - return x if isinstance(x, dict) else zero_dict.copy() + cells = ( + agg.apply(_row_to_dict, axis=1) + if len(metrics) + else agg.apply(lambda _: {}, axis=1) + ) + wide = cells.unstack("service_type").reindex(index=test_blocks.index) + + all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) + for s in all_services: + if s not in wide.columns: + wide[s] = np.nan - wide = wide.applymap(_fill_cell) wide = wide[all_services] test_blocks_with_services: gpd.GeoDataFrame = test_blocks.join(wide, how="left") @@ -1153,8 +1164,19 @@ def _clean_number(self, v): return float(v) return v + def _format_indicator_label(self, ind_info: dict[str, Any]) -> str: + """Build display label: 'name_full (unit)' if measurement_unit exists.""" + name = (ind_info.get("name_full") or "").strip() + if not name: + name = (ind_info.get("name_short") or "").strip() + + mu = ind_info.get("measurement_unit") or {} + unit = (mu.get("name") or "").strip() + + return f"{name} ({unit})" if unit else name + async def _load_indicator_name_cache(self) -> dict[int, str]: - """Load indicator_id -> name_full mapping once, based on INDICATORS_MAPPING.""" + """Load indicator_id -> formatted label mapping once, based on INDICATORS_MAPPING.""" if self._indicator_name_cache: return self._indicator_name_cache @@ -1169,48 +1191,38 @@ async def _load_indicator_name_cache(self) -> dict[int, str]: try: indicator_ids.add(int(v)) except (TypeError, ValueError): - logger.warning( - "Skipping invalid indicator id in INDICATORS_MAPPING: %r", v - ) + logger.warning("Skipping invalid indicator id in INDICATORS_MAPPING: %r", v) - logger.info(f"Preloading indicator names for {len(indicator_ids)} indicators") + logger.info("Preloading indicator names for %s indicators", len(indicator_ids)) id_to_name: dict[int, str] = {} for ind_id in sorted(indicator_ids): try: ind_info = await self.urban_api_client.get_indicator_info(ind_id) - id_to_name[ind_id] = ind_info["name_full"] + id_to_name[ind_id] = self._format_indicator_label(ind_info) except Exception as exc: - logger.warning( - f"Failed to fetch indicator info for id={ind_id}: {exc}", - ) + logger.warning("Failed to fetch indicator info for id=%s: %s", ind_id, exc) self._indicator_name_cache = id_to_name - logger.info( - f"Indicator name cache loaded: {len(self._indicator_name_cache)} entries" - ) + logger.info("Indicator name cache loaded: %s entries", len(self._indicator_name_cache)) return self._indicator_name_cache async def _load_urbanomy_indicator_name_cache(self) -> dict[int, str]: - """Load Urbanomy indicator_id -> name_full mapping once.""" + """Load Urbanomy indicator_id -> formatted label mapping once.""" async with self._urbanomy_indicator_name_cache_lock: if self._urbanomy_indicator_name_cache: return self._urbanomy_indicator_name_cache indicator_ids = {int(v) for v in URBANOMY_INDICATORS_MAPPING.values() if v is not None} - logger.info(f"Preloading Urbanomy indicator names for {len(indicator_ids)} indicators") + logger.info("Preloading Urbanomy indicator names for %s indicators", len(indicator_ids)) id_to_name: dict[int, str] = {} for ind_id in sorted(indicator_ids): try: ind_info = await self.urban_api_client.get_indicator_info(ind_id) - id_to_name[ind_id] = ind_info["name_full"] + id_to_name[ind_id] = self._format_indicator_label(ind_info) except Exception as exc: - logger.warning( - "Failed to fetch Urbanomy indicator info for id=%s: %s", - ind_id, - exc, - ) + logger.warning("Failed to fetch Urbanomy indicator info for id=%s: %s", ind_id, exc) self._urbanomy_indicator_name_cache = id_to_name logger.info( diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 17cb901..7a06edc 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -30,8 +30,6 @@ ) from app.effects_api.modules.services_service import adapt_services -_SOURCES_PRIORITY = ["PZZ", "OSM", "User"] - class ContextService: """Context layer orchestration (blocks, buildings, services, fzones).""" diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index f762e35..11735d3 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -23,7 +23,6 @@ from app.effects_api.modules.service_type_service import adapt_service_types from app.effects_api.modules.services_service import adapt_services -SOURCES_PRIORITY = ["PZZ", "OSM", "User"] def close_gaps(gdf, tolerance): # taken from momepy From 761c8c558e60408638b59612551430e45a9f9b35 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 12 Dec 2025 15:52:21 +0300 Subject: [PATCH 135/161] fix(build_hash_params): 1. Returned token in API requests method where was missing --- app/clients/urban_api_client.py | 6 ++++-- app/effects_api/effects_service.py | 4 ++-- app/effects_api/modules/context_service.py | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 036119a..09132fb 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -373,9 +373,11 @@ async def get_territory_geometry(self, territory_id: int): geom = json.dumps(geom) return shapely.from_geojson(geom) - async def get_base_scenario_id(self, project_id: int) -> int: + async def get_base_scenario_id(self, project_id: int, token: str) -> int: + headers = {"Authorization": f"Bearer {token}"} scenarios = await self.json_handler.get( - f"/api/v1/projects/{project_id}/scenarios" + f"/api/v1/projects/{project_id}/scenarios", + headers=headers, ) base = next((s for s in scenarios if s.get("is_based")), None) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 528c87d..bb3932b 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -123,7 +123,7 @@ async def build_hash_params( project_id = ( await self.urban_api_client.get_scenario_info(params.scenario_id, token) )["project"]["project_id"] - base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) + base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id, token) base_src, base_year = ( await self.urban_api_client.get_optimal_func_zone_request_data( token, base_scenario_id, None, None @@ -239,7 +239,7 @@ async def territory_transformation_scenario_before( info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] project_id = info["project"]["project_id"] - base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id) + base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id, token) params = await self.get_optimal_func_zone_data(params, token) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 7a06edc..0d11326 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -342,7 +342,7 @@ async def get_shared_context( territory_id = (await self.client.get_all_project_info(project_id, token))[ "territory" ]["id"] - base_sid = await self.client.get_base_scenario_id(project_id) + base_sid = await self.client.get_base_scenario_id(project_id, token) ctx_src, ctx_year = await self.client.get_optimal_func_zone_request_data( token=token, data_id=base_sid, source=None, year=None, project=False ) From 39a81f265a87dc6706a775d48869571bf48146f9 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 12 Dec 2025 17:21:32 +0300 Subject: [PATCH 136/161] fix(evaluate_social_economical_metrics): 1. Fixed cache loading --- app/effects_api/effects_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index bb3932b..6425d7d 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1804,7 +1804,8 @@ async def evaluate_social_economical_metrics(self, token: str, params: SocioEcon cached = self.cache.load(method_name, project_id, phash) if cached: logger.info(f"[Effects] cache hit for project {project_id}, parent={parent_id}") - results_all = self._sanitize_for_json(cached["results"]) + data = cached.get("data", cached) + results_all = self._sanitize_for_json(data["results"]) return self._filter_by_territories(results_all, requested_ids) else: logger.info(f"[Effects] force=True, recalculating metrics for project {project_id}, parent={parent_id}") From c8aa3983c055130dd14f4292a27d9a8fc74111b3 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Fri, 12 Dec 2025 20:01:54 +0300 Subject: [PATCH 137/161] fix(evaluate_social_economical_metrics): 1. Fixed state status for F22 --- app/common/caching/caching_service.py | 48 +++++++++++++++---------- app/effects_api/effects_service.py | 21 +++++------ app/effects_api/modules/task_service.py | 1 - app/effects_api/tasks_controller.py | 2 +- 4 files changed, 39 insertions(+), 33 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index f612a1e..cafc3d9 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -18,8 +18,20 @@ def _safe(s: str) -> str: return _FILENAME_RE.sub("", s) -def _file_name(method: str, scenario_id: int, phash: str, day: str) -> Path: - name = f"{day}__scenario_{scenario_id}__{_safe(method)}__{phash}.json" +PROJECT_BASED_METHODS: set[str] = { + "social_economical_metrics", + "urbanomy_metrics", +} + + +def _owner_prefix(method: str) -> str: + """Return cache key prefix based on method semantics.""" + return "project" if method in PROJECT_BASED_METHODS else "scenario" + + +def _file_name(method: str, owner_id: int, phash: str, day: str) -> Path: + prefix = _owner_prefix(method) + name = f"{day}__{prefix}_{owner_id}__{_safe(method)}__{phash}.json" return _CACHE_DIR / name @@ -42,7 +54,7 @@ def params_hash(self, params: dict[str, Any]) -> str: def save( self, method: str, - scenario_id: int, + owner_id: int, params: dict[str, Any], data: dict[str, Any], scenario_updated_at: str | None = None, @@ -54,7 +66,7 @@ def save( phash = self.params_hash(params) day = datetime.now().strftime("%Y%m%d") - path = _file_name(method, scenario_id, phash, day) + path = _file_name(method, owner_id, phash, day) to_save = { "meta": { "timestamp": datetime.now().isoformat(), @@ -66,25 +78,21 @@ def save( path.write_text(json.dumps(to_save, ensure_ascii=False), encoding="utf-8") return path - def _latest_path(self, method: str, scenario_id: int) -> Path | None: - if method == "social_economical_metric": - pattern = f"*__project_{scenario_id}__{_safe(method)}__*.json" - else: - pattern = f"*__scenario_{scenario_id}__{_safe(method)}__*.json" + def _latest_path(self, method: str, owner_id: int) -> Path | None: + prefix = _owner_prefix(method) + pattern = f"*__{prefix}_{owner_id}__{_safe(method)}__*.json" files = sorted(_CACHE_DIR.glob(pattern), reverse=True) return files[0] if files else None def load( self, method: str, - scenario_id: int, + owner_id: int, params_hash: str, max_age: timedelta | None = None, ) -> dict[str, Any] | None: - if method == "social_economical_metric": - pattern = f"*__project_{scenario_id}__{_safe(method)}__{params_hash}.json" - else: - pattern = f"*__scenario_{scenario_id}__{_safe(method)}__{params_hash}.json" + prefix = _owner_prefix(method) + pattern = f"*__{prefix}_{owner_id}__{_safe(method)}__{params_hash}.json" files = sorted(_CACHE_DIR.glob(pattern), reverse=True) if not files: return None @@ -97,16 +105,20 @@ def load( return json.loads(path.read_text(encoding="utf-8")) - def load_latest(self, method: str, scenario_id: int) -> dict[str, Any] | None: - path = self._latest_path(method, scenario_id) + def load_latest(self, method: str, owner_id: int) -> dict[str, Any] | None: + path = self._latest_path(method, owner_id) if not path: return None return json.loads(path.read_text(encoding="utf-8")) def has( - self, method: str, scenario_id: int, max_age: timedelta | None = None + self, + method: str, + owner_id: int, + params_hash: str, + max_age: timedelta | None = None, ) -> bool: - return self.load(method, scenario_id, max_age) is not None + return self.load(method, owner_id, params_hash, max_age=max_age) is not None def parse_task_id(self, task_id: str): parts = task_id.split("_") diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 6425d7d..c273f9f 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1193,7 +1193,7 @@ async def _load_indicator_name_cache(self) -> dict[int, str]: except (TypeError, ValueError): logger.warning("Skipping invalid indicator id in INDICATORS_MAPPING: %r", v) - logger.info("Preloading indicator names for %s indicators", len(indicator_ids)) + logger.info(f"Preloading indicator names for {len(indicator_ids)} indicators") id_to_name: dict[int, str] = {} for ind_id in sorted(indicator_ids): @@ -1201,10 +1201,10 @@ async def _load_indicator_name_cache(self) -> dict[int, str]: ind_info = await self.urban_api_client.get_indicator_info(ind_id) id_to_name[ind_id] = self._format_indicator_label(ind_info) except Exception as exc: - logger.warning("Failed to fetch indicator info for id=%s: %s", ind_id, exc) + logger.warning(f"Failed to fetch indicator info for id={ind_id}: {exc}") self._indicator_name_cache = id_to_name - logger.info("Indicator name cache loaded: %s entries", len(self._indicator_name_cache)) + logger.info(f"Indicator name cache loaded: {len(self._indicator_name_cache)} entries") return self._indicator_name_cache async def _load_urbanomy_indicator_name_cache(self) -> dict[int, str]: @@ -1214,7 +1214,7 @@ async def _load_urbanomy_indicator_name_cache(self) -> dict[int, str]: return self._urbanomy_indicator_name_cache indicator_ids = {int(v) for v in URBANOMY_INDICATORS_MAPPING.values() if v is not None} - logger.info("Preloading Urbanomy indicator names for %s indicators", len(indicator_ids)) + logger.info(f"Preloading Urbanomy indicator names for {len(indicator_ids)} indicators") id_to_name: dict[int, str] = {} for ind_id in sorted(indicator_ids): @@ -1222,12 +1222,11 @@ async def _load_urbanomy_indicator_name_cache(self) -> dict[int, str]: ind_info = await self.urban_api_client.get_indicator_info(ind_id) id_to_name[ind_id] = self._format_indicator_label(ind_info) except Exception as exc: - logger.warning("Failed to fetch Urbanomy indicator info for id=%s: %s", ind_id, exc) + logger.warning(f"Failed to fetch Urbanomy indicator info for id={ind_id}: {exc}") self._urbanomy_indicator_name_cache = id_to_name logger.info( - "Urbanomy indicator name cache loaded: %s entries", - len(self._urbanomy_indicator_name_cache), + f"Urbanomy indicator name cache loaded: {len(self._urbanomy_indicator_name_cache)} entries" ) return self._urbanomy_indicator_name_cache @@ -1255,9 +1254,7 @@ def _map_name(v: Any) -> str | None: before = len(df) df = df[df["indicator_name"].notna()].copy() logger.info( - "Attached Urbanomy indicator names for %s rows (filtered out %s rows without names)", - len(df), - before - len(df), + f"Attached Urbanomy indicator names for {len(df)} rows (filtered out {before - len(df)} rows without names)" ) return df @@ -1817,8 +1814,6 @@ async def evaluate_social_economical_metrics(self, token: str, params: SocioEcon target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id] logger.info(f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})") - only_parent_ids = None - results: dict[int, list[dict]] = {} only_parent_ids = None @@ -1864,6 +1859,6 @@ async def evaluate_social_economical_metrics(self, token: str, params: SocioEcon scenario_updated_at=updated_at, ) - logger.success("[Effects] socio-economic metrics cached for project_id=%s, parent=%s", project_id, parent_id) + logger.success(f"[Effects] socio-economic metrics cached for project_id={project_id}, parent={parent_id}") return self._filter_by_territories(results_all, requested_ids) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 081dcda..be77a0e 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -123,7 +123,6 @@ async def create_task( params_for_hash = { "project_id": getattr(params, "project_id", None), "regional_scenario_id": getattr(params, "regional_scenario_id", None), - "territory_ids": getattr(params, "territory_ids", []) or [], } force = bool(getattr(params, "force", False)) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 411db64..bb2bc69 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -148,7 +148,7 @@ async def create_scenario_task( description=( "Queues an asynchronous **project-level** task. Currently supported: " "`social_economical_metrics`.\n\n" - "**Hash parameters**: `{project_id, regional_scenario_id, territory_ids}`.\n" + "**Hash parameters**: `{project_id, regional_scenario_id}` (territory_ids are not part of cache key).\n" "**Caching behavior**: if `force=false` and a complete cached result exists, " "for the computed parameter hash, the endpoint returns `status=done` immediately. " "Otherwise a task is queued and `status=queued` is returned.\n\n" From b3ed7b4bb551dc0aa8fb23c0b1276eada82ff8bf Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 15 Dec 2025 12:29:34 +0300 Subject: [PATCH 138/161] fix(response): 1. Fixed land use prediction response --- app/effects_api/constants/const.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/effects_api/constants/const.py b/app/effects_api/constants/const.py index f1fef90..05732e2 100644 --- a/app/effects_api/constants/const.py +++ b/app/effects_api/constants/const.py @@ -187,9 +187,9 @@ } PROB_COLS_EN_TO_RU = { - "prob_urban": "Вероятность жилого или бизнес видов использования, %", - "prob_non_urban": "Вероятность рекреационного вида использования, %", - "prob_industrial": "Вероятность промышленного вида использования, %", + "prob_urban": "Вероятность жилого или бизнес видов использования", + "prob_non_urban": "Вероятность рекреационного вида использования", + "prob_industrial": "Вероятность промышленного вида использования", } SOCIAL_INDICATORS_MAPPING = { From e5a0af471dc727b7352a76f20db49bdd6d5fc39d Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 15 Dec 2025 18:10:46 +0300 Subject: [PATCH 139/161] fix(scenario_service): 1. Workaround for cases when roads are not featured --- app/effects_api/modules/scenario_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 11735d3..c897a69 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -105,7 +105,7 @@ async def _get_scenario_blocks( user_roads.geometry = close_gaps(user_roads, 1) roads = user_roads.explode(column="geometry") else: - roads = None + roads = gpd.GeoDataFrame(geometry=[], crs=boundaries.crs) water = None lines, polygons = preprocess_urban_objects(roads, None, water) From 91cfdc0c6e5d7291082096e32bf1d3ad2a56eb72 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Tue, 16 Dec 2025 18:45:44 +0300 Subject: [PATCH 140/161] fix(context_service): 1.Logic for when no roads or water objects are not presented in the context --- app/effects_api/modules/context_service.py | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 0d11326..c444efc 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -57,28 +57,32 @@ async def _get_context_boundaries( return gpd.GeoDataFrame(geometry=geometries, crs=4326) async def _get_context_roads( - self, scenario_id: int, token: str - ) -> gpd.GeoDataFrame: + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame | None: """Return roads geometry for context cut (only geometry column).""" gdf = await self.client.get_physical_objects( scenario_id, token, physical_object_function_id=ROADS_ID ) + if gdf is None: + return None return gdf[["geometry"]].reset_index(drop=True) async def _get_context_water( - self, scenario_id: int, token: str - ) -> gpd.GeoDataFrame: + self, scenario_id: int, token: str + ) -> gpd.GeoDataFrame | None: """Return water geometry for context cut (only geometry column).""" gdf = await self.client.get_physical_objects( scenario_id, token=token, physical_object_function_id=WATER_ID ) + if gdf is None: + return None return gdf[["geometry"]].reset_index(drop=True) async def _get_context_blocks( - self, - scenario_id: int, - boundaries: gpd.GeoDataFrame, - token: str, + self, + scenario_id: int, + boundaries: gpd.GeoDataFrame, + token: str, ) -> gpd.GeoDataFrame: """Construct context blocks by cutting boundaries with roads/water.""" crs = boundaries.crs @@ -89,9 +93,16 @@ async def _get_context_blocks( self._get_context_roads(scenario_id, token), ) - water = water.to_crs(crs) - roads = roads.to_crs(crs) - roads.geometry = close_gaps(roads, 1) + if water is not None and not water.empty: + water = water.to_crs(crs).explode().reset_index(drop=True) + + if roads is not None and not roads.empty: + roads = roads.to_crs(crs).explode().reset_index(drop=True) + roads.geometry = close_gaps(roads, 1) + roads = roads.explode(column="geometry") + else: + roads = gpd.GeoDataFrame(geometry=[], crs=boundaries.crs) + water = None lines, polygons = preprocess_urban_objects(roads, None, water) blocks = cut_urban_blocks(boundaries, lines, polygons) From 3e245908ce7151738376c8152e2b7062af45576e Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 17 Dec 2025 18:26:09 +0300 Subject: [PATCH 141/161] fix(fixes): 1.Token authorization in indicator scenario value method --- app/clients/urban_api_client.py | 5 +++-- app/effects_api/effects_service.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/clients/urban_api_client.py b/app/clients/urban_api_client.py index 09132fb..58db271 100644 --- a/app/clients/urban_api_client.py +++ b/app/clients/urban_api_client.py @@ -412,6 +412,7 @@ async def get_indicator_info(self, indicator_id: int) -> dict: res = await self.json_handler.get(f"/api/v1/indicators/{indicator_id}") return res - async def get_indicator_scenario_value(self, scenario_id: int) -> dict: - res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}/indicators_values") + async def get_indicator_scenario_value(self, scenario_id: int, token: str) -> dict: + headers = {"Authorization": f"Bearer {token}"} if token else None + res = await self.json_handler.get(f"/api/v1/scenarios/{scenario_id}/indicators_values", headers=headers) return res diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c273f9f..be87a4a 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1317,7 +1317,7 @@ async def _get_land_price_model(self) -> CatBoostRegressor: return model async def _fetch_land_use_potentials(self, scenario_id: int, token: str) -> pd.DataFrame: - scenario_indicators = await self.urban_api_client.get_indicator_scenario_value(scenario_id) + scenario_indicators = await self.urban_api_client.get_indicator_scenario_value(scenario_id, token) indicator_attributes = { (item.get("indicator") or {}).get("name_full"): item.get("value") From a0813be2367594a504e7515c7b7c577eba559bc9 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 17 Dec 2025 18:41:01 +0300 Subject: [PATCH 142/161] fix(fixes): 1.Convertion of null to zero float --- app/effects_api/tasks_controller.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index bb2bc69..db1a983 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -289,9 +289,15 @@ async def get_territory_transformation_layer(scenario_id: int, service_name: str fc_before = before_dict.get(service_name) fc_after = after_dict.get(service_name) - provision_before = before_dict.get("provision_total_before") - provision_after = after_dict.get("provision_total_after") - + provision_before = { + k: 0.0 if v is None else float(v) + for k, v in (before_dict.get("provision_total_before") or {}).items() + } + + provision_after = { + k: 0.0 if v is None else float(v) + for k, v in (after_dict.get("provision_total_after") or {}).items() + } if fc_before and fc_after: return TerritoryTransformationLayerResponse( before = fc_before, From 10772efec775369a116e4fbdf2b33f884b0bd690 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 17 Dec 2025 20:11:06 +0300 Subject: [PATCH 143/161] fix(fixes): 1.Convertion of null to zero float --- app/effects_api/tasks_controller.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index db1a983..8e680e7 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -477,8 +477,15 @@ async def get_total_provisions(scenario_id: int): before_dict = data.get("before", {}) or {} after_dict = data.get("after", {}) or {} - provision_before = before_dict.get("provision_total_before") - provision_after = after_dict.get("provision_total_after") + provision_before = { + k: 0.0 if v is None else float(v) + for k, v in (before_dict.get("provision_total_before") or {}).items() + } + + provision_after = { + k: 0.0 if v is None else float(v) + for k, v in (after_dict.get("provision_total_after") or {}).items() + } if provision_before and provision_after: return TerritoryTransformationResponseTablesSchema( From b593dae7495f148ac4cf9c5f4d583189176491c3 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 17 Dec 2025 21:07:52 +0300 Subject: [PATCH 144/161] fix(F22): 1.Fixed behaviour when non-existent roads were causing errors calculating all the other indicators. Now if roads are not present in scenario, the transport indicators will be skipped --- app/effects_api/effects_service.py | 38 ++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index be87a4a..c64e8e9 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -1395,7 +1395,10 @@ async def _compute_for_single_scenario( roads_gdf = await self.urban_api_client.get_physical_objects_scenario( scenario_id, token=token, physical_object_function_id=ROADS_ID ) - roads_gdf = roads_gdf.to_crs(before_blocks.crs).overlay(before_blocks) + if roads_gdf is not None and not roads_gdf.empty: + roads_gdf = roads_gdf.to_crs(before_blocks.crs).overlay(before_blocks) + else: + roads_gdf = gpd.GeoDataFrame(geometry=[], crs=before_blocks.crs) try: acc_mx = get_accessibility_matrix(before_blocks) @@ -1413,13 +1416,35 @@ async def _compute_for_single_scenario( general = calculate_general_indicators(before_blocks) demo = calculate_demographic_indicators(before_blocks) - transp = calculate_transport_indicators(before_blocks, acc_mx, roads_gdf) eng = calculate_engineering_indicators(before_blocks) sc, sp = calculate_social_indicators( before_blocks, acc_mx, dist_mx, st_for_social ) - indicators_df = pd.concat([general, demo, transp, eng, sc, sp]) + frames = [general, demo, eng, sc, sp] + + has_roads = ( + roads_gdf is not None + and not roads_gdf.empty + and len(roads_gdf) > 1 + ) + + if has_roads: + try: + transp = calculate_transport_indicators( + before_blocks, acc_mx, roads_gdf + ) + frames.append(transp) + except Exception as exc: + logger.warning( + "Transport indicators skipped: %s", exc + ) + else: + logger.info( + "Transport indicators skipped: roads_gdf is empty or insufficient" + ) + + indicators_df = pd.concat(frames) long_df = ( indicators_df.reset_index() @@ -1443,7 +1468,12 @@ async def _compute_for_single_scenario( territory_id_hint: int | None = None if "is_project" in before_blocks.columns: - proj_mask = before_blocks["is_project"].fillna(False).astype(bool) + proj_mask = ( + before_blocks["is_project"] + .infer_objects(copy=False) + .fillna(False) + .astype(bool) + ) territory_id_hint = self._pick_single_territory_id(before_blocks.loc[proj_mask, "parent"]) urbanomy_records: list[dict] = [] From 90b62af290a5e9c19414bdad5243c2a79bec856f Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 18 Dec 2025 14:32:31 +0300 Subject: [PATCH 145/161] fix(blocks): 1. Workaround for cases when water or roads --- app/effects_api/modules/context_service.py | 6 +++++- app/effects_api/modules/scenario_service.py | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index c444efc..2d5d124 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -100,11 +100,15 @@ async def _get_context_blocks( roads = roads.to_crs(crs).explode().reset_index(drop=True) roads.geometry = close_gaps(roads, 1) roads = roads.explode(column="geometry") + water_geoms = ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'] + roads_geoms = ['LineString', 'MultiLineString'] + water = water[water.geom_type.isin(water_geoms)].reset_index(drop=True) + roads = roads[roads.geom_type.isin(roads_geoms)].reset_index(drop=True) else: roads = gpd.GeoDataFrame(geometry=[], crs=boundaries.crs) water = None - lines, polygons = preprocess_urban_objects(roads, None, water) + lines, polygons = preprocess_urban_objects(roads, None, water.reset_index(drop=True)) blocks = cut_urban_blocks(boundaries, lines, polygons) return blocks diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index c897a69..514e0d1 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -104,6 +104,11 @@ async def _get_scenario_blocks( if user_roads is not None and not user_roads.empty: user_roads.geometry = close_gaps(user_roads, 1) roads = user_roads.explode(column="geometry") + water_geoms = ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'] + roads_geoms = ['LineString', 'MultiLineString'] + water = water[water.geom_type.isin(water_geoms)].reset_index(drop=True) + roads = roads[roads.geom_type.isin(roads_geoms)].reset_index(drop=True) + else: roads = gpd.GeoDataFrame(geometry=[], crs=boundaries.crs) water = None From 6c99943d25c5991c39d56ae1ca23dddc2c617bb0 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 18 Dec 2025 19:40:55 +0300 Subject: [PATCH 146/161] feat(broker): 1. Continuation of broker logic features --- app/broker_handlers/__init__.py | 0 .../project_created_handler.py | 53 ++++++++++++++++++ app/common/consumer_wrapper.py | 13 +++++ app/common/producer_wrapper.py | 10 ++++ app/dependencies.py | 10 ++++ app/effects_api/modules/task_service.py | 5 +- requirements.txt | Bin 836 -> 870 bytes 7 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 app/broker_handlers/__init__.py create mode 100644 app/broker_handlers/project_created_handler.py create mode 100644 app/common/consumer_wrapper.py create mode 100644 app/common/producer_wrapper.py diff --git a/app/broker_handlers/__init__.py b/app/broker_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/broker_handlers/project_created_handler.py b/app/broker_handlers/project_created_handler.py new file mode 100644 index 0000000..a9edb0e --- /dev/null +++ b/app/broker_handlers/project_created_handler.py @@ -0,0 +1,53 @@ +from typing import Protocol + + +from confluent_kafka import Message +from iduconfig import Config +from loguru import logger +from otteroad import BaseMessageHandler, KafkaProducerClient +from otteroad.consumer.handlers.base import EventT +from otteroad.models.scenario_events.projects.ScenarioObjectsUpdated import ScenarioObjectsUpdated +from otteroad.models.scenario_events.projects.ScenarioZonesUpdated import ScenarioZonesUpdated + +from app.clients.urban_api_client import UrbanAPIClient +from app.effects_api.dto.socio_economic_project_dto import SocioEconomicByProjectDTO +from app.effects_api.effects_service import EffectsService + +# ScenarioZonesUpdated +class ScenarioObjectsUpdatedHandler(BaseMessageHandler[ScenarioObjectsUpdated]): + def __init__( + self, + effects: EffectsService, + producer: KafkaProducerClient, + urban_api: UrbanAPIClient, + config: Config + + ): + self.effects = effects + self.producer = producer + self.urban_api = urban_api + self.config = config + super().__init__() + + async def on_startup(self): + pass + + async def on_shutdown(self): + pass + + async def handle(self, event: EventT, ctx: Message = None): + logger.info(f"Received {type(event)}") + logger.info( + f"scenario: {event.scenario_id}, project: {event.project_id}") + scenario_id: int = event.scenario_id + regional_scenario_response = await self.urban_api.get_scenario(scenario_id, self.config.get("URBAN_API_TOKEN")) + regional_scenario_id = regional_scenario_response["parent_scenario"]["id"] + params = SocioEconomicByProjectDTO( + project_id=event.project_id, + regional_scenario_id=regional_scenario_id, + force=True) + await self.effects.evaluate_social_economical_metrics(self.config.get("URBAN_API_TOKEN"), params=params) + logger.error( + f"F22 was calculated for {event.project_id} project") + return await self.producer.send(event) + diff --git a/app/common/consumer_wrapper.py b/app/common/consumer_wrapper.py new file mode 100644 index 0000000..2bfe344 --- /dev/null +++ b/app/common/consumer_wrapper.py @@ -0,0 +1,13 @@ +from otteroad import KafkaConsumerSettings, KafkaConsumerService, BaseMessageHandler + + +class ConsumerWrapper: + def __init__(self): + self.consumer_settings = KafkaConsumerSettings.from_env() + self.consumer_service = KafkaConsumerService(self.consumer_settings) + + def register_handler(self, handler: BaseMessageHandler): + self.consumer_service.register_handler(handler) + + async def start(self, topics: list[str]): + await self.consumer_service.add_worker(topics=topics).start() diff --git a/app/common/producer_wrapper.py b/app/common/producer_wrapper.py new file mode 100644 index 0000000..5bef3a9 --- /dev/null +++ b/app/common/producer_wrapper.py @@ -0,0 +1,10 @@ +from otteroad import KafkaProducerSettings, KafkaProducerClient + + +class ProducerWrapper: + def __init__(self): + self.producer_settings = KafkaProducerSettings.from_env() + self.producer_service = KafkaProducerClient(self.producer_settings) + + async def start(self): + await self.producer_service.start() diff --git a/app/dependencies.py b/app/dependencies.py index 47e646d..ecf7b48 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -4,9 +4,12 @@ from iduconfig import Config from loguru import logger +from app.broker_handlers.project_created_handler import ScenarioObjectsUpdatedHandler from app.clients.urban_api_client import UrbanAPIClient from app.common.api_handlers.json_api_handler import JSONAPIHandler from app.common.caching.caching_service import FileCache +from app.common.consumer_wrapper import ConsumerWrapper +from app.common.producer_wrapper import ProducerWrapper from app.common.utils.effects_utils import EffectsUtils from app.effects_api.effects_service import EffectsService from app.effects_api.modules.context_service import ContextService @@ -34,3 +37,10 @@ effects_service = EffectsService( urban_api_client, file_cache, scenario_service, context_service, effects_utils ) + +consumer = ConsumerWrapper() +producer = ProducerWrapper() + +consumer.register_handler( + ScenarioObjectsUpdatedHandler(effects_service, producer.producer_service, urban_api_client, config) +) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index be77a0e..d56ea58 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -9,7 +9,7 @@ from loguru import logger from app.common.exceptions.http_exception_wrapper import http_exception -from app.dependencies import effects_service, file_cache, effects_utils +from app.dependencies import effects_service, file_cache, effects_utils, consumer, producer MethodFunc = Callable[[str, Any], "dict[str, Any]"] @@ -240,10 +240,11 @@ async def stop(self): worker = Worker() - @asynccontextmanager async def lifespan(app: FastAPI): worker.start() + await consumer.start(["scenario.events"]) + await producer.start() try: yield finally: diff --git a/requirements.txt b/requirements.txt index 4cf76065e4b2609816a87f68a3fd9ff39b6b90e6..6fe48a7afcc2cf8f7f269fa240e6615788368abc 100644 GIT binary patch delta 41 tcmX@Y_Ka=AJ|@|Gh7ur5Whi3EXGmm7VW?xUWiVjSV=w|@!^u0Dx&iL53Ml{p delta 11 ScmaFHc7$!iKBmbJnA!j!0R Date: Fri, 19 Dec 2025 14:01:15 +0300 Subject: [PATCH 147/161] fix(blocks): 1.Fixed behaviour when water was in points causing errors in gdf validation --- app/effects_api/modules/context_service.py | 5 +++-- app/effects_api/modules/scenario_service.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 2d5d124..59b7c27 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -95,14 +95,15 @@ async def _get_context_blocks( if water is not None and not water.empty: water = water.to_crs(crs).explode().reset_index(drop=True) + water_geoms = ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'] + water = water[water.geom_type.isin(water_geoms)].reset_index(drop=True) if roads is not None and not roads.empty: roads = roads.to_crs(crs).explode().reset_index(drop=True) roads.geometry = close_gaps(roads, 1) roads = roads.explode(column="geometry") - water_geoms = ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'] roads_geoms = ['LineString', 'MultiLineString'] - water = water[water.geom_type.isin(water_geoms)].reset_index(drop=True) + roads = roads[roads.geom_type.isin(roads_geoms)].reset_index(drop=True) else: roads = gpd.GeoDataFrame(geometry=[], crs=boundaries.crs) diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 514e0d1..3fca090 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -97,6 +97,8 @@ async def _get_scenario_blocks( if water is not None and not water.empty: water = water.to_crs(crs).explode().reset_index(drop=True) + water_geoms = ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'] + water = water[water.geom_type.isin(water_geoms)].reset_index(drop=True) if user_roads is not None and not user_roads.empty: user_roads = user_roads.to_crs(crs).explode().reset_index(drop=True) @@ -104,9 +106,8 @@ async def _get_scenario_blocks( if user_roads is not None and not user_roads.empty: user_roads.geometry = close_gaps(user_roads, 1) roads = user_roads.explode(column="geometry") - water_geoms = ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'] roads_geoms = ['LineString', 'MultiLineString'] - water = water[water.geom_type.isin(water_geoms)].reset_index(drop=True) + roads = roads[roads.geom_type.isin(roads_geoms)].reset_index(drop=True) else: From 6d15a23018cb0d9c883053bf8dfaf2b9bbcde8ad Mon Sep 17 00:00:00 2001 From: voronapxl Date: Fri, 19 Dec 2025 16:30:37 +0300 Subject: [PATCH 148/161] feat(broker): 1.Featured logic for events when scenarios are updated with objects or zones, F22 cache will be terminated for such events --- app/broker_handlers/cache_invalidation.py | 78 ++++++++++ .../project_created_handler.py | 53 ------- .../scenario_updated_handler.py | 133 ++++++++++++++++++ app/common/caching/caching_service.py | 31 ++++ app/dependencies.py | 13 +- 5 files changed, 253 insertions(+), 55 deletions(-) create mode 100644 app/broker_handlers/cache_invalidation.py delete mode 100644 app/broker_handlers/project_created_handler.py create mode 100644 app/broker_handlers/scenario_updated_handler.py diff --git a/app/broker_handlers/cache_invalidation.py b/app/broker_handlers/cache_invalidation.py new file mode 100644 index 0000000..de7b2ca --- /dev/null +++ b/app/broker_handlers/cache_invalidation.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Generic, Iterable, TypeVar + +from confluent_kafka import Message +from loguru import logger +from otteroad import BaseMessageHandler, KafkaProducerClient +from otteroad.consumer.handlers.base import EventT + +from app.common.caching.caching_service import FileCache + +TEvent = TypeVar("TEvent") + + +@dataclass(frozen=True) +class CacheInvalidationRule: + """ + Describes what cache to invalidate and how to extract owner_id from an event. + + method: cache method name (e.g. "social_economical_metrics"). + owner_id_getter: function that returns owner_id from event (e.g. project_id or scenario_id). + """ + method: str + owner_id_getter: Callable[[Any], int] + + +class CacheInvalidationService: + """Applies cache invalidation rules using FileCache.""" + + def __init__(self, cache: FileCache) -> None: + self._cache = cache + + def invalidate(self, event: Any, rules: Iterable[CacheInvalidationRule]) -> int: + """ + Invalidate cache for an event using given rules. + + Returns: + Total number of deleted files. + """ + total_deleted = 0 + for rule in rules: + owner_id = int(rule.owner_id_getter(event)) + deleted = self._cache.delete_all(rule.method, owner_id) + total_deleted += deleted + + logger.info( + f"Cache invalidation rule applied: method={rule.method} owner_id={owner_id} deleted_files={deleted}" + ) + + return total_deleted + + +class BaseCacheInvalidationHandler(Generic[TEvent], BaseMessageHandler[TEvent]): + """Base handler for cache invalidation with easy extension via rules.""" + + def __init__( + self, + invalidation_service: CacheInvalidationService, + producer: KafkaProducerClient, + rules: list[CacheInvalidationRule], + ) -> None: + self._invalidation_service = invalidation_service + self._producer = producer + self._rules = rules + super().__init__() + + async def on_startup(self): + pass + + async def on_shutdown(self): + pass + + async def handle(self, event: EventT, ctx: Message = None): + logger.info(f"Received event: type={type(event)}") + total_deleted = self._invalidation_service.invalidate(event, self._rules) + logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") + return await self._producer.send(event) diff --git a/app/broker_handlers/project_created_handler.py b/app/broker_handlers/project_created_handler.py deleted file mode 100644 index a9edb0e..0000000 --- a/app/broker_handlers/project_created_handler.py +++ /dev/null @@ -1,53 +0,0 @@ -from typing import Protocol - - -from confluent_kafka import Message -from iduconfig import Config -from loguru import logger -from otteroad import BaseMessageHandler, KafkaProducerClient -from otteroad.consumer.handlers.base import EventT -from otteroad.models.scenario_events.projects.ScenarioObjectsUpdated import ScenarioObjectsUpdated -from otteroad.models.scenario_events.projects.ScenarioZonesUpdated import ScenarioZonesUpdated - -from app.clients.urban_api_client import UrbanAPIClient -from app.effects_api.dto.socio_economic_project_dto import SocioEconomicByProjectDTO -from app.effects_api.effects_service import EffectsService - -# ScenarioZonesUpdated -class ScenarioObjectsUpdatedHandler(BaseMessageHandler[ScenarioObjectsUpdated]): - def __init__( - self, - effects: EffectsService, - producer: KafkaProducerClient, - urban_api: UrbanAPIClient, - config: Config - - ): - self.effects = effects - self.producer = producer - self.urban_api = urban_api - self.config = config - super().__init__() - - async def on_startup(self): - pass - - async def on_shutdown(self): - pass - - async def handle(self, event: EventT, ctx: Message = None): - logger.info(f"Received {type(event)}") - logger.info( - f"scenario: {event.scenario_id}, project: {event.project_id}") - scenario_id: int = event.scenario_id - regional_scenario_response = await self.urban_api.get_scenario(scenario_id, self.config.get("URBAN_API_TOKEN")) - regional_scenario_id = regional_scenario_response["parent_scenario"]["id"] - params = SocioEconomicByProjectDTO( - project_id=event.project_id, - regional_scenario_id=regional_scenario_id, - force=True) - await self.effects.evaluate_social_economical_metrics(self.config.get("URBAN_API_TOKEN"), params=params) - logger.error( - f"F22 was calculated for {event.project_id} project") - return await self.producer.send(event) - diff --git a/app/broker_handlers/scenario_updated_handler.py b/app/broker_handlers/scenario_updated_handler.py new file mode 100644 index 0000000..7183148 --- /dev/null +++ b/app/broker_handlers/scenario_updated_handler.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Iterable + +from confluent_kafka import Message +from loguru import logger +from otteroad import BaseMessageHandler, KafkaProducerClient +from otteroad.consumer.handlers.base import EventT +from otteroad.models.scenario_events.projects.ScenarioObjectsUpdated import ( + ScenarioObjectsUpdated, +) +from otteroad.models.scenario_events.projects.ScenarioZonesUpdated import ( + ScenarioZonesUpdated, +) + +from app.common.caching.caching_service import FileCache + + +@dataclass(frozen=True) +class CacheInvalidationRule: + """ + Cache invalidation rule. + + method: cache method name (e.g. "social_economical_metrics") + owner_id_getter: extracts owner_id from event (e.g. project_id or scenario_id) + """ + method: str + owner_id_getter: Callable[[Any], int] + + +class CacheInvalidationService: + """Applies cache invalidation rules using FileCache.""" + + def __init__(self, cache: FileCache) -> None: + self._cache = cache + + def invalidate(self, event: Any, rules: Iterable[CacheInvalidationRule]) -> int: + """ + Invalidate cache entries for event according to rules. + + Returns: + Total number of deleted files. + """ + total_deleted = 0 + for rule in rules: + owner_id = int(rule.owner_id_getter(event)) + deleted = self._cache.delete_all(rule.method, owner_id) + total_deleted += deleted + + logger.info( + f"Cache invalidation applied: method={rule.method} owner_id={owner_id} deleted_files={deleted}" + ) + + return total_deleted + + +class CacheInvalidationHandlerCore: + """Shared handler logic without BaseMessageHandler inheritance (otteroad-friendly).""" + + def __init__( + self, + invalidation_service: CacheInvalidationService, + producer: KafkaProducerClient, + rules: list[CacheInvalidationRule], + ) -> None: + self._invalidation_service = invalidation_service + self._producer = producer + self._rules = rules + + async def process(self, event: Any) -> Any: + logger.info(f"Received event: type={type(event)}") + logger.info( + f"Invalidate cache for project_id={getattr(event, 'project_id', None)} " + f"scenario_id={getattr(event, 'scenario_id', None)}" + ) + + total_deleted = self._invalidation_service.invalidate(event, self._rules) + + logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") + return None + + +class ScenarioObjectsUpdatedHandler(BaseMessageHandler[ScenarioObjectsUpdated]): + """Invalidates cache when ScenarioObjectsUpdated is received.""" + + def __init__(self, cache: FileCache, producer: KafkaProducerClient) -> None: + self._core = CacheInvalidationHandlerCore( + invalidation_service=CacheInvalidationService(cache), + producer=producer, + rules=[ + CacheInvalidationRule( + method="social_economical_metrics", + owner_id_getter=lambda e: e.project_id, + ), + ], + ) + super().__init__() + + async def on_startup(self): + pass + + async def on_shutdown(self): + pass + + async def handle(self, event: EventT, ctx: Message = None): + return await self._core.process(event) + + +class ScenarioZonesUpdatedHandler(BaseMessageHandler[ScenarioZonesUpdated]): + """Invalidates cache when ScenarioZonesUpdated is received.""" + + def __init__(self, cache: FileCache, producer: KafkaProducerClient) -> None: + self._core = CacheInvalidationHandlerCore( + invalidation_service=CacheInvalidationService(cache), + producer=producer, + rules=[ + CacheInvalidationRule( + method="social_economical_metrics", + owner_id_getter=lambda e: e.project_id, + ), + ], + ) + super().__init__() + + async def on_startup(self): + pass + + async def on_shutdown(self): + pass + + async def handle(self, event: EventT, ctx: Message = None): + return await self._core.process(event) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index cafc3d9..31a066b 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -7,6 +7,7 @@ import geopandas as gpd import pandas as pd +from loguru import logger _CACHE_DIR = Path().absolute() / "__effects_cache__" _CACHE_DIR.mkdir(parents=True, exist_ok=True) @@ -213,3 +214,33 @@ def load_gdf_artifact(self, path: Path) -> "gpd.GeoDataFrame": elif ext == ".pkl": return pd.read_pickle(path) raise ValueError(f"Unsupported artifact extension: {ext}") + + def delete_all(self, method: str, owner_id: int) -> int: + """ + Delete all cached JSON files and heavy artifacts for given method and owner_id. + + Returns: + Number of deleted files. + """ + prefix = _owner_prefix(method) + + json_pattern = f"*__{prefix}_{owner_id}__{_safe(method)}__*.json" + json_files = list(_CACHE_DIR.glob(json_pattern)) + + artifact_pattern = f"artifact__{_safe(method)}__{owner_id}__*" + artifact_files = list(_CACHE_DIR.glob(artifact_pattern)) + + deleted = 0 + for path in json_files + artifact_files: + try: + path.unlink(missing_ok=True) + deleted += 1 + except Exception as e: + logger.warning( + f"Failed to delete cache file: path={path.as_posix()} err={e}" + ) + + logger.info( + f"Cache invalidated: method={method} owner_id={owner_id} deleted_files={deleted}" + ) + return deleted diff --git a/app/dependencies.py b/app/dependencies.py index ecf7b48..96aece2 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -4,7 +4,11 @@ from iduconfig import Config from loguru import logger -from app.broker_handlers.project_created_handler import ScenarioObjectsUpdatedHandler +from app.broker_handlers.cache_invalidation import CacheInvalidationService +from app.broker_handlers.scenario_updated_handler import ( + ScenarioObjectsUpdatedHandler, + ScenarioZonesUpdatedHandler, +) from app.clients.urban_api_client import UrbanAPIClient from app.common.api_handlers.json_api_handler import JSONAPIHandler from app.common.caching.caching_service import FileCache @@ -41,6 +45,11 @@ consumer = ConsumerWrapper() producer = ProducerWrapper() +invalidation_service = CacheInvalidationService(file_cache) + +consumer.register_handler( + ScenarioObjectsUpdatedHandler(file_cache, producer.producer_service) +) consumer.register_handler( - ScenarioObjectsUpdatedHandler(effects_service, producer.producer_service, urban_api_client, config) + ScenarioZonesUpdatedHandler(file_cache, producer.producer_service) ) From 6141d6be03fae0bc16e49a8551ba0660edcd6173 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sat, 20 Dec 2025 21:09:40 +0300 Subject: [PATCH 149/161] fix(broker): 1. Deletion of redundant broker logic for cache invalidation 2. Featured extermination of consumer and producer workers --- app/broker_handlers/cache_invalidation.py | 34 +++-- .../scenario_updated_handler.py | 125 +++--------------- app/common/consumer_wrapper.py | 13 +- app/common/producer_wrapper.py | 6 +- app/dependencies.py | 6 +- app/effects_api/modules/task_service.py | 4 +- 6 files changed, 55 insertions(+), 133 deletions(-) diff --git a/app/broker_handlers/cache_invalidation.py b/app/broker_handlers/cache_invalidation.py index de7b2ca..9bbadfb 100644 --- a/app/broker_handlers/cache_invalidation.py +++ b/app/broker_handlers/cache_invalidation.py @@ -1,22 +1,19 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any, Callable, Generic, Iterable, TypeVar +from typing import Any, Callable, Iterable from confluent_kafka import Message from loguru import logger -from otteroad import BaseMessageHandler, KafkaProducerClient -from otteroad.consumer.handlers.base import EventT +from otteroad import KafkaProducerClient from app.common.caching.caching_service import FileCache -TEvent = TypeVar("TEvent") - @dataclass(frozen=True) class CacheInvalidationRule: """ - Describes what cache to invalidate and how to extract owner_id from an event. + Cache invalidation rule. method: cache method name (e.g. "social_economical_metrics"). owner_id_getter: function that returns owner_id from event (e.g. project_id or scenario_id). @@ -27,7 +24,6 @@ class CacheInvalidationRule: class CacheInvalidationService: """Applies cache invalidation rules using FileCache.""" - def __init__(self, cache: FileCache) -> None: self._cache = cache @@ -51,9 +47,10 @@ def invalidate(self, event: Any, rules: Iterable[CacheInvalidationRule]) -> int: return total_deleted -class BaseCacheInvalidationHandler(Generic[TEvent], BaseMessageHandler[TEvent]): - """Base handler for cache invalidation with easy extension via rules.""" - +class CacheInvalidationMixin: + """ + Shared handler logic for cache invalidation. + """ def __init__( self, invalidation_service: CacheInvalidationService, @@ -63,16 +60,15 @@ def __init__( self._invalidation_service = invalidation_service self._producer = producer self._rules = rules - super().__init__() - async def on_startup(self): - pass - - async def on_shutdown(self): - pass - - async def handle(self, event: EventT, ctx: Message = None): + async def _handle_cache_invalidation(self, event: Any, ctx: Message | None = None) -> None: logger.info(f"Received event: type={type(event)}") + logger.info( + f"Invalidate cache for project_id={getattr(event, 'project_id', None)} " + f"scenario_id={getattr(event, 'scenario_id', None)}" + ) + total_deleted = self._invalidation_service.invalidate(event, self._rules) logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") - return await self._producer.send(event) + + return None diff --git a/app/broker_handlers/scenario_updated_handler.py b/app/broker_handlers/scenario_updated_handler.py index 7183148..0d49391 100644 --- a/app/broker_handlers/scenario_updated_handler.py +++ b/app/broker_handlers/scenario_updated_handler.py @@ -1,101 +1,27 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, Callable, Iterable - from confluent_kafka import Message -from loguru import logger from otteroad import BaseMessageHandler, KafkaProducerClient from otteroad.consumer.handlers.base import EventT -from otteroad.models.scenario_events.projects.ScenarioObjectsUpdated import ( - ScenarioObjectsUpdated, -) -from otteroad.models.scenario_events.projects.ScenarioZonesUpdated import ( - ScenarioZonesUpdated, -) - -from app.common.caching.caching_service import FileCache - - -@dataclass(frozen=True) -class CacheInvalidationRule: - """ - Cache invalidation rule. - - method: cache method name (e.g. "social_economical_metrics") - owner_id_getter: extracts owner_id from event (e.g. project_id or scenario_id) - """ - method: str - owner_id_getter: Callable[[Any], int] - - -class CacheInvalidationService: - """Applies cache invalidation rules using FileCache.""" - - def __init__(self, cache: FileCache) -> None: - self._cache = cache +from otteroad.models.scenario_events.projects.ScenarioObjectsUpdated import ScenarioObjectsUpdated +from otteroad.models.scenario_events.projects.ScenarioZonesUpdated import ScenarioZonesUpdated - def invalidate(self, event: Any, rules: Iterable[CacheInvalidationRule]) -> int: - """ - Invalidate cache entries for event according to rules. - - Returns: - Total number of deleted files. - """ - total_deleted = 0 - for rule in rules: - owner_id = int(rule.owner_id_getter(event)) - deleted = self._cache.delete_all(rule.method, owner_id) - total_deleted += deleted - - logger.info( - f"Cache invalidation applied: method={rule.method} owner_id={owner_id} deleted_files={deleted}" - ) - - return total_deleted - - -class CacheInvalidationHandlerCore: - """Shared handler logic without BaseMessageHandler inheritance (otteroad-friendly).""" - - def __init__( - self, - invalidation_service: CacheInvalidationService, - producer: KafkaProducerClient, - rules: list[CacheInvalidationRule], - ) -> None: - self._invalidation_service = invalidation_service - self._producer = producer - self._rules = rules - - async def process(self, event: Any) -> Any: - logger.info(f"Received event: type={type(event)}") - logger.info( - f"Invalidate cache for project_id={getattr(event, 'project_id', None)} " - f"scenario_id={getattr(event, 'scenario_id', None)}" - ) - - total_deleted = self._invalidation_service.invalidate(event, self._rules) +from app.broker_handlers.cache_invalidation import ( + CacheInvalidationMixin, + CacheInvalidationRule, + CacheInvalidationService, +) - logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") - return None +_SOCIAL_RULES = [ + CacheInvalidationRule(method="social_economical_metrics", owner_id_getter=lambda e: e.project_id), +] -class ScenarioObjectsUpdatedHandler(BaseMessageHandler[ScenarioObjectsUpdated]): - """Invalidates cache when ScenarioObjectsUpdated is received.""" - def __init__(self, cache: FileCache, producer: KafkaProducerClient) -> None: - self._core = CacheInvalidationHandlerCore( - invalidation_service=CacheInvalidationService(cache), - producer=producer, - rules=[ - CacheInvalidationRule( - method="social_economical_metrics", - owner_id_getter=lambda e: e.project_id, - ), - ], - ) - super().__init__() +class ScenarioObjectsUpdatedHandler(BaseMessageHandler[ScenarioObjectsUpdated], CacheInvalidationMixin): + def __init__(self, invalidation_service: CacheInvalidationService, producer: KafkaProducerClient) -> None: + CacheInvalidationMixin.__init__(self, invalidation_service, producer, _SOCIAL_RULES) + BaseMessageHandler.__init__(self) async def on_startup(self): pass @@ -104,24 +30,13 @@ async def on_shutdown(self): pass async def handle(self, event: EventT, ctx: Message = None): - return await self._core.process(event) - + return await self._handle_cache_invalidation(event, ctx) -class ScenarioZonesUpdatedHandler(BaseMessageHandler[ScenarioZonesUpdated]): - """Invalidates cache when ScenarioZonesUpdated is received.""" - def __init__(self, cache: FileCache, producer: KafkaProducerClient) -> None: - self._core = CacheInvalidationHandlerCore( - invalidation_service=CacheInvalidationService(cache), - producer=producer, - rules=[ - CacheInvalidationRule( - method="social_economical_metrics", - owner_id_getter=lambda e: e.project_id, - ), - ], - ) - super().__init__() +class ScenarioZonesUpdatedHandler(BaseMessageHandler[ScenarioZonesUpdated], CacheInvalidationMixin): + def __init__(self, invalidation_service: CacheInvalidationService, producer: KafkaProducerClient) -> None: + CacheInvalidationMixin.__init__(self, invalidation_service, producer, _SOCIAL_RULES) + BaseMessageHandler.__init__(self) async def on_startup(self): pass @@ -130,4 +45,4 @@ async def on_shutdown(self): pass async def handle(self, event: EventT, ctx: Message = None): - return await self._core.process(event) + return await self._handle_cache_invalidation(event, ctx) diff --git a/app/common/consumer_wrapper.py b/app/common/consumer_wrapper.py index 2bfe344..c52afe8 100644 --- a/app/common/consumer_wrapper.py +++ b/app/common/consumer_wrapper.py @@ -5,9 +5,14 @@ class ConsumerWrapper: def __init__(self): self.consumer_settings = KafkaConsumerSettings.from_env() self.consumer_service = KafkaConsumerService(self.consumer_settings) - - def register_handler(self, handler: BaseMessageHandler): + + def register_handler(self, handler: BaseMessageHandler) -> None: self.consumer_service.register_handler(handler) - + async def start(self, topics: list[str]): - await self.consumer_service.add_worker(topics=topics).start() + self.consumer_service.add_worker(topics=topics) + await self.consumer_service.start() + + async def stop(self) -> None: + """Gracefully stop all consumer workers.""" + await self.consumer_service.stop() diff --git a/app/common/producer_wrapper.py b/app/common/producer_wrapper.py index 5bef3a9..1fc3fc2 100644 --- a/app/common/producer_wrapper.py +++ b/app/common/producer_wrapper.py @@ -5,6 +5,10 @@ class ProducerWrapper: def __init__(self): self.producer_settings = KafkaProducerSettings.from_env() self.producer_service = KafkaProducerClient(self.producer_settings) - + async def start(self): await self.producer_service.start() + + async def stop(self) -> None: + """Gracefully stop producer service (flush + stop polling thread).""" + await self.producer_service.close() diff --git a/app/dependencies.py b/app/dependencies.py index 96aece2..4f7da0b 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -45,11 +45,11 @@ consumer = ConsumerWrapper() producer = ProducerWrapper() -invalidation_service = CacheInvalidationService(file_cache) +cache_invalidator = CacheInvalidationService(file_cache) consumer.register_handler( - ScenarioObjectsUpdatedHandler(file_cache, producer.producer_service) + ScenarioObjectsUpdatedHandler(cache_invalidator, producer.producer_service) ) consumer.register_handler( - ScenarioZonesUpdatedHandler(file_cache, producer.producer_service) + ScenarioZonesUpdatedHandler(cache_invalidator, producer.producer_service) ) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index d56ea58..d1a336a 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -243,9 +243,11 @@ async def stop(self): @asynccontextmanager async def lifespan(app: FastAPI): worker.start() - await consumer.start(["scenario.events"]) await producer.start() + await consumer.start(["scenario.events"]) try: yield finally: + await consumer.stop() + await producer.stop() await worker.stop() From 117a8085a0dcf3e493eff7a23b14a2a5f7d9ccbb Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sat, 20 Dec 2025 22:30:09 +0300 Subject: [PATCH 150/161] fix(broker): 1. invalidation methods putted to thread --- app/broker_handlers/cache_invalidation.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/broker_handlers/cache_invalidation.py b/app/broker_handlers/cache_invalidation.py index 9bbadfb..a9e9606 100644 --- a/app/broker_handlers/cache_invalidation.py +++ b/app/broker_handlers/cache_invalidation.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from dataclasses import dataclass from typing import Any, Callable, Iterable @@ -68,7 +69,11 @@ async def _handle_cache_invalidation(self, event: Any, ctx: Message | None = Non f"scenario_id={getattr(event, 'scenario_id', None)}" ) - total_deleted = self._invalidation_service.invalidate(event, self._rules) + total_deleted = await asyncio.to_thread( + self._invalidation_service.invalidate, + event, + self._rules, + ) logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") return None From eabd9c21cfa38a7fd6cdff7dfa4b5d8b6d8fc793 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 21 Dec 2025 02:29:29 +0300 Subject: [PATCH 151/161] fix(f35): 1. Deleted optimizer form F35 logic 2. Before and after layers are now caching at once 3. Temporarily saving territory_transformation_scenario_after as workaround for values_transfromation as optimizer is required there for best_x --- app/effects_api/effects_service.py | 441 +++++++++++++----------- app/effects_api/modules/task_service.py | 4 +- app/effects_api/tasks_controller.py | 6 +- 3 files changed, 254 insertions(+), 197 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index c64e8e9..d69e42b 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -228,101 +228,217 @@ async def calculate_provision_totals( prov_totals[st_name] = round(total, ndigits) return prov_totals - async def territory_transformation_scenario_before( + async def _compute_provision_layers( self, - token: str, - params: ContextDevelopmentDTO, - context_blocks: gpd.GeoDataFrame = None, + blocks: gpd.GeoDataFrame, + service_types: pd.DataFrame, + *, + section_label: str, + ) -> tuple[dict[str, gpd.GeoDataFrame], dict[str, float | None]]: + """Compute provision layers (GeoDataFrames) and totals for a blocks layer. + + Args: + blocks: Blocks GeoDataFrame (must include 'geometry' and 'population'). + service_types: Service types dataframe filtered to infrastructure services. + section_label: Human-readable label for logging (e.g. 'BEFORE', 'AFTER'). + + Returns: + Tuple of: + - dict[service_name, GeoDataFrame] with provision columns + - dict[service_name, total_provision] where total_provision may be None + """ + blocks = blocks.copy() + + if "is_project" in blocks.columns: + blocks["is_project"] = ( + blocks["is_project"] + .infer_objects(copy=False) + .fillna(False) + .astype(bool) + ) + else: + blocks["is_project"] = False + + try: + acc_mx = get_accessibility_matrix(blocks) + except Exception as exc: + logger.exception( + f"Accessibility matrix calculation failed ({section_label}): {exc}" + ) + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(exc) + ) + + prov_gdfs: dict[str, gpd.GeoDataFrame] = {} + + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdf.join( + blocks[["is_project"]].reindex(prov_gdf.index), how="left" + ) + prov_gdf["is_project"] = prov_gdf["is_project"].fillna(False).astype(bool) + prov_gdf = prov_gdf.to_crs(4326).drop( + columns="provision_weak", errors="ignore" + ) + + num_cols = [ + c for c in prov_gdf.select_dtypes(include=["number"]).columns + if c != "is_project" + ] + if num_cols: + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + + prov_gdfs[st_name] = gpd.GeoDataFrame( + prov_gdf, geometry="geometry", crs="EPSG:4326" + ) + + prov_totals = await self.calculate_provision_totals(prov_gdfs) + logger.info( + f"Provision layers computed ({section_label}): services={len(prov_gdfs)}" + ) + return prov_gdfs, prov_totals + + + async def territory_transformation_scenario_before( + self, + token: str, + params: ContextDevelopmentDTO, + context_blocks: gpd.GeoDataFrame | None = None, ): + """Compute and cache provision layers for territory transformation. + + Semantics: + - 'before' is always computed for the *base* scenario of the project. + - 'after' is computed for the requested scenario_id (only for non-base scenarios). + + Cache: + Stored under method 'territory_transformation' in a single JSON with sections: + data.before.{service_name, ..., provision_total_before} + data.after.{service_name, ..., provision_total_after} (only for non-base) + + Returns: + - For base scenarios: dict[str, GeoDataFrame] (only BEFORE layers) + - For non-base scenarios: {"before": {...}, "after": {...}} + """ + method_name = "territory_transformation" info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] + is_based = bool(info.get("is_based")) project_id = info["project"]["project_id"] - base_scenario_id = await self.urban_api_client.get_base_scenario_id(project_id, token) - params = await self.get_optimal_func_zone_data(params, token) + base_scenario_id = await self.urban_api_client.get_base_scenario_id( + project_id, token + ) + params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) - force = getattr(params, "force", False) - cached = ( - None if force else self.cache.load(method_name, params.scenario_id, phash) - ) - if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "before" in cached["data"] - ): - return { - n: fc_to_gdf(fc) - for n, fc in cached["data"]["before"].items() - if is_fc(fc) - } + force = bool(getattr(params, "force", False)) + cached = None if force else self.cache.load(method_name, params.scenario_id, phash) + + if cached and cached.get("meta", {}).get("scenario_updated_at") == updated_at: + data = cached.get("data") or {} + has_before = isinstance(data.get("before"), dict) and any( + is_fc(v) for v in (data.get("before") or {}).values() + ) + has_after = isinstance(data.get("after"), dict) and any( + is_fc(v) for v in (data.get("after") or {}).values() + ) + + if has_before and (is_based or has_after): + before_gdfs = { + n: fc_to_gdf(fc) + for n, fc in (data.get("before") or {}).items() + if is_fc(fc) + } + if is_based: + return before_gdfs + + after_gdfs = { + n: fc_to_gdf(fc) + for n, fc in (data.get("after") or {}).items() + if is_fc(fc) + } + return {"before": before_gdfs, "after": after_gdfs} - logger.info("Cache stale, missing or forced: calculating BEFORE") + logger.info("Cache stale, missing or forced: calculating TERRITORY_TRANSFORMATION provisions") service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[ - ~service_types["infrastructure_type"].isna() - ].copy() + service_types = service_types[~service_types["infrastructure_type"].isna()].copy() - params = await self.get_optimal_func_zone_data(params, token) - base_src, base_year = ( - await self.urban_api_client.get_optimal_func_zone_request_data( - token, base_scenario_id, None, None - ) + base_src, base_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token, base_scenario_id, None, None ) - - base_scenario_blocks, base_scenario_buildings = ( - await self.scenario.aggregate_blocks_layer_scenario( - base_scenario_id, base_src, base_year, token - ) + base_scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( + base_scenario_id, base_src, base_year, token ) + if context_blocks is None: + context_blocks = gpd.GeoDataFrame(geometry=[], crs=base_scenario_blocks.crs) + before_blocks = pd.concat([context_blocks, base_scenario_blocks]).reset_index( drop=True ) - if "is_project" not in before_blocks.columns: - before_blocks["is_project"] = False - else: - before_blocks["is_project"] = ( - before_blocks["is_project"].fillna(False).astype(bool) + prov_gdfs_before, prov_totals_before = await self._compute_provision_layers( + before_blocks, + service_types=service_types, + section_label="BEFORE", + ) + + existing_data = (cached.get("data") if cached else {}) or {} + + existing_data["before"] = { + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs_before.items() + } + existing_data["before"]["provision_total_before"] = prov_totals_before + + # AFTER: requested scenario + shared context (only for non-base scenarios) + prov_gdfs_after: dict[str, gpd.GeoDataFrame] = {} + prov_totals_after: dict[str, float | None] = {} + + if not is_based: + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, ) - try: - acc_mx = get_accessibility_matrix(before_blocks) - except Exception as e: - logger.exception(f"Error getting accessibility matrix: {str(e)}") - raise http_exception(500, "Error getting accessibility matrix", _detail=e) - prov_gdfs_before = {} - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - _, demand, accessibility = service_types_config[st_name].values() - prov_gdf = await self._assess_provision(before_blocks, acc_mx, st_name) - prov_gdf = prov_gdf.join( - before_blocks[["is_project"]].reindex(prov_gdf.index), how="left" + after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( + drop=True ) - prov_gdf["is_project"] = prov_gdf["is_project"].fillna(False).astype(bool) - prov_gdf = prov_gdf.to_crs(4326) - prov_gdf = prov_gdf.drop(axis="columns", columns="provision_weak") - prov_gdfs_before[st_name] = prov_gdf - prov_totals = await self.calculate_provision_totals(prov_gdfs_before) + if ( + "population" not in after_blocks.columns + or after_blocks["population"].isna().any() + ): + dev_df = await self.run_development_parameters(after_blocks) + after_blocks["population"] = pd.to_numeric( + dev_df["population"], errors="coerce" + ).fillna(0) + else: + after_blocks["population"] = pd.to_numeric( + after_blocks["population"], errors="coerce" + ).fillna(0) + + prov_gdfs_after, prov_totals_after = await self._compute_provision_layers( + after_blocks, + service_types=service_types, + section_label="AFTER", + ) - existing_data = cached["data"] if cached else {} - try: - existing_data["before"] = { + existing_data["after"] = { name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) - for name, gdf in prov_gdfs_before.items() + for name, gdf in prov_gdfs_after.items() } - except Exception as e: - logger.exception(f"Error calculating BEFORE: {str(e)}") - raise http_exception(500, "Error calculating BEFORE", _detail=e) - existing_data["before"]["provision_total_before"] = prov_totals + existing_data["after"]["provision_total_after"] = prov_totals_after self.cache.save( method_name, @@ -332,7 +448,10 @@ async def territory_transformation_scenario_before( scenario_updated_at=updated_at, ) - return prov_gdfs_before + if is_based: + return prov_gdfs_before + + return {"before": prov_gdfs_before, "after": prov_gdfs_after} @staticmethod async def run_development_parameters( @@ -436,57 +555,57 @@ def _build_facade( return facade async def territory_transformation_scenario_after( - self, - token, - params: ContextDevelopmentDTO | DevelopmentDTO, - context_blocks: gpd.GeoDataFrame, - save_cache: bool = True, - ): - # provision after - method_name = "territory_transformation" + self, + token: str, + params: ContextDevelopmentDTO | DevelopmentDTO, + context_blocks: gpd.GeoDataFrame, + save_cache: bool = True, + ) -> dict[str, Any]: + """Compute and (optionally) cache optimization result for values transformation. + + This method no longer persists provision layers. It is only responsible for + producing `best_x` (service placement optimization vector) which is later + used by `values_transformation`. + + Cache: + Stored under method 'territory_transformation_opt' with payload: {"best_x": best_x} + + Returns: + {"best_x": best_x} + """ + + opt_method = "territory_transformation_opt" info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) updated_at = info["updated_at"] - is_based = info["is_based"] + is_based = bool(info.get("is_based")) if is_based: - logger.exception( - "Base scenario has no 'after' layer needed for calculation" - ) + logger.exception("Base scenario has no optimization 'after' context") raise http_exception( - 400, "Base scenario has no 'after' layer needed for calculation" + 400, "Base scenario has no optimization 'after' context" ) params = await self.get_optimal_func_zone_data(params, token) - params_for_hash = await self.build_hash_params(params, token) phash = self.cache.params_hash(params_for_hash) - force = getattr(params, "force", False) - cached = ( - None if force else self.cache.load(method_name, params.scenario_id, phash) - ) + force = bool(getattr(params, "force", False)) + cached = None if force else self.cache.load(opt_method, params.scenario_id, phash) + if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "after" in cached["data"] + cached + and cached.get("meta", {}).get("scenario_updated_at") == updated_at + and isinstance(cached.get("data"), dict) + and "best_x" in cached["data"] ): - gdfs_after = { - n: fc_to_gdf(fc) - for n, fc in cached["data"]["after"].items() - if is_fc(fc) - } - totals = cached["data"]["after"].get("provision_total_after") - opt_ctx = cached.get("data", {}).get("opt_context") or {} - return {"prov_gdfs_after": gdfs_after, "prov_totals": totals, **opt_ctx} + return {"best_x": cached["data"]["best_x"]} - logger.info("Cache stale, missing or forced: calculating AFTER") + logger.info("Cache stale, missing or forced: running service placement optimization") service_types = await self.urban_api_client.get_service_types() service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[ - ~service_types["infrastructure_type"].isna() - ].copy() + service_types = service_types[~service_types["infrastructure_type"].isna()].copy() scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( params.scenario_id, @@ -495,29 +614,29 @@ async def territory_transformation_scenario_after( token, ) - after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index( - drop=True - ) + after_blocks = pd.concat([context_blocks, scenario_blocks]).reset_index(drop=True) + + if "is_project" in after_blocks.columns: + after_blocks["is_project"] = ( + after_blocks["is_project"].infer_objects(copy=False).fillna(False).astype(bool) + ) + else: + after_blocks["is_project"] = False - after_blocks["is_project"] = ( - after_blocks["is_project"].fillna(False).astype(bool) - ) try: acc_mx = get_accessibility_matrix(after_blocks) - except Exception as e: + except Exception as exc: logger.exception("Accessibility matrix calculation failed") - raise http_exception( - 500, "Accessibility matrix calculation failed", _detail=str(e) - ) + raise http_exception(500, "Accessibility matrix calculation failed", _detail=str(exc)) service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) if ( - "population" not in after_blocks.columns - or after_blocks["population"].isna().any() + "population" not in after_blocks.columns + or after_blocks["population"].isna().any() ): dev_df = await self.run_development_parameters(after_blocks) after_blocks["population"] = pd.to_numeric( @@ -527,11 +646,10 @@ async def territory_transformation_scenario_after( after_blocks["population"] = pd.to_numeric( after_blocks["population"], errors="coerce" ).fillna(0) + facade = self._build_facade(after_blocks, acc_mx, service_types) - services_weights = service_types.set_index("name")[ - "infrastructure_weight" - ].to_dict() + services_weights = service_types.set_index("name")["infrastructure_weight"].to_dict() objective = WeightedObjective( num_params=facade.num_params, @@ -556,74 +674,29 @@ async def territory_transformation_scenario_after( 500, "Service placement optimization failed", _detail=str(e) ) - prov_gdfs_after = {} - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - if st_name in facade._chosen_service_types: - prov_df = facade._provision_adapter.get_last_provision_df(st_name) - prov_gdf = ( - after_blocks[["geometry", "is_project"]] - .join(prov_df, how="left") - .drop(columns="provision_weak", errors="ignore") - ) - - if getattr(prov_gdf, "crs", None) is None: - prov_gdf = gpd.GeoDataFrame( - prov_gdf, geometry="geometry", crs=after_blocks.crs - ) - prov_gdf = prov_gdf.to_crs(4326) - - prov_gdf["is_project"] = ( - prov_gdf["is_project"].fillna(False).astype(bool) - ) - num_cols = [ - c - for c in prov_gdf.select_dtypes(include=["number"]).columns - if c != "is_project" - ] - if num_cols: - prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) - - prov_gdfs_after[st_name] = gpd.GeoDataFrame( - prov_gdf, geometry="geometry", crs="EPSG:4326" - ) - - prov_totals = await self.calculate_provision_totals(prov_gdfs_after) - - after_fc = { - name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) - for name, gdf in prov_gdfs_after.items() - } - after_fc["provision_total_after"] = prov_totals - - from_cache = cached.get("data", {}).copy() if cached else {} - from_cache["after"] = after_fc - from_cache["opt_context"] = {"best_x": best_x} - if save_cache: self.cache.save( - "territory_transformation", + opt_method, params.scenario_id, params_for_hash, - from_cache, + {"best_x": best_x}, scenario_updated_at=updated_at, ) - return { - "best_x": best_x, - "prov_totals": prov_totals, - "prov_gdfs_after": prov_gdfs_after, - } + return {"best_x": best_x} async def territory_transformation( - self, - token: str, - params: ContextDevelopmentDTO, + self, + token: str, + params: ContextDevelopmentDTO, ) -> dict[str, Any] | dict[str, dict[str, Any]]: + """Compute territory transformation provision layers. - info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - is_based = info["is_based"] - updated_at = info["updated_at"] + NOTE: + Provision layers for both 'before' (base scenario) and 'after' (requested scenario) + are computed inside `territory_transformation_scenario_before`. The 'after' section + is omitted for base scenarios. + """ context_blocks, _ = await self.context.aggregate_blocks_layer_context( params.scenario_id, @@ -631,32 +704,10 @@ async def territory_transformation( params.context_func_source_year, token, ) - prov_before = await self.territory_transformation_scenario_before( - token, params, context_blocks - ) - if is_based: - return prov_before - - params_for_hash = await self.build_hash_params(params, token) - phash = self.cache.params_hash(params_for_hash) - - cached = self.cache.load("territory_transformation", params.scenario_id, phash) - if ( - cached - and cached["meta"]["scenario_updated_at"] == updated_at - and "after" in cached["data"] - ): - prov_after = { - name: fc_to_gdf(fc) - for name, fc in cached["data"]["after"].items() - if is_fc(fc) - } - return {"before": prov_before, "after": prov_after} - prov_after = await self.territory_transformation_scenario_after( + return await self.territory_transformation_scenario_before( token, params, context_blocks ) - return {"before": prov_before, "after": prov_after} async def values_transformation( self, diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index be77a0e..1549e8a 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -26,7 +26,9 @@ def _cache_complete(method: str, cached_obj: dict | None) -> bool: return False data = cached_obj.get("data") or {} if method == "territory_transformation": - return bool(data.get("after")) + if data.get("after"): + return True + return bool(data.get("before")) return True _task_queue: asyncio.Queue["AnyTask"] = asyncio.Queue() diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 8e680e7..10ca5c7 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -66,7 +66,11 @@ def _cache_complete(method: str, cached: dict | None) -> bool: return False data = cached.get("data") or {} if method == "territory_transformation": - return _section_ready(data.get("before")) and _section_ready(data.get("after")) + before_ok = _section_ready(data.get("before")) + after_sec = data.get("after") + if after_sec: + return before_ok and _section_ready(after_sec) + return before_ok return True From 7441e7444cf6f066f1ea3cc159f87cabc21e6d40 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 21 Dec 2025 03:02:32 +0300 Subject: [PATCH 152/161] fix(f35): 1. Adjusted correct base id --- app/effects_api/effects_service.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index d69e42b..a8eab5a 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -328,10 +328,8 @@ async def territory_transformation_scenario_before( updated_at = info["updated_at"] is_based = bool(info.get("is_based")) project_id = info["project"]["project_id"] - - base_scenario_id = await self.urban_api_client.get_base_scenario_id( - project_id, token - ) + base_id_response = await self.urban_api_client.get_all_project_info(project_id, token) + base_scenario_id = base_id_response["base_scenario"]["id"] params = await self.get_optimal_func_zone_data(params, token) params_for_hash = await self.build_hash_params(params, token) @@ -399,7 +397,6 @@ async def territory_transformation_scenario_before( } existing_data["before"]["provision_total_before"] = prov_totals_before - # AFTER: requested scenario + shared context (only for non-base scenarios) prov_gdfs_after: dict[str, gpd.GeoDataFrame] = {} prov_totals_after: dict[str, float | None] = {} From 5839f6e265870aadf49c5959e166948840280b7c Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 21 Dec 2025 14:09:28 +0300 Subject: [PATCH 153/161] feat(producer): 1. KafkaProducerClient init_loop set to False --- app/common/producer_wrapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/common/producer_wrapper.py b/app/common/producer_wrapper.py index 1fc3fc2..f968005 100644 --- a/app/common/producer_wrapper.py +++ b/app/common/producer_wrapper.py @@ -4,7 +4,7 @@ class ProducerWrapper: def __init__(self): self.producer_settings = KafkaProducerSettings.from_env() - self.producer_service = KafkaProducerClient(self.producer_settings) + self.producer_service = KafkaProducerClient(self.producer_settings, init_loop=False) async def start(self): await self.producer_service.start() From 07ac6a8ff88773f638d670040df607761b7549d4 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 21 Dec 2025 14:15:30 +0300 Subject: [PATCH 154/161] feat(producer): 1. KafkaProducerClient init_loop at start --- app/common/producer_wrapper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/common/producer_wrapper.py b/app/common/producer_wrapper.py index f968005..56f4c25 100644 --- a/app/common/producer_wrapper.py +++ b/app/common/producer_wrapper.py @@ -7,6 +7,7 @@ def __init__(self): self.producer_service = KafkaProducerClient(self.producer_settings, init_loop=False) async def start(self): + self.producer_service.init_loop() await self.producer_service.start() async def stop(self) -> None: From b6b6966ebe23eb774b425e66d2f14031ac617fa2 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Sun, 21 Dec 2025 19:33:58 +0300 Subject: [PATCH 155/161] fix(worker): 1. Fixing worker timeout errors --- Dockerfile | 3 +- app/effects_api/modules/task_service.py | 50 ++++++++++++++----------- 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/Dockerfile b/Dockerfile index b1f9107..c0a67f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,6 @@ ENV PYTHONUNBUFFERED=1 # Enables env file ENV APP_ENV=production -ENV APP_WORKERS=1 #add pypi mirror to config COPY pip.conf /etc/xdg/pip/pip.conf @@ -27,4 +26,4 @@ WORKDIR /app COPY . /app # During debugging, this entry point will be overridden. For more information, please refer to https://aka.ms/vscode-docker-python-debug -CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --workers $APP_WORKERS app.main:app"] \ No newline at end of file +CMD ["sh", "-c", "gunicorn --bind 0.0.0.0:80 -k uvicorn.workers.UvicornWorker --workers 1 --timeout 600 app.main:app"] \ No newline at end of file diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index d004bd4..00ece9e 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -2,7 +2,7 @@ import contextlib import json from contextlib import asynccontextmanager -from typing import Any, Callable, Literal +from typing import Any, Callable, Literal, Coroutine import geopandas as gpd from fastapi import FastAPI @@ -11,7 +11,7 @@ from app.common.exceptions.http_exception_wrapper import http_exception from app.dependencies import effects_service, file_cache, effects_utils, consumer, producer -MethodFunc = Callable[[str, Any], "dict[str, Any]"] +MethodFunc = Callable[[str, Any], Coroutine[Any, Any, Any]] TASK_METHODS: dict[str, MethodFunc] = { "territory_transformation": effects_service.territory_transformation, @@ -65,14 +65,18 @@ async def to_response(self) -> dict: return {"status": "done", "result": self.result} return {"status": "failed", "error": self.error} - def run_sync(self) -> None: + async def run(self) -> None: + """ + Run task asynchronously inside event loop. + """ try: logger.info(f"[{self.task_id}] started") self.status = "running" force = getattr(self.params, "force", False) - - cached = None if force else self.cache.load(self.method, self.scenario_id, self.param_hash) + cached = None if force else self.cache.load( + self.method, self.scenario_id, self.param_hash + ) if not force and _cache_complete(self.method, cached): logger.info(f"[{self.task_id}] loaded from cache") @@ -81,22 +85,9 @@ def run_sync(self) -> None: return func = TASK_METHODS[self.method] - raw_data = asyncio.run(func(self.token, self.params)) - - def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: - return json.loads(gdf.to_json(drop_id=True)) - - if isinstance(raw_data, gpd.GeoDataFrame): - data_to_cache = gdf_to_dict(raw_data) - elif isinstance(raw_data, dict): - data_to_cache = { - k: gdf_to_dict(v) if isinstance(v, gpd.GeoDataFrame) else v - for k, v in raw_data.items() - } - else: - data_to_cache = raw_data - - self.result = data_to_cache + raw_data = await func(self.token, self.params) + + self.result = self._serialize_result(raw_data) self.status = "done" except Exception as exc: @@ -104,6 +95,21 @@ def gdf_to_dict(gdf: gpd.GeoDataFrame) -> dict: self.status = "failed" self.error = str(exc) + def _serialize_result(self, raw_data): + """Serialize GeoDataFrame or dict to json-compatible structure.""" + if isinstance(raw_data, gpd.GeoDataFrame): + return json.loads(raw_data.to_json(drop_id=True)) + + if isinstance(raw_data, dict): + return { + k: json.loads(v.to_json(drop_id=True)) + if isinstance(v, gpd.GeoDataFrame) + else v + for k, v in raw_data.items() + } + + return raw_data + async def create_task( method: str, @@ -226,7 +232,7 @@ def __init__(self): async def run(self): while self.is_alive: task: AnyTask = await _task_queue.get() - await asyncio.to_thread(task.run_sync) + await task.run() _task_queue.task_done() def start(self): From c6995c3283b05d986533770af7bca4d3c5ae5771 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 22 Dec 2025 13:52:51 +0300 Subject: [PATCH 156/161] fix(f35): 1. Fix for population in scenario blocks 2. Deleted logic for erasing united context blocks on broker events 3. Featured logic for using united context blocks in F35 --- app/common/caching/caching_service.py | 5 +---- app/effects_api/effects_service.py | 11 +++++------ app/effects_api/modules/context_service.py | 2 +- app/effects_api/modules/scenario_service.py | 2 +- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/app/common/caching/caching_service.py b/app/common/caching/caching_service.py index 31a066b..e8ce8e1 100644 --- a/app/common/caching/caching_service.py +++ b/app/common/caching/caching_service.py @@ -227,11 +227,8 @@ def delete_all(self, method: str, owner_id: int) -> int: json_pattern = f"*__{prefix}_{owner_id}__{_safe(method)}__*.json" json_files = list(_CACHE_DIR.glob(json_pattern)) - artifact_pattern = f"artifact__{_safe(method)}__{owner_id}__*" - artifact_files = list(_CACHE_DIR.glob(artifact_pattern)) - deleted = 0 - for path in json_files + artifact_files: + for path in json_files: try: path.unlink(missing_ok=True) deleted += 1 diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index a8eab5a..3bcc5ce 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -694,13 +694,12 @@ async def territory_transformation( are computed inside `territory_transformation_scenario_before`. The 'after' section is omitted for base scenarios. """ + project_id = ( + await self.urban_api_client.get_scenario_info(params.scenario_id, token) + )["project"]["project_id"] - context_blocks, _ = await self.context.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, - ) + context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, + token) return await self.territory_transformation_scenario_before( token, params, context_blocks diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index 59b7c27..b9f1db8 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -353,7 +353,7 @@ async def get_shared_context( ) return ctx_blocks, ctx_territories, service_types - logger.info("Shared context cache miss for project_id={project_id} — building") + logger.info(f"Shared context cache miss for project_id={project_id} — is building") territory_id = (await self.client.get_all_project_info(project_id, token))[ "territory" diff --git a/app/effects_api/modules/scenario_service.py b/app/effects_api/modules/scenario_service.py index 3fca090..f982163 100644 --- a/app/effects_api/modules/scenario_service.py +++ b/app/effects_api/modules/scenario_service.py @@ -159,7 +159,7 @@ async def get_scenario_buildings(self, scenario_id: int, token: str): scenario_id, token, physical_object_type_id=LIVING_BUILDINGS_ID, - centers_only=True, + centers_only=False, ) if gdf is None: return None From a699eb7451819b3db686b81c461c99e079aa6658 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Mon, 22 Dec 2025 20:23:50 +0300 Subject: [PATCH 157/161] feat(broker): 1. Featured territory_transformation in broker cache deletion on scenario zones and object update --- app/broker_handlers/scenario_updated_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/app/broker_handlers/scenario_updated_handler.py b/app/broker_handlers/scenario_updated_handler.py index 0d49391..b5a80b5 100644 --- a/app/broker_handlers/scenario_updated_handler.py +++ b/app/broker_handlers/scenario_updated_handler.py @@ -15,6 +15,7 @@ _SOCIAL_RULES = [ CacheInvalidationRule(method="social_economical_metrics", owner_id_getter=lambda e: e.project_id), + CacheInvalidationRule(method="territory_transformation", owner_id_getter=lambda e: e.scenario_id) ] From 447582deda2b96537dd126a9f9569cd951889332 Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Wed, 24 Dec 2025 18:16:49 +0300 Subject: [PATCH 158/161] feat(prometheus): 1. Featured Prometheus logic --- app/broker_handlers/cache_invalidation.py | 38 ++++++++++++-------- app/effects_api/effects_service.py | 26 +++++++++++--- app/main.py | 2 ++ app/prometheus/metrics.py | 41 ++++++++++++++++++++++ app/prometheus/server.py | 10 ++++++ requirements.txt | Bin 870 -> 924 bytes 6 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 app/prometheus/metrics.py create mode 100644 app/prometheus/server.py diff --git a/app/broker_handlers/cache_invalidation.py b/app/broker_handlers/cache_invalidation.py index a9e9606..da47911 100644 --- a/app/broker_handlers/cache_invalidation.py +++ b/app/broker_handlers/cache_invalidation.py @@ -7,6 +7,10 @@ from confluent_kafka import Message from loguru import logger from otteroad import KafkaProducerClient +import time + +from app.prometheus.metrics import CACHE_INVALIDATION_EVENTS_TOTAL, CACHE_INVALIDATION_ERROR_TOTAL, \ + CACHE_INVALIDATION_DURATION_SECONDS, CACHE_INVALIDATION_SUCCESS_TOTAL from app.common.caching.caching_service import FileCache @@ -63,17 +67,23 @@ def __init__( self._rules = rules async def _handle_cache_invalidation(self, event: Any, ctx: Message | None = None) -> None: - logger.info(f"Received event: type={type(event)}") - logger.info( - f"Invalidate cache for project_id={getattr(event, 'project_id', None)} " - f"scenario_id={getattr(event, 'scenario_id', None)}" - ) - - total_deleted = await asyncio.to_thread( - self._invalidation_service.invalidate, - event, - self._rules, - ) - logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") - - return None + CACHE_INVALIDATION_EVENTS_TOTAL.inc() + start_time = time.perf_counter() + + try: + total_deleted = await asyncio.to_thread( + self._invalidation_service.invalidate, + event, + self._rules, + ) + CACHE_INVALIDATION_SUCCESS_TOTAL.inc() + return None + + except Exception: + CACHE_INVALIDATION_ERROR_TOTAL.inc() + raise + + finally: + duration = time.perf_counter() - start_time + CACHE_INVALIDATION_DURATION_SECONDS.observe(duration) + diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 3bcc5ce..18cebae 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -2,6 +2,7 @@ import json import math import re +import time from pathlib import Path from typing import Any, Dict, Literal @@ -79,6 +80,8 @@ ) from .dto.transformation_effects_dto import TerritoryTransformationDTO from .modules.context_service import ContextService +from ..prometheus.metrics import EFFECTS_TERRITORY_TRANSFORMATION_TOTAL, EFFECTS_TERRITORY_TRANSFORMATION_ERROR_TOTAL, \ + EFFECTS_TERRITORY_TRANSFORMATION_DURATION_SECONDS class EffectsService: @@ -698,12 +701,25 @@ async def territory_transformation( await self.urban_api_client.get_scenario_info(params.scenario_id, token) )["project"]["project_id"] - context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, - token) - - return await self.territory_transformation_scenario_before( - token, params, context_blocks + # context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, + # token) + context_blocks, _ = await self.context.aggregate_blocks_layer_context( + params.scenario_id, + params.context_func_zone_source, + params.context_func_source_year, + token, ) + EFFECTS_TERRITORY_TRANSFORMATION_TOTAL.inc() + start_time = time.perf_counter() + try: + return await self.territory_transformation_scenario_before(token, params, context_blocks) + except Exception: + EFFECTS_TERRITORY_TRANSFORMATION_ERROR_TOTAL.inc() + raise + finally: + EFFECTS_TERRITORY_TRANSFORMATION_DURATION_SECONDS.observe( + time.perf_counter() - start_time + ) async def values_transformation( self, diff --git a/app/main.py b/app/main.py index e2374cb..5afa8d0 100644 --- a/app/main.py +++ b/app/main.py @@ -6,6 +6,7 @@ from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.modules.task_service import lifespan from app.effects_api.tasks_controller import router as tasks_router +from app.prometheus.server import start_metrics_server from app.system_router.system_controller import system_router # TODO add app version @@ -33,5 +34,6 @@ async def read_root(): return RedirectResponse("/docs") +start_metrics_server(port=8001) app.include_router(tasks_router) app.include_router(system_router) diff --git a/app/prometheus/metrics.py b/app/prometheus/metrics.py new file mode 100644 index 0000000..1ed891c --- /dev/null +++ b/app/prometheus/metrics.py @@ -0,0 +1,41 @@ +"""Prometheus metrics for Effects API.""" + +from prometheus_client import Counter, Histogram + + +CACHE_INVALIDATION_EVENTS_TOTAL = Counter( + "effects_cache_invalidation_events_total", + "Total number of cache invalidation events received", +) + +CACHE_INVALIDATION_SUCCESS_TOTAL = Counter( + "effects_cache_invalidation_success_total", + "Total number of cache invalidation events successfully processed", +) + +CACHE_INVALIDATION_ERROR_TOTAL = Counter( + "effects_cache_invalidation_error_total", + "Total number of cache invalidation events failed during processing", +) + +CACHE_INVALIDATION_DURATION_SECONDS = Histogram( + "effects_cache_invalidation_duration_seconds", + "Duration of cache invalidation processing", + buckets=(0.05, 0.1, 0.2, 0.5, 1, 2, 5, 10), +) + +EFFECTS_TERRITORY_TRANSFORMATION_TOTAL = Counter( + "effects_territory_transformation_total", + "Total number of territory_transformation calls", +) + +EFFECTS_TERRITORY_TRANSFORMATION_ERROR_TOTAL = Counter( + "effects_territory_transformation_error_total", + "Total number of failed territory_transformation calls", +) + +EFFECTS_TERRITORY_TRANSFORMATION_DURATION_SECONDS = Histogram( + "effects_territory_transformation_duration_seconds", + "Duration of territory_transformation execution", + buckets=(1, 2, 5, 10, 30, 60, 120, 300), +) diff --git a/app/prometheus/server.py b/app/prometheus/server.py new file mode 100644 index 0000000..97e62b7 --- /dev/null +++ b/app/prometheus/server.py @@ -0,0 +1,10 @@ +# app/metrics_server.py +from prometheus_client import start_http_server + +_server = None + + +def start_metrics_server(port: int = 8000) -> None: + """Start Prometheus metrics HTTP server.""" + global _server + _server = start_http_server(port) diff --git a/requirements.txt b/requirements.txt index 6fe48a7afcc2cf8f7f269fa240e6615788368abc..5ec80ef1df42613101a8664a7935b8905885dc47 100644 GIT binary patch delta 57 zcmaFHHivz~118-9h9ZW1hFpeJh7yJhAX&;#%%IDV%#g#73FPGgMe7)B84MWo7>pQ< JCtqjk2LP~S4Zr{Z delta 11 ScmbQk{)}zI1E$Gh%xwT1e*{zj From 4208c0eda3ebb322d29d5531e5390e60cdd95ab0 Mon Sep 17 00:00:00 2001 From: voronapxl Date: Wed, 14 Jan 2026 16:13:45 +0300 Subject: [PATCH 159/161] feat(prometheus_metrics): 1. Featured additional metrics for values_transformation, values_oriented_requirements, social_economical_metrics, 2. Moved Prometheus port to env --- app/effects_api/effects_service.py | 952 ++++++++++++------------ app/effects_api/modules/task_service.py | 44 +- app/main.py | 2 - app/prometheus/metrics.py | 109 ++- app/prometheus/server.py | 9 +- 5 files changed, 647 insertions(+), 469 deletions(-) diff --git a/app/effects_api/effects_service.py b/app/effects_api/effects_service.py index 18cebae..3bf0b31 100644 --- a/app/effects_api/effects_service.py +++ b/app/effects_api/effects_service.py @@ -80,8 +80,19 @@ ) from .dto.transformation_effects_dto import TerritoryTransformationDTO from .modules.context_service import ContextService -from ..prometheus.metrics import EFFECTS_TERRITORY_TRANSFORMATION_TOTAL, EFFECTS_TERRITORY_TRANSFORMATION_ERROR_TOTAL, \ - EFFECTS_TERRITORY_TRANSFORMATION_DURATION_SECONDS +from ..prometheus.metrics import ( + EFFECTS_TERRITORY_TRANSFORMATION_TOTAL, + EFFECTS_TERRITORY_TRANSFORMATION_ERROR_TOTAL, + EFFECTS_TERRITORY_TRANSFORMATION_DURATION_SECONDS, + EFFECTS_VALUES_TRANSFORMATION_TOTAL, + EFFECTS_VALUES_TRANSFORMATION_ERROR_TOTAL, + EFFECTS_VALUES_TRANSFORMATION_DURATION_SECONDS, + EFFECTS_VALUES_ORIENTED_REQUIREMENTS_TOTAL, + EFFECTS_VALUES_ORIENTED_REQUIREMENTS_ERROR_TOTAL, + EFFECTS_VALUES_ORIENTED_REQUIREMENTS_DURATION_SECONDS, + EFFECTS_SOCIO_ECONOMICAL_METRICS_TOTAL, + EFFECTS_SOCIO_ECONOMICAL_METRICS_ERROR_TOTAL, + EFFECTS_SOCIO_ECONOMICAL_METRICS_DURATION_SECONDS) class EffectsService: @@ -726,280 +737,290 @@ async def values_transformation( token: str, params: TerritoryTransformationDTO, ) -> dict: - opt_method = "territory_transformation_opt" + EFFECTS_VALUES_TRANSFORMATION_TOTAL.inc() + start_time = time.perf_counter() + try: + start_time = time.perf_counter() - params = await self.get_optimal_func_zone_data(params, token) + opt_method = "territory_transformation_opt" - params_for_hash = await self.build_hash_params(params, token) - phash = self.cache.params_hash(params_for_hash) - force = getattr(params, "force", False) + params = await self.get_optimal_func_zone_data(params, token) - info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) - updated_at = info["updated_at"] + params_for_hash = await self.build_hash_params(params, token) + phash = self.cache.params_hash(params_for_hash) + force = getattr(params, "force", False) - context_blocks, _ = await self.context.aggregate_blocks_layer_context( - params.scenario_id, - params.context_func_zone_source, - params.context_func_source_year, - token, - ) + info = await self.urban_api_client.get_scenario_info(params.scenario_id, token) + updated_at = info["updated_at"] - opt_cached = ( - None if force else self.cache.load(opt_method, params.scenario_id, phash) - ) - need_refresh = ( - force - or not opt_cached - or opt_cached["meta"]["scenario_updated_at"] != updated_at - or "best_x" not in opt_cached["data"] - ) - if need_refresh: - res = await self.territory_transformation_scenario_after( - token, params, context_blocks, save_cache=False - ) - best_x_val = res["best_x"] - - self.cache.save( - opt_method, + context_blocks, _ = await self.context.aggregate_blocks_layer_context( params.scenario_id, - params_for_hash, - {"best_x": best_x_val}, - scenario_updated_at=updated_at, + params.context_func_zone_source, + params.context_func_source_year, + token, ) - opt_cached = self.cache.load(opt_method, params.scenario_id, phash) - best_x = opt_cached["data"]["best_x"] - - scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( - params.scenario_id, - params.proj_func_zone_source, - params.proj_func_source_year, - token, - ) - - after_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=False) - if "block_id" in after_blocks.columns: - after_blocks["block_id"] = after_blocks["block_id"].astype(int) - if after_blocks.index.name == "block_id": - after_blocks = after_blocks.reset_index(drop=True) - after_blocks = ( - after_blocks.drop_duplicates(subset="block_id", keep="last") - .set_index("block_id") - .sort_index() + opt_cached = ( + None if force else self.cache.load(opt_method, params.scenario_id, phash) ) - else: - after_blocks.index = after_blocks.index.astype(int) - after_blocks = after_blocks[ - ~after_blocks.index.duplicated(keep="last") - ].sort_index() - after_blocks.index.name = "block_id" - - if "is_project" in after_blocks.columns: - after_blocks["is_project"] = ( - after_blocks["is_project"].fillna(False).astype(bool) + need_refresh = ( + force + or not opt_cached + or opt_cached["meta"]["scenario_updated_at"] != updated_at + or "best_x" not in opt_cached["data"] ) - else: - after_blocks["is_project"] = False + if need_refresh: + res = await self.territory_transformation_scenario_after( + token, params, context_blocks, save_cache=False + ) + best_x_val = res["best_x"] - try: - acc_mx = get_accessibility_matrix(after_blocks) - except Exception as e: - logger.exception("Accessibility matrix calculation failed") - raise http_exception( - 500, "Accessibility matrix calculation failed", _detail=str(e) - ) + self.cache.save( + opt_method, + params.scenario_id, + params_for_hash, + {"best_x": best_x_val}, + scenario_updated_at=updated_at, + ) + opt_cached = self.cache.load(opt_method, params.scenario_id, phash) - service_types = await self.urban_api_client.get_service_types() - service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[ - ~service_types["infrastructure_type"].isna() - ].copy() - service_types["infrastructure_weight"] = ( - service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) - * service_types["infrastructure_weight"] - ) + best_x = opt_cached["data"]["best_x"] - facade = self._build_facade(after_blocks, acc_mx, service_types) - test_blocks: gpd.GeoDataFrame = after_blocks.loc[ - list(facade._blocks_lu.keys()) - ].copy() - test_blocks.index = test_blocks.index.astype(int) + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( + params.scenario_id, + params.proj_func_zone_source, + params.proj_func_source_year, + token, + ) - try: - solution_df = facade.solution_to_services_df(best_x).copy() - except Exception as e: - logger.exception("Solution calculation failed") - raise http_exception(500, "Solution calculation failed", _detail=str(e)) - - solution_df["block_id"] = solution_df["block_id"].astype(int) - metrics = [ - c - for c in ["site_area", "build_floor_area", "capacity", "count"] - if c in solution_df.columns - ] + after_blocks = pd.concat([context_blocks, scenario_blocks], ignore_index=False) + if "block_id" in after_blocks.columns: + after_blocks["block_id"] = after_blocks["block_id"].astype(int) + if after_blocks.index.name == "block_id": + after_blocks = after_blocks.reset_index(drop=True) + after_blocks = ( + after_blocks.drop_duplicates(subset="block_id", keep="last") + .set_index("block_id") + .sort_index() + ) + else: + after_blocks.index = after_blocks.index.astype(int) + after_blocks = after_blocks[ + ~after_blocks.index.duplicated(keep="last") + ].sort_index() + after_blocks.index.name = "block_id" + + if "is_project" in after_blocks.columns: + after_blocks["is_project"] = ( + after_blocks["is_project"].fillna(False).astype(bool) + ) + else: + after_blocks["is_project"] = False - if metrics: - non_zero_mask = (solution_df[metrics].fillna(0) != 0).any(axis=1) - solution_df = solution_df[non_zero_mask].copy() + try: + acc_mx = get_accessibility_matrix(after_blocks) + except Exception as e: + logger.exception("Accessibility matrix calculation failed") + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(e) + ) - if len(metrics): - agg = ( - solution_df.groupby(["block_id", "service_type"])[metrics] - .sum() - .sort_index() - ) - else: - agg = ( - solution_df.groupby(["block_id", "service_type"]) - .size() - .to_frame(name="__dummy__") - .drop(columns="__dummy__") + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[ + ~service_types["infrastructure_type"].isna() + ].copy() + service_types["infrastructure_weight"] = ( + service_types["infrastructure_type"].map(INFRASTRUCTURES_WEIGHTS) + * service_types["infrastructure_weight"] ) - def _row_to_dict(s: pd.Series) -> dict: - d = {m: (0 if pd.isna(s.get(m)) else s.get(m)) for m in metrics} - for k, v in d.items(): - try: - fv = float(v) - d[k] = int(fv) if fv.is_integer() else fv - except Exception: - pass - return d + facade = self._build_facade(after_blocks, acc_mx, service_types) + test_blocks: gpd.GeoDataFrame = after_blocks.loc[ + list(facade._blocks_lu.keys()) + ].copy() + test_blocks.index = test_blocks.index.astype(int) - cells = ( - agg.apply(_row_to_dict, axis=1) - if len(metrics) - else agg.apply(lambda _: {}, axis=1) - ) - wide = cells.unstack("service_type").reindex(index=test_blocks.index) + try: + solution_df = facade.solution_to_services_df(best_x).copy() + except Exception as e: + logger.exception("Solution calculation failed") + raise http_exception(500, "Solution calculation failed", _detail=str(e)) - all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) - for s in all_services: - if s not in wide.columns: - wide[s] = np.nan + solution_df["block_id"] = solution_df["block_id"].astype(int) + metrics = [ + c + for c in ["site_area", "build_floor_area", "capacity", "count"] + if c in solution_df.columns + ] - cells = ( - agg.apply(_row_to_dict, axis=1) - if len(metrics) - else agg.apply(lambda _: {}, axis=1) - ) - wide = cells.unstack("service_type").reindex(index=test_blocks.index) + if metrics: + non_zero_mask = (solution_df[metrics].fillna(0) != 0).any(axis=1) + solution_df = solution_df[non_zero_mask].copy() - all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) - for s in all_services: - if s not in wide.columns: - wide[s] = np.nan + if len(metrics): + agg = ( + solution_df.groupby(["block_id", "service_type"])[metrics] + .sum() + .sort_index() + ) + else: + agg = ( + solution_df.groupby(["block_id", "service_type"]) + .size() + .to_frame(name="__dummy__") + .drop(columns="__dummy__") + ) - wide = wide[all_services] - test_blocks_with_services: gpd.GeoDataFrame = test_blocks.join(wide, how="left") + def _row_to_dict(s: pd.Series) -> dict: + d = {m: (0 if pd.isna(s.get(m)) else s.get(m)) for m in metrics} + for k, v in d.items(): + try: + fv = float(v) + d[k] = int(fv) if fv.is_integer() else fv + except Exception: + pass + return d + + cells = ( + agg.apply(_row_to_dict, axis=1) + if len(metrics) + else agg.apply(lambda _: {}, axis=1) + ) + wide = cells.unstack("service_type").reindex(index=test_blocks.index) - logger.info("Values transformed complete") + all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) + for s in all_services: + if s not in wide.columns: + wide[s] = np.nan - geom_col = test_blocks_with_services.geometry.name - service_cols = all_services - base_cols = [ - c for c in ["is_project"] if c in test_blocks_with_services.columns - ] + cells = ( + agg.apply(_row_to_dict, axis=1) + if len(metrics) + else agg.apply(lambda _: {}, axis=1) + ) + wide = cells.unstack("service_type").reindex(index=test_blocks.index) - gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] + all_services = sorted(solution_df["service_type"].dropna().unique().tolist()) + for s in all_services: + if s not in wide.columns: + wide[s] = np.nan - try: - logger.info("Running land-use prediction on 'after_blocks'") + wide = wide[all_services] + test_blocks_with_services: gpd.GeoDataFrame = test_blocks.join(wide, how="left") - ab = after_blocks[ - after_blocks.geometry.notna() & ~after_blocks.geometry.is_empty - ].copy() - ab.geometry = ab.geometry.buffer(0) + logger.info("Values transformed complete") + + geom_col = test_blocks_with_services.geometry.name + service_cols = all_services + base_cols = [ + c for c in ["is_project"] if c in test_blocks_with_services.columns + ] + + gdf_out = test_blocks_with_services[base_cols + service_cols + [geom_col]] try: - utm_crs = ab.estimate_utm_crs() - ab = ab.to_crs(utm_crs) - except Exception: - ab = ab.to_crs("EPSG:3857") + logger.info("Running land-use prediction on 'after_blocks'") - clf = SpatialClassifier.default() - lu = clf.run(ab) + ab = after_blocks[ + after_blocks.geometry.notna() & ~after_blocks.geometry.is_empty + ].copy() + ab.geometry = ab.geometry.buffer(0) - lu = lu.drop(columns=["category"], errors="ignore") + try: + utm_crs = ab.estimate_utm_crs() + ab = ab.to_crs(utm_crs) + except Exception: + ab = ab.to_crs("EPSG:3857") - keep_cols = ["pred_name", "prob_urban", "prob_non_urban", "prob_industrial"] - for c in keep_cols: - if c not in lu.columns: - lu[c] = np.nan - lu = lu[keep_cols] + clf = SpatialClassifier.default() + lu = clf.run(ab) - lu = _ensure_block_index(lu) - gdf_out = _ensure_block_index(gdf_out) - gdf_out = gdf_out.join(lu, how="left") + lu = lu.drop(columns=["category"], errors="ignore") - logger.info( - "Attached land-use predictions to gdf_out (cols: {})", keep_cols - ) + keep_cols = ["pred_name", "prob_urban", "prob_non_urban", "prob_industrial"] + for c in keep_cols: + if c not in lu.columns: + lu[c] = np.nan + lu = lu[keep_cols] - if "pred_name" in gdf_out.columns: - gdf_out["Предсказанный вид использования"] = ( - gdf_out["pred_name"] - .str.lower() - .map(PRED_VALUE_RU) - .fillna(gdf_out["pred_name"]) - ) - gdf_out = gdf_out.drop(columns=["pred_name"]) + lu = _ensure_block_index(lu) + gdf_out = _ensure_block_index(gdf_out) + gdf_out = gdf_out.join(lu, how="left") - prob_cols = [ - c - for c in ["prob_urban", "prob_non_urban", "prob_industrial"] - if c in gdf_out.columns - ] - for col in prob_cols: - gdf_out[col] = gdf_out[col].astype(float).round(1) + logger.info( + "Attached land-use predictions to gdf_out (cols: {})", keep_cols + ) - rename_map = { - k: v for k, v in PROB_COLS_EN_TO_RU.items() if k in gdf_out.columns - } - gdf_out = gdf_out.rename(columns=rename_map) + if "pred_name" in gdf_out.columns: + gdf_out["Предсказанный вид использования"] = ( + gdf_out["pred_name"] + .str.lower() + .map(PRED_VALUE_RU) + .fillna(gdf_out["pred_name"]) + ) + gdf_out = gdf_out.drop(columns=["pred_name"]) + + prob_cols = [ + c + for c in ["prob_urban", "prob_non_urban", "prob_industrial"] + if c in gdf_out.columns + ] + for col in prob_cols: + gdf_out[col] = gdf_out[col].astype(float).round(1) + + rename_map = { + k: v for k, v in PROB_COLS_EN_TO_RU.items() if k in gdf_out.columns + } + gdf_out = gdf_out.rename(columns=rename_map) - except Exception as e: - raise http_exception(500, "Failed to attach land-use predictions: {}", e) + except Exception as e: + raise http_exception(500, "Failed to attach land-use predictions: {}", e) - gdf_out = gdf_out.to_crs("EPSG:4326") - gdf_out.geometry = round_coords(gdf_out.geometry, 6) + gdf_out = gdf_out.to_crs("EPSG:4326") + gdf_out.geometry = round_coords(gdf_out.geometry, 6) - service_types = await self.urban_api_client.get_service_types() - try: - en2ru = await build_en_to_ru_map(service_types) - rename_map = {k: v for k, v in en2ru.items() if k in gdf_out.columns} - if rename_map: - gdf_out = gdf_out.rename(columns=rename_map) + service_types = await self.urban_api_client.get_service_types() + try: + en2ru = await build_en_to_ru_map(service_types) + rename_map = {k: v for k, v in en2ru.items() if k in gdf_out.columns} + if rename_map: + gdf_out = gdf_out.rename(columns=rename_map) - geom_col = gdf_out.geometry.name - non_geom = [c for c in gdf_out.columns if c != geom_col] + geom_col = gdf_out.geometry.name + non_geom = [c for c in gdf_out.columns if c != geom_col] - pin_first = [ - c - for c in ["is_project", "Предсказанный вид использования"] - if c in non_geom - ] + pin_first = [ + c + for c in ["is_project", "Предсказанный вид использования"] + if c in non_geom + ] - rest = [c for c in non_geom if c not in pin_first] - rest_sorted = sorted(rest, key=lambda s: s.casefold()) + rest = [c for c in non_geom if c not in pin_first] + rest_sorted = sorted(rest, key=lambda s: s.casefold()) - gdf_out = gdf_out[pin_first + rest_sorted + [geom_col]] + gdf_out = gdf_out[pin_first + rest_sorted + [geom_col]] - geojson = json.loads(gdf_out.to_json()) - except Exception as e: - logger.exception("Failed to attach land-use predictions to gdf_out") - raise http_exception(500, "Failed to attach land-use predictions", e) + geojson = json.loads(gdf_out.to_json()) + except Exception as e: + logger.exception("Failed to attach land-use predictions to gdf_out") + raise http_exception(500, "Failed to attach land-use predictions", e) - self.cache.save( - "values_transformation", - params.scenario_id, - params_for_hash, - geojson, - scenario_updated_at=updated_at, - ) + self.cache.save( + "values_transformation", + params.scenario_id, + params_for_hash, + geojson, + scenario_updated_at=updated_at, + ) - logger.info("Values transformed complete (with land-use predictions)") - return geojson + logger.info("Values transformed complete (with land-use predictions)") + return geojson + except Exception: + EFFECTS_VALUES_TRANSFORMATION_ERROR_TOTAL.inc() + raise + finally: + EFFECTS_VALUES_TRANSFORMATION_DURATION_SECONDS.observe(time.perf_counter() - start_time) def _get_value_level(self, provisions: list[float | None]) -> float: vals = [p for p in provisions if p is not None] @@ -1011,193 +1032,201 @@ async def values_oriented_requirements( params: TerritoryTransformationDTO | DevelopmentDTO, persist: Literal["full", "table_only"] = "full", ): - method_name = "values_oriented_requirements" - - force: bool = bool(getattr(params, "force", False)) + EFFECTS_VALUES_ORIENTED_REQUIREMENTS_TOTAL.inc() + start_time = time.perf_counter() + try: + method_name = "values_oriented_requirements" - base_id = await self.effects_utils.resolve_base_id(token, params.scenario_id) - logger.info( - f"Using base scenario_id={base_id} (requested={params.scenario_id})" - ) + force: bool = bool(getattr(params, "force", False)) - params_base = params.model_copy( - update={ - "scenario_id": base_id, - "proj_func_zone_source": None, - "proj_func_source_year": None, - "context_func_zone_source": None, - "context_func_source_year": None, - } - ) - params_base = await self.get_optimal_func_zone_data(params_base, token) - - params_for_hash_base = await self.build_hash_params(params_base, token) - phash_base = self.cache.params_hash(params_for_hash_base) - info_base = await self.urban_api_client.get_scenario_info(base_id, token) - updated_at_base = info_base["updated_at"] - - def _result_to_df(payload: Any) -> pd.DataFrame: - if isinstance(payload, dict) and "data" not in payload: - items = sorted( - ((int(k), v.get("value", 0.0)) for k, v in payload.items()), - key=lambda t: t[0], - ) - idx = [k for k, _ in items] - vals = [float(v) if v is not None else 0.0 for _, v in items] - return pd.DataFrame({"social_value_level": vals}, index=idx) - df = pd.DataFrame( - data=payload["data"], index=payload["index"], columns=payload["columns"] + base_id = await self.effects_utils.resolve_base_id(token, params.scenario_id) + logger.info( + f"Using base scenario_id={base_id} (requested={params.scenario_id})" ) - df.index.name = payload.get("index_name", None) - return df - if not force: - cached_base = self.cache.load(method_name, base_id, phash_base) - if ( - cached_base - and cached_base["meta"].get("scenario_updated_at") == updated_at_base - and "result" in cached_base["data"] - ): - return _result_to_df(cached_base["data"]["result"]) - - context_blocks, _ = await self.context.aggregate_blocks_layer_context( - params.scenario_id, - params_base.context_func_zone_source, - params_base.context_func_source_year, - token, - ) - - scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( - params_base.scenario_id, - params_base.proj_func_zone_source, - params_base.proj_func_source_year, - token, - ) - scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) - - cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] - scenario_blocks.loc[ - scenario_blocks["is_project"], ["population"] + cap_cols - ] = 0 - if "capacity" in scenario_blocks.columns: - scenario_blocks = scenario_blocks.drop(columns="capacity") - - blocks = gpd.GeoDataFrame( - pd.concat([context_blocks, scenario_blocks], ignore_index=True), - crs=context_blocks.crs, - ) + params_base = params.model_copy( + update={ + "scenario_id": base_id, + "proj_func_zone_source": None, + "proj_func_source_year": None, + "context_func_zone_source": None, + "context_func_source_year": None, + } + ) + params_base = await self.get_optimal_func_zone_data(params_base, token) + + params_for_hash_base = await self.build_hash_params(params_base, token) + phash_base = self.cache.params_hash(params_for_hash_base) + info_base = await self.urban_api_client.get_scenario_info(base_id, token) + updated_at_base = info_base["updated_at"] + + def _result_to_df(payload: Any) -> pd.DataFrame: + if isinstance(payload, dict) and "data" not in payload: + items = sorted( + ((int(k), v.get("value", 0.0)) for k, v in payload.items()), + key=lambda t: t[0], + ) + idx = [k for k, _ in items] + vals = [float(v) if v is not None else 0.0 for _, v in items] + return pd.DataFrame({"social_value_level": vals}, index=idx) + df = pd.DataFrame( + data=payload["data"], index=payload["index"], columns=payload["columns"] + ) + df.index.name = payload.get("index_name", None) + return df - service_types = await self.urban_api_client.get_service_types() - service_types = await adapt_service_types(service_types, self.urban_api_client) - service_types = service_types[~service_types["social_values"].isna()].copy() + if not force: + cached_base = self.cache.load(method_name, base_id, phash_base) + if ( + cached_base + and cached_base["meta"].get("scenario_updated_at") == updated_at_base + and "result" in cached_base["data"] + ): + return _result_to_df(cached_base["data"]["result"]) - try: - acc_mx = get_accessibility_matrix(blocks) - except Exception as e: - logger.exception("Accessibility matrix calculation failed") - raise http_exception( - 500, "Accessibility matrix calculation failed", _detail=str(e) + context_blocks, _ = await self.context.aggregate_blocks_layer_context( + params.scenario_id, + params_base.context_func_zone_source, + params_base.context_func_source_year, + token, ) - prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) - prov_gdf = prov_gdf.to_crs(4326).drop( - columns="provision_weak", errors="ignore" + scenario_blocks, _ = await self.scenario.aggregate_blocks_layer_scenario( + params_base.scenario_id, + params_base.proj_func_zone_source, + params_base.proj_func_source_year, + token, + ) + scenario_blocks = scenario_blocks.to_crs(context_blocks.crs) + + cap_cols = [c for c in scenario_blocks.columns if c.startswith("capacity_")] + scenario_blocks.loc[ + scenario_blocks["is_project"], ["population"] + cap_cols + ] = 0 + if "capacity" in scenario_blocks.columns: + scenario_blocks = scenario_blocks.drop(columns="capacity") + + blocks = gpd.GeoDataFrame( + pd.concat([context_blocks, scenario_blocks], ignore_index=True), + crs=context_blocks.crs, ) - num_cols = prov_gdf.select_dtypes(include="number").columns - prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) - prov_gdfs[st_name] = prov_gdf - social_values_provisions: Dict[str, list[float | None]] = {} - for st_id in service_types.index: - st_name = service_types.loc[st_id, "name"] - social_values = service_types.loc[st_id, "social_values"] - prov_gdf = prov_gdfs.get(st_name) - if prov_gdf is None or prov_gdf.empty: - continue - prov_total = ( - None - if prov_gdf["demand"].sum() == 0 - else float(provision_strong_total(prov_gdf)) + service_types = await self.urban_api_client.get_service_types() + service_types = await adapt_service_types(service_types, self.urban_api_client) + service_types = service_types[~service_types["social_values"].isna()].copy() + + try: + acc_mx = get_accessibility_matrix(blocks) + except Exception as e: + logger.exception("Accessibility matrix calculation failed") + raise http_exception( + 500, "Accessibility matrix calculation failed", _detail=str(e) + ) + + prov_gdfs: Dict[str, gpd.GeoDataFrame] = {} + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + prov_gdf = await self._assess_provision(blocks, acc_mx, st_name) + prov_gdf = prov_gdf.to_crs(4326).drop( + columns="provision_weak", errors="ignore" + ) + num_cols = prov_gdf.select_dtypes(include="number").columns + prov_gdf[num_cols] = prov_gdf[num_cols].fillna(0) + prov_gdfs[st_name] = prov_gdf + + social_values_provisions: Dict[str, list[float | None]] = {} + for st_id in service_types.index: + st_name = service_types.loc[st_id, "name"] + social_values = service_types.loc[st_id, "social_values"] + prov_gdf = prov_gdfs.get(st_name) + if prov_gdf is None or prov_gdf.empty: + continue + prov_total = ( + None + if prov_gdf["demand"].sum() == 0 + else float(provision_strong_total(prov_gdf)) + ) + for sv in social_values: + social_values_provisions.setdefault(sv, []).append(prov_total) + + soc_values_map = await self.urban_api_client.get_social_values_info() + index = list(social_values_provisions.keys()) + result_df = pd.DataFrame( + data=[self._get_value_level(social_values_provisions[sv]) for sv in index], + index=index, + columns=["social_value_level"], ) - for sv in social_values: - social_values_provisions.setdefault(sv, []).append(prov_total) - - soc_values_map = await self.urban_api_client.get_social_values_info() - index = list(social_values_provisions.keys()) - result_df = pd.DataFrame( - data=[self._get_value_level(social_values_provisions[sv]) for sv in index], - index=index, - columns=["social_value_level"], - ) - values_table = { - int(sv_id): { - "name": soc_values_map.get(sv_id, str(sv_id)), - "value": round(float(val), 2) if val else 0.0, + values_table = { + int(sv_id): { + "name": soc_values_map.get(sv_id, str(sv_id)), + "value": round(float(val), 2) if val else 0.0, + } + for sv_id, val in result_df["social_value_level"].to_dict().items() } - for sv_id, val in result_df["social_value_level"].to_dict().items() - } - raw_services_df = await self.urban_api_client.get_service_types() - en2ru = await build_en_to_ru_map(raw_services_df) + raw_services_df = await self.urban_api_client.get_service_types() + en2ru = await build_en_to_ru_map(raw_services_df) - demand_left_col = "demand_left" - social_values_table: list[dict] = [] + demand_left_col = "demand_left" + social_values_table: list[dict] = [] - for st_id in service_types.index: - st_en = service_types.loc[st_id, "name"] - st_ru = en2ru.get(st_en, st_en) + for st_id in service_types.index: + st_en = service_types.loc[st_id, "name"] + st_ru = en2ru.get(st_en, st_en) - linked_ids = list( - map(int, (service_types.loc[st_id, "social_values"] or [])) - ) - linked_ru = [soc_values_map.get(sv_id, str(sv_id)) for sv_id in linked_ids] - - gdf = prov_gdfs.get(st_en) - total_unsatisfied = 0.0 - if gdf is not None and not gdf.empty: - if demand_left_col not in gdf.columns: - raise RuntimeError( - f"Колонка '{demand_left_col}' отсутствует для сервиса '{st_en}'" - ) - total_unsatisfied = float(gdf[demand_left_col].sum()) + linked_ids = list( + map(int, (service_types.loc[st_id, "social_values"] or [])) + ) + linked_ru = [soc_values_map.get(sv_id, str(sv_id)) for sv_id in linked_ids] + + gdf = prov_gdfs.get(st_en) + total_unsatisfied = 0.0 + if gdf is not None and not gdf.empty: + if demand_left_col not in gdf.columns: + raise RuntimeError( + f"Колонка '{demand_left_col}' отсутствует для сервиса '{st_en}'" + ) + total_unsatisfied = float(gdf[demand_left_col].sum()) + + social_values_table.append( + { + "service": st_ru, + "unsatisfied_demand_sum": round(total_unsatisfied, 2), + "social_values": linked_ru, + } + ) - social_values_table.append( - { - "service": st_ru, - "unsatisfied_demand_sum": round(total_unsatisfied, 2), - "social_values": linked_ru, + if persist == "full": + payload = { + "provision": { + name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) + for name, gdf in prov_gdfs.items() + }, + "result": values_table, + "social_values_table": social_values_table, + "services_type_deficit": social_values_table, + } + else: + payload = { + "result": values_table, + "social_values_table": social_values_table, + "services_type_deficit": social_values_table, } - ) - - if persist == "full": - payload = { - "provision": { - name: await gdf_to_ru_fc_rounded(gdf, ndigits=6) - for name, gdf in prov_gdfs.items() - }, - "result": values_table, - "social_values_table": social_values_table, - "services_type_deficit": social_values_table, - } - else: - payload = { - "result": values_table, - "social_values_table": social_values_table, - "services_type_deficit": social_values_table, - } - self.cache.save( - method_name, - base_id, - params_for_hash_base, - payload, - scenario_updated_at=updated_at_base, - ) + self.cache.save( + method_name, + base_id, + params_for_hash_base, + payload, + scenario_updated_at=updated_at_base, + ) - return result_df + return result_df + except Exception: + EFFECTS_VALUES_ORIENTED_REQUIREMENTS_ERROR_TOTAL.inc() + raise + finally: + EFFECTS_VALUES_ORIENTED_REQUIREMENTS_DURATION_SECONDS.observe(time.perf_counter() - start_time) def _clean_number(self, v): """ @@ -1878,80 +1907,87 @@ async def evaluate_social_economical_metrics(self, token: str, params: SocioEcon Project-level multi-scenario calculation with a shared context. Return: {territory_id: {indicator_name: {scenario_id: value}}} """ - project_id = params.project_id - parent_id = params.regional_scenario_id - method_name = "social_economical_metrics" + EFFECTS_SOCIO_ECONOMICAL_METRICS_TOTAL.inc() + start_time = time.perf_counter() + try: + project_id = params.project_id + parent_id = params.regional_scenario_id + method_name = "social_economical_metrics" - requested_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None + requested_ids = {int(x) for x in getattr(params, "territory_ids", [])} or None - params_for_hash = { - "project_id": project_id, - "regional_scenario_id": parent_id, - } + params_for_hash = { + "project_id": project_id, + "regional_scenario_id": parent_id, + } - if not params.force: - phash = self.cache.params_hash(params_for_hash) - cached = self.cache.load(method_name, project_id, phash) - if cached: - logger.info(f"[Effects] cache hit for project {project_id}, parent={parent_id}") - data = cached.get("data", cached) - results_all = self._sanitize_for_json(data["results"]) - return self._filter_by_territories(results_all, requested_ids) - else: - logger.info(f"[Effects] force=True, recalculating metrics for project {project_id}, parent={parent_id}") + if not params.force: + phash = self.cache.params_hash(params_for_hash) + cached = self.cache.load(method_name, project_id, phash) + if cached: + logger.info(f"[Effects] cache hit for project {project_id}, parent={parent_id}") + data = cached.get("data", cached) + results_all = self._sanitize_for_json(data["results"]) + return self._filter_by_territories(results_all, requested_ids) + else: + logger.info(f"[Effects] force=True, recalculating metrics for project {project_id}, parent={parent_id}") - context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, - token) + context_blocks, context_territories_gdf, service_types = await self.context.get_shared_context(project_id, + token) - scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) - target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id] - logger.info(f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})") + scenarios = await self.urban_api_client.get_project_scenarios(project_id, token) + target = [s for s in scenarios if (s.get("parent_scenario") or {}).get("id") == parent_id] + logger.info(f"[Effects] matched {len(target)} scenarios in project {project_id} (parent={parent_id})") - results: dict[int, list[dict]] = {} + results: dict[int, list[dict]] = {} - only_parent_ids = None + only_parent_ids = None - for s in target: - sid = int(s["scenario_id"]) - try: - proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( - token=token, - data_id=sid, - source=None, - year=None, - project=True, - ) + for s in target: + sid = int(s["scenario_id"]) + try: + proj_src, proj_year = await self.urban_api_client.get_optimal_func_zone_request_data( + token=token, + data_id=sid, + source=None, + year=None, + project=True, + ) - records = await self._compute_for_single_scenario( - sid, - context_blocks=context_blocks, - context_territories_gdf=context_territories_gdf, - service_types_df=service_types, - proj_src=proj_src, - proj_year=proj_year, - token=token, - only_parent_ids=only_parent_ids, - ) - results[sid] = records - except Exception as exc: - logger.error(f"[Effects] Scenario {sid} failed during socio-economic computation: {exc}") - logger.exception(exc) - results[sid] = [] + records = await self._compute_for_single_scenario( + sid, + context_blocks=context_blocks, + context_territories_gdf=context_territories_gdf, + service_types_df=service_types, + proj_src=proj_src, + proj_year=proj_year, + token=token, + only_parent_ids=only_parent_ids, + ) + results[sid] = records + except Exception: + logger.error(f"[Effects] Scenario {sid} failed during socio-economic computation") + results[sid] = [] - results_all = await self._pivot_results_by_territory(results) - results_all = self._sanitize_for_json(results_all) + results_all = await self._pivot_results_by_territory(results) + results_all = self._sanitize_for_json(results_all) - project_info = await self.urban_api_client.get_project(project_id, token) - updated_at = project_info.get("updated_at") + project_info = await self.urban_api_client.get_project(project_id, token) + updated_at = project_info.get("updated_at") - self.cache.save( - method_name, - project_id, - params_for_hash, - {"results": results_all}, - scenario_updated_at=updated_at, - ) + self.cache.save( + method_name, + project_id, + params_for_hash, + {"results": results_all}, + scenario_updated_at=updated_at, + ) - logger.success(f"[Effects] socio-economic metrics cached for project_id={project_id}, parent={parent_id}") - return self._filter_by_territories(results_all, requested_ids) + logger.success(f"[Effects] socio-economic metrics cached for project_id={project_id}, parent={parent_id}") + return self._filter_by_territories(results_all, requested_ids) + except Exception: + EFFECTS_SOCIO_ECONOMICAL_METRICS_ERROR_TOTAL.inc() + raise + finally: + EFFECTS_SOCIO_ECONOMICAL_METRICS_DURATION_SECONDS.observe(time.perf_counter() - start_time) diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 00ece9e..736e8a7 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -9,7 +9,21 @@ from loguru import logger from app.common.exceptions.http_exception_wrapper import http_exception -from app.dependencies import effects_service, file_cache, effects_utils, consumer, producer +from app.dependencies import effects_service, file_cache, effects_utils, consumer, producer, config +from app.prometheus.server import start_metrics_server, stop_metrics_server +import time + +from app.prometheus.metrics import ( + EFFECTS_TASKS_CREATED_TOTAL, + EFFECTS_TASKS_CACHE_HIT_TOTAL, + EFFECTS_TASKS_ENQUEUED_TOTAL, + EFFECTS_TASKS_STARTED_TOTAL, + EFFECTS_TASKS_DONE_TOTAL, + EFFECTS_TASKS_FAILED_TOTAL, + EFFECTS_TASK_DURATION_SECONDS, + EFFECTS_TASKS_QUEUE_SIZE, + EFFECTS_TASKS_RUNNING, +) MethodFunc = Callable[[str, Any], Coroutine[Any, Any, Any]] @@ -69,17 +83,20 @@ async def run(self) -> None: """ Run task asynchronously inside event loop. """ + start_time = time.perf_counter() + EFFECTS_TASKS_STARTED_TOTAL.labels(method=self.method).inc() + EFFECTS_TASKS_RUNNING.inc() + try: logger.info(f"[{self.task_id}] started") self.status = "running" force = getattr(self.params, "force", False) - cached = None if force else self.cache.load( - self.method, self.scenario_id, self.param_hash - ) + cached = None if force else self.cache.load(self.method, self.scenario_id, self.param_hash) if not force and _cache_complete(self.method, cached): logger.info(f"[{self.task_id}] loaded from cache") + EFFECTS_TASKS_CACHE_HIT_TOTAL.labels(method=self.method).inc() self.result = cached["data"] self.status = "done" return @@ -89,11 +106,20 @@ async def run(self) -> None: self.result = self._serialize_result(raw_data) self.status = "done" + EFFECTS_TASKS_DONE_TOTAL.labels(method=self.method).inc() except Exception as exc: logger.exception(exc) self.status = "failed" self.error = str(exc) + EFFECTS_TASKS_FAILED_TOTAL.labels(method=self.method).inc() + + finally: + EFFECTS_TASK_DURATION_SECONDS.labels(method=self.method).observe( + time.perf_counter() - start_time + ) + EFFECTS_TASKS_RUNNING.dec() + EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize()) def _serialize_result(self, raw_data): """Serialize GeoDataFrame or dict to json-compatible structure.""" @@ -122,6 +148,7 @@ async def create_task( Returns: dict: { "task_id": str, "status": "queued" | "running" | "done" } """ + EFFECTS_TASKS_CREATED_TOTAL.labels(method=method).inc() project_based_methods = {"social_economical_metrics", "urbanomy_metrics"} @@ -160,6 +187,8 @@ async def create_task( task = AnyTask(method, owner_id, token, params, phash, file_cache, task_id) _task_map[task_id] = task + EFFECTS_TASKS_ENQUEUED_TOTAL.labels(method=method).inc() + EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize() + 1) await _task_queue.put(task) return {"task_id": task_id, "status": "queued"} @@ -193,6 +222,8 @@ async def create_task( if task.task_id in _task_map: return {"task_id": task.task_id, "status": "running"} _task_map[task.task_id] = task + EFFECTS_TASKS_ENQUEUED_TOTAL.labels(method=method).inc() + EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize() + 1) await _task_queue.put(task) return {"task_id": task.task_id, "status": "queued"} @@ -210,6 +241,8 @@ async def create_task( if task.task_id in _task_map: return {"task_id": task.task_id, "status": "running"} _task_map[task.task_id] = task + EFFECTS_TASKS_ENQUEUED_TOTAL.labels(method=method).inc() + EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize() + 1) await _task_queue.put(task) return {"task_id": task.task_id, "status": "queued"} @@ -250,6 +283,8 @@ async def stop(self): @asynccontextmanager async def lifespan(app: FastAPI): + start_metrics_server(int(config.get("PROMETHEUS_PORT"))) + worker.start() await producer.start() await consumer.start(["scenario.events"]) @@ -259,3 +294,4 @@ async def lifespan(app: FastAPI): await consumer.stop() await producer.stop() await worker.stop() + stop_metrics_server() diff --git a/app/main.py b/app/main.py index 5afa8d0..e2374cb 100644 --- a/app/main.py +++ b/app/main.py @@ -6,7 +6,6 @@ from app.common.exceptions.exception_handler import ExceptionHandlerMiddleware from app.effects_api.modules.task_service import lifespan from app.effects_api.tasks_controller import router as tasks_router -from app.prometheus.server import start_metrics_server from app.system_router.system_controller import system_router # TODO add app version @@ -34,6 +33,5 @@ async def read_root(): return RedirectResponse("/docs") -start_metrics_server(port=8001) app.include_router(tasks_router) app.include_router(system_router) diff --git a/app/prometheus/metrics.py b/app/prometheus/metrics.py index 1ed891c..01baba0 100644 --- a/app/prometheus/metrics.py +++ b/app/prometheus/metrics.py @@ -1,6 +1,4 @@ -"""Prometheus metrics for Effects API.""" - -from prometheus_client import Counter, Histogram +from prometheus_client import Counter, Histogram, Gauge CACHE_INVALIDATION_EVENTS_TOTAL = Counter( @@ -37,5 +35,108 @@ EFFECTS_TERRITORY_TRANSFORMATION_DURATION_SECONDS = Histogram( "effects_territory_transformation_duration_seconds", "Duration of territory_transformation execution", - buckets=(1, 2, 5, 10, 30, 60, 120, 300), + buckets=(1, 2, 5, 10, 30, 60, 120, 300, 600, 900, 1200), +) + +EFFECTS_TASKS_CREATED_TOTAL = Counter( + "effects_tasks_created_total", + "Total number of tasks created", + labelnames=("method",), +) + +EFFECTS_TASKS_CACHE_HIT_TOTAL = Counter( + "effects_tasks_cache_hit_total", + "Total number of tasks served from cache (no execution needed)", + labelnames=("method",), +) + +EFFECTS_TASKS_ENQUEUED_TOTAL = Counter( + "effects_tasks_enqueued_total", + "Total number of tasks enqueued for execution", + labelnames=("method",), +) + +EFFECTS_TASKS_STARTED_TOTAL = Counter( + "effects_tasks_started_total", + "Total number of tasks started execution", + labelnames=("method",), +) + +EFFECTS_TASKS_DONE_TOTAL = Counter( + "effects_tasks_done_total", + "Total number of tasks finished successfully", + labelnames=("method",), +) + +EFFECTS_TASKS_FAILED_TOTAL = Counter( + "effects_tasks_failed_total", + "Total number of tasks failed during execution", + labelnames=("method",), +) + +EFFECTS_TASK_DURATION_SECONDS = Histogram( + "effects_task_duration_seconds", + "Task execution duration in seconds", + labelnames=("method",), + buckets=(0.1, 0.2, 0.5, 1, 2, 5, 10, 30, 60, 120, 300, 600), +) + +EFFECTS_TASKS_QUEUE_SIZE = Gauge( + "effects_tasks_queue_size", + "Current number of tasks waiting in queue", +) + +EFFECTS_TASKS_RUNNING = Gauge( + "effects_tasks_running", + "Current number of tasks running", +) + +# --- Service entrypoints metrics --- + +EFFECTS_VALUES_TRANSFORMATION_TOTAL = Counter( + "effects_values_transformation_total", + "Total number of values_transformation calls", +) + +EFFECTS_VALUES_TRANSFORMATION_ERROR_TOTAL = Counter( + "effects_values_transformation_error_total", + "Total number of failed values_transformation calls", +) + +EFFECTS_VALUES_TRANSFORMATION_DURATION_SECONDS = Histogram( + "effects_values_transformation_duration_seconds", + "Duration of values_transformation execution", + buckets=(1, 2, 5, 10, 30, 60, 120, 300, 600), +) + +EFFECTS_VALUES_ORIENTED_REQUIREMENTS_TOTAL = Counter( + "effects_values_oriented_requirements_total", + "Total number of values_oriented_requirements calls", +) + +EFFECTS_VALUES_ORIENTED_REQUIREMENTS_ERROR_TOTAL = Counter( + "effects_values_oriented_requirements_error_total", + "Total number of failed values_oriented_requirements calls", +) + +EFFECTS_VALUES_ORIENTED_REQUIREMENTS_DURATION_SECONDS = Histogram( + "effects_values_oriented_requirements_duration_seconds", + "Duration of values_oriented_requirements execution", + buckets=(1, 2, 5, 10, 30, 60, 120, 300, 600), +) + +EFFECTS_SOCIO_ECONOMICAL_METRICS_TOTAL = Counter( + "effects_social_economical_metrics_total", + "Total number of evaluate_social_economical_metrics calls", +) + +EFFECTS_SOCIO_ECONOMICAL_METRICS_ERROR_TOTAL = Counter( + "effects_social_economical_metrics_error_total", + "Total number of failed evaluate_social_economical_metrics calls", +) + +EFFECTS_SOCIO_ECONOMICAL_METRICS_DURATION_SECONDS = Histogram( + "effects_social_economical_metrics_duration_seconds", + "Duration of evaluate_social_economical_metrics execution", + buckets=(1, 2, 5, 10, 30, 60, 120, 300, 600), ) diff --git a/app/prometheus/server.py b/app/prometheus/server.py index 97e62b7..9db4761 100644 --- a/app/prometheus/server.py +++ b/app/prometheus/server.py @@ -1,4 +1,3 @@ -# app/metrics_server.py from prometheus_client import start_http_server _server = None @@ -8,3 +7,11 @@ def start_metrics_server(port: int = 8000) -> None: """Start Prometheus metrics HTTP server.""" global _server _server = start_http_server(port) + + +def stop_metrics_server() -> None: + """Stop Prometheus metrics HTTP server.""" + global _server + if _server is not None: + _server.shutdown() + _server = None From 7c99998785b7f67ceded1943e950cfa339ec9eae Mon Sep 17 00:00:00 2001 From: Voronapxl Date: Thu, 15 Jan 2026 16:39:47 +0300 Subject: [PATCH 160/161] feat(review_changes): 1. Encapsulation of task metrics 2. Fix for queue size metric --- app/broker_handlers/cache_invalidation.py | 32 ++++++------ app/effects_api/modules/context_service.py | 48 +++++++++-------- app/effects_api/modules/task_service.py | 46 ++++++----------- app/effects_api/tasks_controller.py | 5 ++ app/prometheus/metrics.py | 32 ++++++++---- app/prometheus/task_metrics.py | 60 ++++++++++++++++++++++ 6 files changed, 146 insertions(+), 77 deletions(-) create mode 100644 app/prometheus/task_metrics.py diff --git a/app/broker_handlers/cache_invalidation.py b/app/broker_handlers/cache_invalidation.py index da47911..0bc0164 100644 --- a/app/broker_handlers/cache_invalidation.py +++ b/app/broker_handlers/cache_invalidation.py @@ -70,20 +70,20 @@ async def _handle_cache_invalidation(self, event: Any, ctx: Message | None = Non CACHE_INVALIDATION_EVENTS_TOTAL.inc() start_time = time.perf_counter() - try: - total_deleted = await asyncio.to_thread( - self._invalidation_service.invalidate, - event, - self._rules, - ) - CACHE_INVALIDATION_SUCCESS_TOTAL.inc() - return None - - except Exception: - CACHE_INVALIDATION_ERROR_TOTAL.inc() - raise - - finally: - duration = time.perf_counter() - start_time - CACHE_INVALIDATION_DURATION_SECONDS.observe(duration) + logger.info(f"Received event: type={type(event)}") + logger.info( + f"Invalidate cache for project_id={getattr(event, 'project_id', None)} " + f"scenario_id={getattr(event, 'scenario_id', None)}" + ) + total_deleted = await asyncio.to_thread( + self._invalidation_service.invalidate, + event, + self._rules, + ) + CACHE_INVALIDATION_SUCCESS_TOTAL.inc() + + logger.info(f"Cache invalidation completed: deleted_files={total_deleted}") + duration = time.perf_counter() - start_time + CACHE_INVALIDATION_DURATION_SECONDS.observe(duration) + return None diff --git a/app/effects_api/modules/context_service.py b/app/effects_api/modules/context_service.py index b9f1db8..10d2922 100644 --- a/app/effects_api/modules/context_service.py +++ b/app/effects_api/modules/context_service.py @@ -328,13 +328,16 @@ async def get_accessibility_context( return list(context_blocks.index) async def get_shared_context( - self, - project_id: int, - token: str, + self, + project_id: int, + token: str, ) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame, pd.DataFrame]: """ - Get cached context (blocks, territories, service_types) by project_id, - or build and cache if missing. JSON cache stores only paths to artifacts. + Get cached context (blocks, territories, service_types: artifacts) by project_id, + or build and cache if missing/corrupted. + + JSON cache stores only paths to artifacts. If any artifact file is missing, + the cache is treated as stale and context is recomputed. """ method = "shared_context" params = {"project_id": int(project_id)} @@ -344,20 +347,25 @@ async def get_shared_context( if cached: logger.info(f"Shared context cache hit for project_id={project_id}") data = cached["data"] - ctx_blocks = self.cache.load_gdf_artifact(Path(data["context_blocks_path"])) - ctx_territories = self.cache.load_gdf_artifact( - Path(data["context_territories_path"]) - ) - service_types = self.cache.load_df_artifact( - Path(data["service_types_path"]) - ) - return ctx_blocks, ctx_territories, service_types + + try: + ctx_blocks = self.cache.load_gdf_artifact(Path(data["context_blocks_path"])) + ctx_territories = self.cache.load_gdf_artifact(Path(data["context_territories_path"])) + service_types = self.cache.load_df_artifact(Path(data["service_types_path"])) + return ctx_blocks, ctx_territories, service_types + + except (FileNotFoundError, OSError, KeyError) as exc: + # KeyError — если в JSON вдруг нет нужного ключа + logger.warning( + f"Shared context cache is corrupted/stale for project_id={project_id}. " + f"Rebuilding. Reason: {exc}" + ) + # optional: если у тебя есть метод точечной инвалидции: + # self.cache.invalidate(method, project_id, phash) logger.info(f"Shared context cache miss for project_id={project_id} — is building") - territory_id = (await self.client.get_all_project_info(project_id, token))[ - "territory" - ]["id"] + territory_id = (await self.client.get_all_project_info(project_id, token))["territory"]["id"] base_sid = await self.client.get_base_scenario_id(project_id, token) ctx_src, ctx_year = await self.client.get_optimal_func_zone_request_data( token=token, data_id=base_sid, source=None, year=None, project=False @@ -374,16 +382,12 @@ async def get_shared_context( service_types = await self.client.get_service_types() service_types = await adapt_service_types(service_types, self.client) - service_types = service_types[ - service_types["infrastructure_type"].notna() - ].copy() + service_types = service_types[service_types["infrastructure_type"].notna()].copy() service_types = adapt_social_service_types_df( service_types, SOCIAL_INDICATORS_MAPPING ).join(normatives) - ctx_blocks, _ = await self.aggregate_blocks_layer_context( - base_sid, ctx_src, ctx_year, token - ) + ctx_blocks, _ = await self.aggregate_blocks_layer_context(base_sid, ctx_src, ctx_year, token) ctx_territories = await self.get_context_territories(project_id, token) ctx_blocks_path = self.cache.save_gdf_artifact( diff --git a/app/effects_api/modules/task_service.py b/app/effects_api/modules/task_service.py index 736e8a7..f6d7061 100644 --- a/app/effects_api/modules/task_service.py +++ b/app/effects_api/modules/task_service.py @@ -14,18 +14,10 @@ import time from app.prometheus.metrics import ( - EFFECTS_TASKS_CREATED_TOTAL, - EFFECTS_TASKS_CACHE_HIT_TOTAL, - EFFECTS_TASKS_ENQUEUED_TOTAL, - EFFECTS_TASKS_STARTED_TOTAL, - EFFECTS_TASKS_DONE_TOTAL, - EFFECTS_TASKS_FAILED_TOTAL, - EFFECTS_TASK_DURATION_SECONDS, - EFFECTS_TASKS_QUEUE_SIZE, - EFFECTS_TASKS_RUNNING, -) + bind_queue_metrics, get_task_metrics) MethodFunc = Callable[[str, Any], Coroutine[Any, Any, Any]] +TASK_METRICS = get_task_metrics() TASK_METHODS: dict[str, MethodFunc] = { "territory_transformation": effects_service.territory_transformation, @@ -84,8 +76,7 @@ async def run(self) -> None: Run task asynchronously inside event loop. """ start_time = time.perf_counter() - EFFECTS_TASKS_STARTED_TOTAL.labels(method=self.method).inc() - EFFECTS_TASKS_RUNNING.inc() + TASK_METRICS.on_started(self.method) try: logger.info(f"[{self.task_id}] started") @@ -96,7 +87,7 @@ async def run(self) -> None: if not force and _cache_complete(self.method, cached): logger.info(f"[{self.task_id}] loaded from cache") - EFFECTS_TASKS_CACHE_HIT_TOTAL.labels(method=self.method).inc() + TASK_METRICS.on_cache_hit(self.method) self.result = cached["data"] self.status = "done" return @@ -106,20 +97,17 @@ async def run(self) -> None: self.result = self._serialize_result(raw_data) self.status = "done" - EFFECTS_TASKS_DONE_TOTAL.labels(method=self.method).inc() + TASK_METRICS.on_finished_success(self.method) + except Exception as exc: - logger.exception(exc) + logger.exception(f"[{self.task_id}] failed") self.status = "failed" self.error = str(exc) - EFFECTS_TASKS_FAILED_TOTAL.labels(method=self.method).inc() + TASK_METRICS.on_finished_failed(self.method) finally: - EFFECTS_TASK_DURATION_SECONDS.labels(method=self.method).observe( - time.perf_counter() - start_time - ) - EFFECTS_TASKS_RUNNING.dec() - EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize()) + TASK_METRICS.observe_duration(self.method, time.perf_counter() - start_time) def _serialize_result(self, raw_data): """Serialize GeoDataFrame or dict to json-compatible structure.""" @@ -148,7 +136,7 @@ async def create_task( Returns: dict: { "task_id": str, "status": "queued" | "running" | "done" } """ - EFFECTS_TASKS_CREATED_TOTAL.labels(method=method).inc() + TASK_METRICS.on_created(method) project_based_methods = {"social_economical_metrics", "urbanomy_metrics"} @@ -179,6 +167,7 @@ async def create_task( _input={"method": method, "owner_id": owner_id}, _detail=str(e)) if not force and _cache_complete(method, cached): + TASK_METRICS.on_cache_hit(method) return {"task_id": task_id, "status": "done"} existing = None if force else _task_map.get(task_id) @@ -187,8 +176,7 @@ async def create_task( task = AnyTask(method, owner_id, token, params, phash, file_cache, task_id) _task_map[task_id] = task - EFFECTS_TASKS_ENQUEUED_TOTAL.labels(method=method).inc() - EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize() + 1) + TASK_METRICS.on_enqueued(method) await _task_queue.put(task) return {"task_id": task_id, "status": "queued"} @@ -216,14 +204,14 @@ async def create_task( cached = file_cache.load(method, owner_id, phash) if cached and "data" in cached and "result" in cached["data"]: logger.info("[Tasks] Cache hit for values_oriented_requirements -> DONE") + TASK_METRICS.on_cache_hit(method) return {"task_id": task_id, "status": "done"} task = AnyTask(method, owner_id, token, norm_params, phash, file_cache, task_id) if task.task_id in _task_map: return {"task_id": task.task_id, "status": "running"} _task_map[task.task_id] = task - EFFECTS_TASKS_ENQUEUED_TOTAL.labels(method=method).inc() - EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize() + 1) + TASK_METRICS.on_enqueued(method) await _task_queue.put(task) return {"task_id": task.task_id, "status": "queued"} @@ -235,14 +223,14 @@ async def create_task( cached = file_cache.load(method, owner_id, phash) if cached and "data" in cached: + TASK_METRICS.on_cache_hit(method) return {"task_id": task_id, "status": "done"} task = AnyTask(method, owner_id, token, norm_params, phash, file_cache, task_id) if task.task_id in _task_map: return {"task_id": task.task_id, "status": "running"} _task_map[task.task_id] = task - EFFECTS_TASKS_ENQUEUED_TOTAL.labels(method=method).inc() - EFFECTS_TASKS_QUEUE_SIZE.set(_task_queue.qsize() + 1) + TASK_METRICS.on_enqueued(method) await _task_queue.put(task) return {"task_id": task.task_id, "status": "queued"} @@ -284,7 +272,7 @@ async def stop(self): @asynccontextmanager async def lifespan(app: FastAPI): start_metrics_server(int(config.get("PROMETHEUS_PORT"))) - + bind_queue_metrics(_task_queue) worker.start() await producer.start() await consumer.start(["scenario.events"]) diff --git a/app/effects_api/tasks_controller.py b/app/effects_api/tasks_controller.py index 10ca5c7..30f2e9e 100644 --- a/app/effects_api/tasks_controller.py +++ b/app/effects_api/tasks_controller.py @@ -27,6 +27,9 @@ from ..dependencies import effects_service, effects_utils, file_cache, urban_api_client from .dto.development_dto import ContextDevelopmentDTO from .modules.service_type_service import get_services_with_ids_from_layer +from ..prometheus.metrics import get_task_metrics + +TASK_METRICS = get_task_metrics() router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -127,6 +130,7 @@ async def create_scenario_task( None if force else file_cache.load(method, params_filled.scenario_id, phash) ) if not force and _cache_complete(method, cached): + TASK_METRICS.on_cache_hit(method) return {"task_id": task_id, "status": "done"} existing = None if force else _task_map.get(task_id) @@ -144,6 +148,7 @@ async def create_scenario_task( ) _task_map[task_id] = task await _task_queue.put(task) + TASK_METRICS.on_enqueued(method) return {"task_id": task_id, "status": "queued"} diff --git a/app/prometheus/metrics.py b/app/prometheus/metrics.py index 01baba0..75b2085 100644 --- a/app/prometheus/metrics.py +++ b/app/prometheus/metrics.py @@ -1,5 +1,6 @@ from prometheus_client import Counter, Histogram, Gauge +from app.prometheus.task_metrics import TaskMetrics CACHE_INVALIDATION_EVENTS_TOTAL = Counter( "effects_cache_invalidation_events_total", @@ -62,16 +63,10 @@ labelnames=("method",), ) -EFFECTS_TASKS_DONE_TOTAL = Counter( - "effects_tasks_done_total", - "Total number of tasks finished successfully", - labelnames=("method",), -) - -EFFECTS_TASKS_FAILED_TOTAL = Counter( - "effects_tasks_failed_total", - "Total number of tasks failed during execution", - labelnames=("method",), +EFFECTS_TASKS_FINISHED_TOTAL = Counter( + "effects_tasks_finished_total", + "Total number of tasks finished execution", + labelnames=("method", "status"), ) EFFECTS_TASK_DURATION_SECONDS = Histogram( @@ -86,6 +81,10 @@ "Current number of tasks waiting in queue", ) +def bind_queue_metrics(queue) -> None: + """Bind runtime queue instance to observable metrics.""" + EFFECTS_TASKS_QUEUE_SIZE.set_function(queue.qsize) + EFFECTS_TASKS_RUNNING = Gauge( "effects_tasks_running", "Current number of tasks running", @@ -140,3 +139,16 @@ "Duration of evaluate_social_economical_metrics execution", buckets=(1, 2, 5, 10, 30, 60, 120, 300, 600), ) + +def get_task_metrics() -> TaskMetrics: + """Create TaskMetrics facade.""" + return TaskMetrics( + created_total=EFFECTS_TASKS_CREATED_TOTAL, + cache_hit_total=EFFECTS_TASKS_CACHE_HIT_TOTAL, + enqueued_total=EFFECTS_TASKS_ENQUEUED_TOTAL, + started_total=EFFECTS_TASKS_STARTED_TOTAL, + finished_total=EFFECTS_TASKS_FINISHED_TOTAL, + duration_seconds=EFFECTS_TASK_DURATION_SECONDS, + running=EFFECTS_TASKS_RUNNING, + queue_size=EFFECTS_TASKS_QUEUE_SIZE, + ) diff --git a/app/prometheus/task_metrics.py b/app/prometheus/task_metrics.py new file mode 100644 index 0000000..e5d45f5 --- /dev/null +++ b/app/prometheus/task_metrics.py @@ -0,0 +1,60 @@ +"""Task metrics reporting utilities.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + +from prometheus_client import Counter, Gauge, Histogram + + +@dataclass(frozen=True) +class TaskMetrics: + """ + Aggregates Prometheus metrics related to tasks and provides + convenient reporting methods. + + This class hides Prometheus primitives from business logic. + """ + + created_total: Counter + cache_hit_total: Counter + enqueued_total: Counter + started_total: Counter + finished_total: Counter + duration_seconds: Histogram + running: Gauge + queue_size: Gauge + + def bind_queue_size(self, qsize_getter: Callable[[], int]) -> None: + """ + Bind queue size metric to a callable that returns current size. + + Args: + qsize_getter: Callable returning current queue size. + """ + self.queue_size.set_function(qsize_getter) + + def on_created(self, method: str) -> None: + self.created_total.labels(method=method).inc() + + def on_cache_hit(self, method: str) -> None: + self.cache_hit_total.labels(method=method).inc() + + def on_enqueued(self, method: str) -> None: + self.enqueued_total.labels(method=method).inc() + + def on_started(self, method: str) -> None: + self.started_total.labels(method=method).inc() + self.running.inc() + + def on_finished_success(self, method: str) -> None: + self.finished_total.labels(method=method, status="success").inc() + self.running.dec() + + def on_finished_failed(self, method: str) -> None: + self.finished_total.labels(method=method, status="failed").inc() + self.running.dec() + + def observe_duration(self, method: str, seconds: float) -> None: + self.duration_seconds.labels(method=method).observe(seconds) From 258513af6c33503106dbc14415d9ecad05081f14 Mon Sep 17 00:00:00 2001 From: Dmitry-Grachev Date: Thu, 15 Jan 2026 18:54:19 +0300 Subject: [PATCH 161/161] runner changed to 102_runner, docker-compose.actions.yml updated --- .github/workflows/build_and_deploy.yml | 8 ++++---- docker-compose.actions.yml | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build_and_deploy.yml b/.github/workflows/build_and_deploy.yml index ac55edf..508ad55 100644 --- a/.github/workflows/build_and_deploy.yml +++ b/.github/workflows/build_and_deploy.yml @@ -6,7 +6,7 @@ env: jobs: build: - runs-on: 47_runner + runs-on: 102_runner outputs: now: ${{steps.date.outputs.NOW}} steps: @@ -18,7 +18,7 @@ jobs: - name: copy_env env: ENV_PATH: ${{secrets.ENV_PATH}} - run: cp "$ENV_PATH"/.env.development ./ + run: cp "$ENV_PATH"/.env.production ./ - name: build env: NOW: ${{steps.date.outputs.now}} @@ -28,13 +28,13 @@ jobs: NOW: ${{steps.date.outputs.now}} run: docker push "$IMAGE_NAME":"$NOW" stop_container: - runs-on: 47_runner + runs-on: 102_runner needs: build steps: - name: stop_container run: docker rm -f "$CONTAINER_NAME" run_container: - runs-on: 47_runner + runs-on: 102_runner needs: [build, stop_container] env: NOW: ${{needs.build.outputs.now}} diff --git a/docker-compose.actions.yml b/docker-compose.actions.yml index d9e5dcb..67e35da 100644 --- a/docker-compose.actions.yml +++ b/docker-compose.actions.yml @@ -4,6 +4,8 @@ services: container_name: ${CONTAINER_NAME} ports: - 5100:80 + volumes: + - /var/essdata/effects/__effects_cache__:/app/__effects_cache__ env_file: - .env.development restart: always

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%0AhQ9bdwF7CW0 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

~k~W#P*RV2WEfQaO)p& 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