import dash
from dash import dcc, html, dash_table
from dash.dependencies import Input, Output, State
import plotly.graph_objects as go
import json
import pandas as pd
import os
import numpy as np
import math
from shapely.geometry import Point, Polygon
from shapely.ops import unary_union
# ----------------------------------------------------------------------
# 1. Read configuration parameters
# ----------------------------------------------------------------------
CONFIG_FILE = "simulation_parameter_case.json"
try:
with open(CONFIG_FILE, 'r') as f:
config = json.load(f)
isd = config["channel_parameters"]["isd"]
min_bs_ut_dist = config["channel_parameters"].get("min_bs_ut_dist", 10) # Get forbidden zone radius
display_cell_ids = [bs["cell_id"] for bs in config["bs_parameters"]]
except Exception as e:
print(f"Error: {e} - Using default values")
isd = 200
min_bs_ut_dist = 10 # Default forbidden zone radius
display_cell_ids = []
# ----------------------------------------------------------------------
# 2. Define 7c3s cell topology class (add precise quadrilateral sectors)
# ----------------------------------------------------------------------
class CellTopology:
def __init__(self, isd=200):
self.isd = isd
self.radius = (self.isd / 2.0) / (np.cos(np.deg2rad(30)))
# Base station positions
self.bs_loc_set = np.array([
[0.0, 0.0], # Center base station
[-math.sqrt(3) * self.isd / 2.0, self.isd / 2.0], # Top-left
[0.0, self.isd], # Top
[math.sqrt(3) * self.isd / 2.0, self.isd / 2.0], # Top-right
[math.sqrt(3) * self.isd / 2.0, -self.isd / 2.0], # Bottom-right
[0.0, -self.isd], # Bottom
[-math.sqrt(3) * self.isd / 2.0, -self.isd / 2.0] # Bottom-left
])
# Hexagonal vertices
self.center_bs_hexgon_vertex = np.array([
[self.radius, 0.0],
[self.radius / 2.0, self.isd / 2.0],
[-self.radius / 2.0, self.isd / 2.0],
[-self.radius, 0.0],
[-self.radius / 2.0, -self.isd / 2.0],
[self.radius / 2.0, -self.isd / 2.0],
[self.radius, 0.0]
])
# Sector dividing lines
self.center_sector_split_line = np.array([
[0.0, 0.0], [self.radius, 0.0], # 0°
[0.0, 0.0], [-self.radius / 2.0, self.isd / 2.0], # 120°
[0.0, 0.0], [-self.radius / 2.0, -self.isd / 2.0] # 240°
])
# Store quadrilateral polygons for each sector
self.sector_polygons = {}
current_cell_id = 0
# Sector angle definitions
sector_angles = [0, 120, 240]
for bs_id in range(7):
bs_loc = self.bs_loc_set[bs_id]
hex_vertices = self.center_bs_hexgon_vertex + bs_loc
# Create quadrilateral polygon for each sector
for i, angle in enumerate(sector_angles):
# Sector start and end points
start_idx = i
end_idx = (i + 1) % 3
# Quadrilateral vertices: BS center + three hexagonal vertices
quad_points = np.array([
bs_loc, # BS center
hex_vertices[start_idx * 2],
hex_vertices[start_idx * 2 + 1],
hex_vertices[end_idx * 2]
])
self.sector_polygons[current_cell_id] = quad_points
current_cell_id += 1
# Initialize topology using ISD from config
topology = CellTopology(isd=isd)
# ----------------------------------------------------------------------
# 3. Initial setup for UE trajectory tool
# ----------------------------------------------------------------------
JSON_FILE = "user_specific_parameter.json"
# Initialize JSON file (with options)
def initialize_json_file_with_option(choice=False):
"""Initialize JSON file with options"""
if not os.path.exists(JSON_FILE) or os.stat(JSON_FILE).st_size == 0:
# Force initialization when the file does not exist or is empty
default_data = create_default_json()
with open(JSON_FILE, 'w') as f:
json.dump(default_data, f, indent=4)
print("The JSON file does not exist, a default file has been created.")
return True
elif choice: # User selects initialization
try:
default_data = create_default_json()
with open(JSON_FILE, 'w') as f:
json.dump(default_data, f, indent=4)
print("The user chose to initialize, and the JSON file has been reset.")
return True
except Exception as e:
print(f"JSON initialization error: {e}")
return False
else: # The user chooses not to initialize
try:
with open(JSON_FILE, 'r') as f:
data = json.load(f)
with open(JSON_FILE, 'w') as f:
json.dump(data, f, indent=4)
print("The JSON file has updated the configuration but retained the track data.")
return False
except Exception as e:
print(f"JSON update error: {e}")
return False
def create_default_json():
"""Create default JSON data structure"""
return {
"ue_trajectories": {
"trajectory_interval_second": 1,
"interpolation_interval_second": 0.01,
"trajectory_list": [],
},
"ue_time_freq_granularity": {
"ue_doppler_sampling_pattern": None,
"ue_freq_sampling_pattern": None
}
}
# Initialize JSON file
initialize_json_file_with_option(choice=False)
# ----------------------------------------------------------------------
# 4. Background point generation (only in specified sectors, avoiding forbidden circles)
# ----------------------------------------------------------------------
def generate_background_points(display_cell_ids, min_bs_ut_dist, density=5):
"""Generate background points only for displayed sectors, avoiding BS forbidden zones"""
display_polygons = []
forbidden_circles = [] # Store forbidden circle objects
# Get all polygons for displayed sectors
for cell_id in display_cell_ids:
if cell_id in topology.sector_polygons:
quad_points = topology.sector_polygons[cell_id]
poly = Polygon(quad_points)
display_polygons.append(poly)
# Create corresponding BS forbidden circle
# Calculate BS index (3 sectors per BS)
bs_index = cell_id // 3
if bs_index < len(topology.bs_loc_set):
center = topology.bs_loc_set[bs_index]
circle = Point(center).buffer(min_bs_ut_dist)
forbidden_circles.append(circle)
if not display_polygons:
return [], []
# Combine all polygons
combined_poly = unary_union(display_polygons)
# Combine all forbidden areas
forbidden_area = unary_union(forbidden_circles) if forbidden_circles else None
# Calculate boundaries
min_x, min_y, max_x, max_y = combined_poly.bounds
min_x -= 10
max_x += 10
min_y -= 10
max_y += 10
# Generate grid points
x_coords = np.linspace(min_x, max_x, int((max_x - min_x) * density))
y_coords = np.linspace(min_y, max_y, int((max_y - min_y) * density))
points_x = []
points_y = []
# Filter points inside combined polygon and outside forbidden areas
for x in x_coords:
for y in y_coords:
p = Point(x, y)
if combined_poly.contains(p):
# Check if in forbidden area
in_forbidden = False
if forbidden_area:
in_forbidden = forbidden_area.contains(p)
# Extra check for individual circles (boundary issues)
if not in_forbidden:
for circle in forbidden_circles:
if circle.contains(p):
in_forbidden = True
break
if not in_forbidden:
points_x.append(x)
points_y.append(y)
return points_x, points_y
# Generate background points for specified sectors
BG_POINTS_X, BG_POINTS_Y = generate_background_points(
display_cell_ids, min_bs_ut_dist, density=1
)
# ----------------------------------------------------------------------
# 5. Cell topology plotting function (only plot specified quadrilateral sectors)
# ----------------------------------------------------------------------
def create_initial_figure(all_trajectories_data=[]):
fig = go.Figure()
# Collect coordinates of all displayed sectors
all_x = []
all_y = []
# 1. Plot only specified quadrilateral sectors
for cell_id in display_cell_ids:
if cell_id in topology.sector_polygons:
quad_points = topology.sector_polygons[cell_id]
all_x.extend(quad_points[:, 0])
all_y.extend(quad_points[:, 1])
# Close polygon (add first point to end)
x_quad = list(quad_points[:, 0]) + [quad_points[0, 0]]
y_quad = list(quad_points[:, 1]) + [quad_points[0, 1]]
# Add quadrilateral sector boundaries
fig.add_trace(go.Scatter(
x=x_quad, y=y_quad,
mode='lines',
line=dict(color='green', width=2),
name=f'Cell {cell_id}',
hoverinfo='skip',
showlegend=False
))
# 2. Add sector ID labels
labels_x = []
labels_y = []
labels_text = []
for cell_id in display_cell_ids:
if cell_id in topology.sector_polygons:
quad_points = topology.sector_polygons[cell_id]
# Label position: quadrilateral center
center_x = np.mean(quad_points[:, 0])
center_y = np.mean(quad_points[:, 1])
labels_x.append(center_x)
labels_y.append(center_y)
labels_text.append(f"Cell {cell_id}")
# Add center to coordinate collection
all_x.append(center_x)
all_y.append(center_y)
fig.add_trace(go.Scatter(
x=labels_x,
y=labels_y,
mode='text',
text=labels_text,
textfont=dict(size=12, color='black'),
textposition='middle center',
hoverinfo='skip',
showlegend=False
))
# 3. Add transparent background points
fig.add_trace(go.Scatter(
x=BG_POINTS_X,
y=BG_POINTS_Y,
mode='markers',
marker=dict(size=5, color='rgba(0,0,0,0)', opacity=0),
name='Click Area',
hoverinfo='none',
customdata=[[x, y] for x, y in zip(BG_POINTS_X, BG_POINTS_Y)],
unselected=dict(marker={'opacity': 0}),
selected=dict(marker={'color': 'rgba(255, 0, 0, 0.5)', 'opacity': 0.5, 'size': 8}),
showlegend=False
))
# Add background points to coordinate collection
all_x.extend(BG_POINTS_X)
all_y.extend(BG_POINTS_Y)
# 4. Draw forbidden circles
if min_bs_ut_dist > 0:
for bs_id, bs_loc in enumerate(topology.bs_loc_set):
# Check if BS has displayed sectors (performance optimization)
base_has_displayed_cell = any(
cell_id // 3 == bs_id for cell_id in display_cell_ids
)
if not base_has_displayed_cell:
continue
# Generate circle points (100 points)
theta = np.linspace(0, 2 * np.pi, 100)
circle_x = bs_loc[0] + min_bs_ut_dist * np.cos(theta)
circle_y = bs_loc[1] + min_bs_ut_dist * np.sin(theta)
# Add circle trace
fig.add_trace(go.Scatter(
x=circle_x.tolist() + [circle_x[0]], # Close circle
y=circle_y.tolist() + [circle_y[0]],
mode='lines',
line=dict(color='red', width=1.5, dash='dot'),
name=f'BS {bs_id} Forbidden Zone',
hoverinfo='skip',
showlegend=False
))
# Add circle points to coordinate collection
all_x.extend(circle_x)
all_y.extend(circle_y)
# 5. Auto-calculate axis range
if all_x and all_y:
min_x = min(all_x)
max_x = max(all_x)
min_y = min(all_y)
max_y = max(all_y)
# Add 10% margin
x_margin = (max_x - min_x) * 0.1
y_margin = (max_y - min_y) * 0.1
# Prevent zero margin
if x_margin == 0: x_margin = 10
if y_margin == 0: y_margin = 10
x_range = [min_x - x_margin, max_x + x_margin]
y_range = [min_y - y_margin, max_y + y_margin]
else:
# Default range
x_range = [-100, 100]
y_range = [-100, 100]
# Key modification: Remove aspect ratio lock
fig.update_layout(
title=dict(
text=f'Selected Cells: {display_cell_ids} - UE #{len(all_trajectories_data) + 1} | Forbidden Radius: {min_bs_ut_dist}m',
x=0.02,
y=0.98,
xanchor='left',
yanchor='top',
font=dict(size=12)
),
xaxis_title='X Coordinate (m)',
yaxis_title='Y Coordinate (m)',
xaxis=dict(
range=x_range,
constrain='domain',
automargin=True # Auto-adjust margins
),
yaxis=dict(
range=y_range,
constrain='domain',
automargin=True # Auto-adjust margins
),
dragmode='select',
template='plotly_white',
clickmode='event+select',
margin=dict(l=0, r=0, t=30, b=0),
height=800
)
return fig
# ----------------------------------------------------------------------
# Added: Mode selection enumeration
# ----------------------------------------------------------------------
APP_MODES = {
'DRAW_MODE': 'draw',
'VALIDATE_MODE': 'validate'
}
# ----------------------------------------------------------------------
# New: Boundary validation feature
# ----------------------------------------------------------------------
def validate_trajectories():
"""Verify whether the trajectory points are within the permissible area (report the original points rather than the interpolated points)"""
validation_results = []
invalid_segments_report = [] # Store illegal track segment report
try:
# Read JSON file
with open(JSON_FILE, 'r') as f:
data = json.load(f)
# Obtain trajectory-related parameters
trajectory_list = data["ue_trajectories"]["trajectory_list"]
interval = data["ue_trajectories"]["trajectory_interval_second"]
interp_step = data["ue_trajectories"]["interpolation_interval_second"]
# Calculate the number of interpolation points
interp_points = max(1, int(interval / interp_step))
# Create polygons and restricted areas for all displayed communities
display_polygons = []
forbidden_circles = []
for cell_id in display_cell_ids:
if cell_id in topology.sector_polygons:
quad_points = topology.sector_polygons[cell_id]
poly = Polygon(quad_points)
display_polygons.append(poly)
# Create a restricted area for the corresponding base station
bs_index = cell_id // 3
if bs_index < len(topology.bs_loc_set):
center = topology.bs_loc_set[bs_index]
circle = Point(center).buffer(min_bs_ut_dist)
forbidden_circles.append(circle)
# Combine all polygons and prohibited areas
combined_poly = unary_union(display_polygons) if display_polygons else None
forbidden_area = unary_union(forbidden_circles) if forbidden_circles else None
# Process each trajectory
for ue_idx, trajectory in enumerate(trajectory_list):
ue_id = ue_idx + 1
all_segments_valid = True
ue_invalid_segments = [] # Store the illegal trajectory segment of this UE
# Iterate through each segment in the trajectory (between two adjacent points)
for i in range(len(trajectory) - 1):
start_point = trajectory[i]
end_point = trajectory[i + 1]
segment_invalid = False
# Linear interpolation check
for j in range(interp_points):
ratio = j / interp_points
x = start_point[0] + ratio * (end_point[0] - start_point[0])
y = start_point[1] + ratio * (end_point[1] - start_point[1])
point = Point(x, y)
# Check if it is in a legal area
in_polygon = combined_poly.contains(point) if combined_poly else False
in_forbidden = forbidden_area.contains(point) if forbidden_area else False
if not in_polygon or in_forbidden:
segment_invalid = True
break # If an illegal point is found, stop inspecting the current section.
# If illegal points are found within the segment, record the original trajectory points.
if segment_invalid:
all_segments_valid = False
ue_invalid_segments.append({
'segment_index': i,
'start_point': (start_point[0], start_point[1]),
'end_point': (end_point[0], end_point[1])
})
# Record verification results
if all_segments_valid:
validation_results.append(f"UE{ue_id} All trajectory points are within the legal area.")
else:
# Generate illegal segment report
segments_report = []
for seg in ue_invalid_segments:
segments_report.append(
f"segment {seg['segment_index'] + 1}: pointA({seg['start_point'][0]:.2f},{seg['start_point'][1]:.2f}) -> "
f"pointB({seg['end_point'][0]:.2f},{seg['end_point'][1]:.2f})"
)
validation_results.append(f"UE{ue_id} The trajectory contains illegal segments: {'; '.join(segments_report)}")
# Add to the main report
for seg in ue_invalid_segments:
invalid_segments_report.append({
'UE Logo': f'UE{ue_id}',
'Starting Point X': seg['start_point'][0],
'Starting Point Y': seg['start_point'][1],
'Destination X': seg['end_point'][0],
'Destination Y': seg['end_point'][1]
})
# Generate the final report
if not validation_results:
final_report = "No track data found that needs verification"
elif not invalid_segments_report:
final_report = "All UE trajectory points are within the valid area."
else:
final_report = "Found a trajectory segment containing illegal points"
return final_report, validation_results, invalid_segments_report
except Exception as e:
return f"An error occurred during the verification process: {str(e)}", [], []
# ----------------------------------------------------------------------
# 6. Dash app layout
# ----------------------------------------------------------------------
app = dash.Dash(__name__)
app.layout = html.Div([
html.H1(f"UE Trajectory Plotting Tool (ISD={isd}m)"),
# 新增:模式选择器
dcc.RadioItems(
id='app-mode-selector',
options=[
{'label': 'Drawing Mode', 'value': APP_MODES['DRAW_MODE']},
{'label': 'UE Trajectory Legality Judgment Mode', 'value': APP_MODES['VALIDATE_MODE']}
],
value=APP_MODES['DRAW_MODE'],
labelStyle={'display': 'inline-block', 'margin-right': '20px'}
),
html.Div([
html.P(f"Show cells: {display_cell_ids} | Restricted area radius: {min_bs_ut_dist}m",
style={'font-weight': 'bold', 'margin-top': '10px'})
]),
# Drawing Area
html.Div(id='graph-container', children=[
dcc.Graph(
id='topology-graph',
figure=create_initial_figure(),
config={'displayModeBar': True},
style={'height': '80vh', 'width': '100%'}
),
]),
# Verification Result Area (Initially Hidden)
html.Div(id='validation-container', style={'display': 'none'}, children=[
html.Div(id='validation-summary', style={
'font-size': '18px',
'font-weight': 'bold',
'margin': '20px 0',
'color': 'green'
}),
html.Div(id='validation-details'),
dash_table.DataTable(
id='invalid-points-table',
columns=[
{'name': 'UE Logo', 'id': 'UE Logo'},
{'name': 'Starting Point X', 'id': 'Starting Point X', 'type': 'numeric', 'format': {'specifier': '.2f'}},
{'name': 'Starting Point Y', 'id': 'Starting Point Y', 'type': 'numeric', 'format': {'specifier': '.2f'}},
{'name': 'Destination X', 'id': 'Destination X', 'type': 'numeric', 'format': {'specifier': '.2f'}},
{'name': 'Destination Y', 'id': 'Destination Y', 'type': 'numeric', 'format': {'specifier': '.2f'}}
],
style_table={'overflowX': 'auto', 'margin-top': '20px'},
style_cell={'textAlign': 'center'}
)
]),
html.Div(id='selected-data-output'),
html.Div([
html.Button('Save current UE trajectory', id='save-clear-button', n_clicks=0,
style={'margin-right': '10px', 'padding': '10px', 'background-color': '#4CAF50', 'color': 'white'}),
html.Button('Export to JSON', id='export-json-button', n_clicks=0,
style={'padding': '10px', 'background-color': '#008CBA', 'color': 'white'}),
], style={'margin': '20px 0'}),
html.Div([
html.Button('Initialize JSON file', id='init-json-button', n_clicks=0,
style={'margin-right': '10px', 'padding': '10px', 'background-color': '#FF9800', 'color': 'white'}),
dcc.ConfirmDialog(
id='init-confirm-dialog',
message='This will reset all track data! Are you sure you want to initialize the JSON file?',
),
], style={'margin': '20px 0'}),
dcc.Store(id='current-ue-store', data=[]),
dcc.Store(id='all-trajectories-store', data=[]),
dcc.Store(id='last-point-coords', data={'x': None, 'y': None})
])
# ----------------------------------------------------------------------
# 7. Dash callback function
# ----------------------------------------------------------------------
@app.callback(
Output('init-confirm-dialog', 'displayed'),
Input('init-json-button', 'n_clicks'),
prevent_initial_call=True
)
def confirm_init(n_clicks):
if n_clicks > 0:
return True
return False
@app.callback(
Output('init-json-button', 'children'),
Input('init-confirm-dialog', 'submit_n_clicks'),
prevent_initial_call=True
)
def perform_init(submit_clicks):
if submit_clicks:
success = initialize_json_file_with_option(choice=True)
return "Initialization successful!" if success else "Initialization failed"
return dash.no_update
@app.callback(
[Output('graph-container', 'style'),
Output('validation-container', 'style'),
Output('validation-summary', 'children'),
Output('validation-details', 'children'),
Output('invalid-points-table', 'data')],
[Input('app-mode-selector', 'value')]
)
def switch_app_mode(selected_mode):
if selected_mode == APP_MODES['VALIDATE_MODE']:
# Execute verification
summary, details, invalid_points = validate_trajectories()
# Generate detailed results
details_html = html.Div([
html.P(line, style={'color': 'red' if 'illegal' in line else 'green', 'margin': '5px 0'})
for line in details
])
# Show verification results, hide graphics
return (
{'display': 'none'}, # Hidden graphics
{'display': 'block', 'padding': '20px', 'border': '1px solid #ddd'}, # Show verification results
summary,
details_html,
invalid_points
)
else:
# Show graphics, hide verification results
return (
{'display': 'block'}, # Display graphics
{'display': 'none'}, # Hide verification results
"",
"",
[]
)
@app.callback(
[Output('current-ue-store', 'data', allow_duplicate=True),
Output('last-point-coords', 'data', allow_duplicate=True)],
[Input('topology-graph', 'selectedData')],
[State('current-ue-store', 'data'),
State('topology-graph', 'figure')], # Add dependency on graphic state
prevent_initial_call=True
)
def handle_graph_select(selectedData, current_ue_data, figure):
if selectedData is None or not selectedData['points']:
return dash.no_update, dash.no_update
last_point = selectedData['points'][-1]
# Dynamically calculate the curve number of the background point trajectory
background_curve_number = len(display_cell_ids) + 1 # Quadrilateral Sector Label Track
# Confirm selection from 'click area' path
if last_point.get('curveNumber') != background_curve_number:
return dash.no_update, dash.no_update
x = last_point['x']
y = last_point['y']
z = 1.5
new_point = [round(x, 4), round(y, 4), z]
if current_ue_data and new_point == current_ue_data[-1]:
return dash.no_update, dash.no_update
updated_trajectory = current_ue_data + [new_point]
return updated_trajectory, {'x': round(x, 4), 'y': round(y, 4)}
@app.callback(
[Output('topology-graph', 'figure', allow_duplicate=True),
Output('selected-data-output', 'children')],
[Input('current-ue-store', 'data'),
Input('last-point-coords', 'data')],
[State('all-trajectories-store', 'data')],
prevent_initial_call=True
)
def update_graph_and_display(current_ue_data, last_point_coords, all_trajectories_data):
fig = create_initial_figure(all_trajectories_data)
if current_ue_data:
df = pd.DataFrame(current_ue_data, columns=['x', 'y', 'z'])
fig.add_trace(go.Scatter(
x=df['x'],
y=df['y'],
mode='lines+markers',
marker=dict(size=8, color='red', symbol='circle'),
line=dict(color='red', width=2),
name='UE Trajectory',
hoverinfo='text',
text=[f'({x}, {y})' for x, y in zip(df['x'], df['y'])],
showlegend=True
))
if last_point_coords['x'] is not None:
display_text = f"Last Point: X={last_point_coords['x']}, Y={last_point_coords['y']}, Height=1.5m"
else:
display_text = "Drag/click on the chart area to draw UE trajectory..."
return fig, display_text
@app.callback(
[Output('current-ue-store', 'data', allow_duplicate=True),
Output('all-trajectories-store', 'data', allow_duplicate=True)],
[Input('save-clear-button', 'n_clicks')],
[State('current-ue-store', 'data'),
State('all-trajectories-store', 'data')],
prevent_initial_call=True
)
def save_and_clear_trajectory(n_clicks, current_ue_data, all_trajectories_data):
if n_clicks > 0:
if not current_ue_data:
return [], all_trajectories_data
updated_all_trajectories = all_trajectories_data + [current_ue_data]
new_current_ue_data = []
return new_current_ue_data, updated_all_trajectories
return dash.no_update, dash.no_update
@app.callback(
Output('export-json-button', 'children'),
[Input('export-json-button', 'n_clicks')],
[State('all-trajectories-store', 'data')],
prevent_initial_call=True
)
def export_to_json(n_clicks, all_trajectories_data):
if n_clicks > 0:
try:
with open(JSON_FILE, 'r') as f:
data = json.load(f)
data["ue_trajectories"]["trajectory_list"] = all_trajectories_data
with open(JSON_FILE, 'w') as f:
json.dump(data, f, indent=4)
num_trajectories = len(all_trajectories_data)
return f"Exported {num_trajectories} trajectories to {JSON_FILE}"
except Exception:
return "Export failed!"
return 'Export All Trajectories to JSON File'
# ----------------------------------------------------------------------
# 8. Run the application
# ----------------------------------------------------------------------
if __name__ == '__main__':
print(f"Please visit http://127.0.0.1:8050/")
app.run(debug=True)
将上面代码中初始化Json的按钮 平行放在Export to JSON按钮的右边