Source code for objectnat.methods.noise.noise_simulation_simplified

# simplified version
import geopandas as gpd
import pandas as pd
from shapely.ops import polygonize, unary_union
from tqdm.auto import tqdm

from objectnat.methods.noise.noise_reduce import dist_to_target_db
from objectnat.methods.utils.geom_utils import (
    distribute_points_on_linestrings,
    distribute_points_on_polygons,
    polygons_to_multilinestring,
)
from objectnat.methods.visibility.visibility_analysis import get_visibility_accurate

MAX_DB_VALUE = 194


[docs] def calculate_simplified_noise_frame( noise_sources: gpd.GeoDataFrame, obstacles: gpd.GeoDataFrame, air_temperature, **kwargs ) -> gpd.GeoDataFrame: """ Calculates a simplified environmental noise frame using static noise source geometries without simulating full sound wave propagation or reflections. This function provides a fast approximation of noise dispersion from a variety of source geometries, including points (e.g., traffic noise measurement points), lines (e.g., roads or railways), and polygons (e.g., industrial zones or buildings). Instead of simulating detailed wave interactions and reflections, it constructs an envelope of potential noise exposure by buffering the source geometry and applying simplified decay formulas based on sound power, frequency and temperature. Args: noise_sources (gpd.GeoDataFrame): A GeoDataFrame containing geometries of noise sources (Point, LineString, or Polygon). Each feature must have the following two columns: - 'source_noise_db': Initial sound level at the source, in decibels (dB). - 'geometric_mean_freq_hz': Characteristic sound frequency (Hz) used to model distance-based attenuation. Values in 'source_noise_db' must not exceed the physical maximum of 194 dB. Missing or NaN values in required fields will raise an error. obstacles (gpd.GeoDataFrame): A GeoDataFrame representing physical obstructions in the environment (e.g., buildings, walls, terrain). These are used to build visibility masks that affect where sound can propagate. Geometry will be simplified for performance using a default tolerance of 1 unit. air_temperature (float): The ambient air temperature in degrees Celsius. This value influences the attenuation model of sound in the atmosphere. Temperatures significantly outside the typical 0–30°C range may lead to inaccurate results. Keyword Args: target_noise_db (float, optional): The minimum sound level threshold (in dB) to be modeled. Any value below this threshold is considered insignificant and will be excluded from the resulting noise frame. Default is 40 dB. db_sim_step (float, optional): The simulation step size (in dB) used to discretize sound levels into spatial layers. Default is 5. Smaller values produce more detailed output but increase computation time. linestring_point_radius (float, optional): The spacing radius (in meters) used when converting LineString geometries into distributed point sources for simulation. Default is 30. Reducing this value improves detail along long lines. polygon_point_radius (float, optional): The point spacing (in meters) for distributing sources within Polygon geometries. Default is 15. Points are sampled across the polygon’s surface and perimeter to represent the full sound-emitting area. Returns: gpd.GeoDataFrame: A GeoDataFrame representing simplified noise distribution areas. The output geometries are polygons where each polygon is associated with the maximum sound level (in dB) present in that area, as derived from overlapping source zones. The resulting data is dissolved by noise level and returned in the original coordinate reference system (CRS) of the input sources. Notes: - The function does not model reflections or complex diffraction effects. It uses straight-line visibility (line-of-sight) and a layered distance-decay approach for rapid estimation. - Obstacles are used for visibility masking only, not as reflectors or absorbers. - Output resolution and accuracy depend heavily on the geometry type and point distribution settings. - Results are useful for quick noise mapping or for generating initial noise envelopes prior to more detailed simulations. """ target_noise_db = kwargs.get("target_noise_db", 40) db_sim_step = kwargs.get("db_sim_step", 5) linestring_point_radius = kwargs.get("linestring_point_radius", 30) polygon_point_radius = kwargs.get("polygon_point_radius", 15) required_columns = ["source_noise_db", "geometric_mean_freq_hz"] for col in required_columns: if col not in noise_sources.columns: raise ValueError(f"'{col}' column is missing in provided GeoDataFrame") if noise_sources[col].isnull().any(): raise ValueError(f"Column '{col}' contains missing (NaN) values") if (noise_sources["source_noise_db"] > MAX_DB_VALUE).any(): raise ValueError( f"One or more values in 'source_noise_db' column exceed the physical limit of {MAX_DB_VALUE} dB." ) original_crs = noise_sources.crs if len(obstacles) > 0: obstacles = obstacles.copy() obstacles.geometry = obstacles.geometry.simplify(tolerance=1) local_crs = obstacles.estimate_utm_crs() obstacles.to_crs(local_crs, inplace=True) noise_sources.to_crs(local_crs, inplace=True) else: local_crs = noise_sources.estimate_utm_crs() noise_sources.to_crs(local_crs, inplace=True) noise_sources.reset_index(drop=True) noise_sources = noise_sources.explode(ignore_index=True) noise_sources["geom_type"] = noise_sources.geom_type grouped_sources = noise_sources.groupby(by=["source_noise_db", "geometric_mean_freq_hz", "geom_type"]) frame_result = [] total_tasks = 0 with tqdm(total=total_tasks, desc="Simulating noise") as pbar: for (source_db, freq_hz, geom_type), group_gdf in grouped_sources: # calculating layer dist and db values dist_db = [(0, source_db)] cur_db = source_db - db_sim_step max_dist = 0 while cur_db > target_noise_db - db_sim_step: if cur_db - db_sim_step < target_noise_db: cur_db = target_noise_db max_dist = dist_to_target_db(source_db, cur_db, freq_hz, air_temperature) dist_db.append((max_dist, cur_db)) cur_db -= db_sim_step # increasing max_dist for extra view max_dist = max_dist * 1.2 if geom_type == "Point": total_tasks += len(group_gdf) pbar.total = total_tasks pbar.refresh() for _, row in group_gdf.iterrows(): point_from = row.geometry point_buffer = point_from.buffer(max_dist, resolution=16) local_obstacles = obstacles[obstacles.intersects(point_buffer)] vis_poly = get_visibility_accurate(point_from, obstacles=local_obstacles, view_distance=max_dist) noise_from_feature = _eval_donuts_gdf(point_from, dist_db, local_crs, vis_poly) frame_result.append(noise_from_feature) pbar.update(1) elif geom_type == "LineString": layer_points = distribute_points_on_linestrings( group_gdf, radius=linestring_point_radius, lloyd_relax_n=1 ) total_tasks += len(layer_points) pbar.total = total_tasks pbar.refresh() noise_from_feature = _process_lines_or_polygons( group_gdf, max_dist, obstacles, layer_points, dist_db, local_crs, pbar ) frame_result.append(noise_from_feature) elif geom_type == "Polygon": group_gdf.geometry = group_gdf.buffer(0.1, resolution=1) layer_points = distribute_points_on_polygons( group_gdf, only_exterior=False, radius=polygon_point_radius, lloyd_relax_n=1 ) total_tasks += len(layer_points) pbar.total = total_tasks pbar.refresh() noise_from_feature = _process_lines_or_polygons( group_gdf, max_dist, obstacles, layer_points, dist_db, local_crs, pbar ) frame_result.append(noise_from_feature) else: pass noise_gdf = gpd.GeoDataFrame(pd.concat(frame_result, ignore_index=True), crs=local_crs) polygons = gpd.GeoDataFrame( geometry=list(polygonize(noise_gdf.geometry.apply(polygons_to_multilinestring).union_all())), crs=local_crs ) polygons_points = polygons.copy() polygons_points.geometry = polygons.representative_point() sim_result = polygons_points.sjoin(noise_gdf, predicate="within").reset_index() sim_result = sim_result.groupby("index").agg({"noise_level": "max"}) sim_result["geometry"] = polygons sim_result = ( gpd.GeoDataFrame(sim_result, geometry="geometry", crs=local_crs).dissolve(by="noise_level").reset_index() ) return sim_result.to_crs(original_crs)
def _process_lines_or_polygons( group_gdf, max_dist, obstacles, layer_points, dist_db, local_crs, pbar ) -> gpd.GeoDataFrame: features_vision_polys = [] layer_buffer = group_gdf.buffer(max_dist, resolution=16).union_all() local_obstacles = obstacles[obstacles.intersects(layer_buffer)] for _, row in layer_points.iterrows(): point_from = row.geometry vis_poly = get_visibility_accurate(point_from, obstacles=local_obstacles, view_distance=max_dist) features_vision_polys.append(vis_poly) pbar.update(1) features_vision_polys = unary_union(features_vision_polys) return _eval_donuts_gdf(group_gdf.union_all(), dist_db, local_crs, features_vision_polys) def _eval_donuts_gdf(initial_geometry, dist_db, local_crs, clip_poly) -> gpd.GeoDataFrame: donuts = [] don_values = [] to_cut_off = initial_geometry for i in range(len(dist_db[:-1])): cur_buffer = initial_geometry.buffer(dist_db[i + 1][0]) donuts.append(cur_buffer.difference(to_cut_off)) don_values.append(dist_db[i][1]) to_cut_off = cur_buffer noise_from_feature = ( gpd.GeoDataFrame(geometry=donuts, data={"noise_level": don_values}, crs=local_crs) .clip(clip_poly, keep_geom_type=True) .explode(ignore_index=True) ) return noise_from_feature