DavMelchi commited on
Commit
e36a82b
·
1 Parent(s): e81ad8d

Add site and sector KML map creator

Browse files
app.py CHANGED
@@ -200,7 +200,7 @@ if check_password():
200
  ),
201
  st.Page(
202
  "apps/sector_kml_generator.py",
203
- title="Sector KML Generator",
204
  icon=":material/map:",
205
  ),
206
  st.Page(
@@ -313,7 +313,7 @@ if check_password():
313
  ),
314
  st.Page(
315
  "documentations/sector_kml_doc.py",
316
- title="Sector KML Generator Documentation",
317
  icon=":material/menu_book:",
318
  ),
319
  st.Page(
 
200
  ),
201
  st.Page(
202
  "apps/sector_kml_generator.py",
203
+ title="Site & Sector KML Creator",
204
  icon=":material/map:",
205
  ),
206
  st.Page(
 
313
  ),
314
  st.Page(
315
  "documentations/sector_kml_doc.py",
316
+ title="Site & Sector KML Creator Documentation",
317
  icon=":material/menu_book:",
318
  ),
319
  st.Page(
apps/sector_kml_generator.py CHANGED
@@ -1,95 +1,445 @@
1
  from datetime import datetime
 
2
 
3
  import pandas as pd
 
4
  import streamlit as st
5
 
6
- from utils.kml_creator import generate_kml_from_df
 
 
 
 
 
 
7
 
8
- st.title(":material/map: Telecom Sector KML Generator")
9
 
 
 
 
 
 
 
 
 
 
10
 
11
- # display mandatory columns
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
- col1, col2 = st.columns(2)
14
 
15
- with col1:
16
- st.write("Mandatory columns:")
17
- st.markdown(
18
- """
19
- | Column Name | Description |
20
- | --- | --- |
21
- | code| code of the site |
22
- | name | Name of the sector |
23
- | Azimut | Azimuth of the sector |
24
- | Longitude | Longitude of the sector |
25
- | Latitude | Latitude of the sector |
26
- | Size | Size of the sector ex:100 |
27
- | colors | Color of the sector |
28
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  )
30
- st.write(
31
- "All other columns added in the file will be displayed in the KML description for each sector."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  )
33
 
34
- with col2:
35
- st.markdown(
36
- """
37
- | Color Name | KML Color Code (AABBGGRR) |
38
- | --- | --- |
39
- | Red | 7f0000ff |
40
- | Green | 7f00ff00 |
41
- | Blue | 7fff0000 |
42
- | Yellow | 7f00ffff |
43
- | Cyan | 7fffff00 |
44
- | Magenta | 7fff00ff |
45
- | Orange | 7f007fff |
46
- | Purple | 7f7f00ff |
47
- | Pink | 7fcc99ff |
48
- | Brown | 7f2a2aa5 |
49
- """
50
  )
 
51
 
52
- sector_kml_sample_file = "samples/Sector_kml.xlsx"
53
 
54
- # Create a download button
55
- st.download_button(
56
- label="Download Sector KML sample File",
57
- data=open(sector_kml_sample_file, "rb").read(),
58
- file_name="Sector_kml.xlsx",
59
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
60
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
 
63
- st.write("Upload an excel file containing sectors data to generate a KML file.")
64
- # Upload CSV file
65
- uploaded_file = st.file_uploader("Upload XLSX file", type=["xlsx"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
- if uploaded_file is not None:
68
- # Read CSV
69
  df = pd.read_excel(uploaded_file, keep_default_na=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
- # Check if required columns exist
72
- required_columns = {
73
- "code",
74
- "name",
75
- "Azimut",
76
- "Longitude",
77
- "Latitude",
78
- "size",
79
- "color",
80
- }
81
- if not required_columns.issubset(df.columns):
82
- st.error(f"Uploaded file must contain columns: {', '.join(required_columns)}")
83
- else:
84
- # Generate KML
85
- kml_data = generate_kml_from_df(df)
86
-
87
- # Download button
88
- st.download_button(
89
- label="Download KML",
90
- data=kml_data,
91
- file_name=f"Sectors_kml_{datetime.now()}.kml",
92
- mime="application/vnd.google-earth.kml+xml",
 
 
 
 
 
 
 
 
 
93
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
 
95
- st.success("KML file generated successfully.")
 
 
 
 
 
1
  from datetime import datetime
2
+ from pathlib import Path
3
 
4
  import pandas as pd
5
+ import plotly.graph_objects as go
6
  import streamlit as st
7
 
8
+ from utils.kml_creator import (
9
+ DEFAULT_SITE_ICON,
10
+ generate_kml_from_df,
11
+ generate_site_kml_from_df,
12
+ kml_color_to_rgba,
13
+ sector_polygon_coordinates,
14
+ )
15
 
16
+ st.title(":material/map: Site & Sector KML Creator")
17
 
18
+ REQUIRED_SECTOR_COLUMNS = {
19
+ "code",
20
+ "name",
21
+ "Azimut",
22
+ "Longitude",
23
+ "Latitude",
24
+ "size",
25
+ "color",
26
+ }
27
 
28
+ SITE_ICON_OPTIONS = {
29
+ "Yellow pin": {
30
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/ylw-pushpin.png",
31
+ "preview_color": "#FACC15",
32
+ "size": 16,
33
+ },
34
+ "Red pin": {
35
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/red-pushpin.png",
36
+ "preview_color": "#DC2626",
37
+ "size": 16,
38
+ },
39
+ "Blue pin": {
40
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/blue-pushpin.png",
41
+ "preview_color": "#2563EB",
42
+ "size": 16,
43
+ },
44
+ "Green pin": {
45
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/grn-pushpin.png",
46
+ "preview_color": "#16A34A",
47
+ "size": 16,
48
+ },
49
+ "Purple pin": {
50
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/purple-pushpin.png",
51
+ "preview_color": "#7C3AED",
52
+ "size": 16,
53
+ },
54
+ "Pink pin": {
55
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/pink-pushpin.png",
56
+ "preview_color": "#DB2777",
57
+ "size": 16,
58
+ },
59
+ "Light blue pin": {
60
+ "href": "http://maps.google.com/mapfiles/kml/pushpin/ltblu-pushpin.png",
61
+ "preview_color": "#38BDF8",
62
+ "size": 16,
63
+ },
64
+ "Circle": {
65
+ "href": DEFAULT_SITE_ICON,
66
+ "preview_color": "#E4572E",
67
+ "size": 13,
68
+ },
69
+ "Target": {
70
+ "href": "http://maps.google.com/mapfiles/kml/shapes/target.png",
71
+ "preview_color": "#2563EB",
72
+ "size": 15,
73
+ },
74
+ "Square": {
75
+ "href": "http://maps.google.com/mapfiles/kml/shapes/square.png",
76
+ "preview_color": "#16A34A",
77
+ "size": 13,
78
+ },
79
+ "Triangle": {
80
+ "href": "http://maps.google.com/mapfiles/kml/shapes/triangle.png",
81
+ "preview_color": "#D97706",
82
+ "size": 14,
83
+ },
84
+ "Star": {
85
+ "href": "http://maps.google.com/mapfiles/kml/shapes/star.png",
86
+ "preview_color": "#CA8A04",
87
+ "size": 15,
88
+ },
89
+ "Info": {
90
+ "href": "http://maps.google.com/mapfiles/kml/shapes/info-i.png",
91
+ "preview_color": "#7C3AED",
92
+ "size": 14,
93
+ },
94
+ }
95
 
 
96
 
97
+ def _timestamp() -> str:
98
+ return datetime.now().strftime("%Y%m%d_%H%M%S")
99
+
100
+
101
+ def _read_uploaded_table(uploaded_file) -> pd.DataFrame:
102
+ suffix = Path(uploaded_file.name).suffix.lower()
103
+ if suffix == ".csv":
104
+ return pd.read_csv(uploaded_file, keep_default_na=False)
105
+ return pd.read_excel(uploaded_file, keep_default_na=False)
106
+
107
+
108
+ def _default_column(columns: list[str], candidates: list[str]) -> str:
109
+ normalized = {str(col).strip().lower(): col for col in columns}
110
+ for candidate in candidates:
111
+ if candidate.lower() in normalized:
112
+ return normalized[candidate.lower()]
113
+
114
+ for candidate in candidates:
115
+ for col in columns:
116
+ if candidate.lower() in str(col).strip().lower():
117
+ return col
118
+
119
+ return columns[0]
120
+
121
+
122
+ def _prepare_coordinate_df(df: pd.DataFrame, lat_col: str, lon_col: str) -> pd.DataFrame:
123
+ map_df = df.copy()
124
+ map_df[lat_col] = pd.to_numeric(map_df[lat_col], errors="coerce")
125
+ map_df[lon_col] = pd.to_numeric(map_df[lon_col], errors="coerce")
126
+ map_df = map_df.dropna(subset=[lat_col, lon_col])
127
+ map_df = map_df[
128
+ map_df[lat_col].between(-90, 90) & map_df[lon_col].between(-180, 180)
129
+ ]
130
+ return map_df
131
+
132
+
133
+ def _estimate_zoom(df: pd.DataFrame, lat_col: str, lon_col: str) -> float:
134
+ if df.empty:
135
+ return 5
136
+
137
+ lat_span = float(df[lat_col].max() - df[lat_col].min())
138
+ lon_span = float(df[lon_col].max() - df[lon_col].min())
139
+ span = max(lat_span, lon_span)
140
+
141
+ if span <= 0.01:
142
+ return 13
143
+ if span <= 0.05:
144
+ return 11
145
+ if span <= 0.2:
146
+ return 9
147
+ if span <= 1:
148
+ return 7
149
+ if span <= 5:
150
+ return 5
151
+ return 3
152
+
153
+
154
+ def _rgba_css(rgba: list[int], alpha_override: float | None = None) -> str:
155
+ red, green, blue, alpha = rgba
156
+ alpha_float = alpha / 255 if alpha_override is None else alpha_override
157
+ return f"rgba({red}, {green}, {blue}, {alpha_float:.3f})"
158
+
159
+
160
+ def _hover_text(row: pd.Series) -> str:
161
+ return "<br>".join(
162
+ f"<b>{column}</b>: {value}" for column, value in row.items()
163
  )
164
+
165
+
166
+ def _show_site_position_map(
167
+ df: pd.DataFrame,
168
+ site_col: str,
169
+ lat_col: str,
170
+ lon_col: str,
171
+ title: str,
172
+ show_labels: bool = True,
173
+ marker_color: str = "#E4572E",
174
+ marker_size: int = 13,
175
+ ) -> None:
176
+ map_df = _prepare_coordinate_df(df, lat_col, lon_col)
177
+
178
+ if map_df.empty:
179
+ st.warning("No valid coordinates available for map display.")
180
+ return
181
+
182
+ fig = go.Figure()
183
+ mode = "markers+text" if show_labels else "markers"
184
+ fig.add_trace(
185
+ go.Scattermapbox(
186
+ lat=map_df[lat_col],
187
+ lon=map_df[lon_col],
188
+ mode=mode,
189
+ text=map_df[site_col].astype(str),
190
+ textposition="top center",
191
+ marker={"size": marker_size, "color": marker_color},
192
+ hovertext=map_df.apply(_hover_text, axis=1),
193
+ hoverinfo="text",
194
+ name="Sites",
195
+ )
196
  )
197
 
198
+ fig.update_layout(
199
+ title=title,
200
+ mapbox_style="open-street-map",
201
+ mapbox_center={
202
+ "lat": float(map_df[lat_col].mean()),
203
+ "lon": float(map_df[lon_col].mean()),
204
+ },
205
+ mapbox_zoom=_estimate_zoom(map_df, lat_col, lon_col),
206
+ height=620,
207
+ margin={"r": 0, "t": 45, "l": 0, "b": 0},
 
 
 
 
 
 
208
  )
209
+ st.plotly_chart(fig, use_container_width=True)
210
 
 
211
 
212
+ def _show_sector_map(df: pd.DataFrame, show_labels: bool = True) -> None:
213
+ map_df = _prepare_coordinate_df(df, "Latitude", "Longitude")
214
+
215
+ if map_df.empty:
216
+ st.warning("No valid sector coordinates available for map display.")
217
+ return
218
+
219
+ numeric_cols = ["Azimut", "size"]
220
+ for col in numeric_cols:
221
+ map_df[col] = pd.to_numeric(map_df[col], errors="coerce")
222
+ map_df = map_df.dropna(subset=numeric_cols)
223
+
224
+ if map_df.empty:
225
+ st.warning("No valid azimuth/size values available for sector map display.")
226
+ return
227
+
228
+ fig = go.Figure()
229
+ df_sorted = map_df.sort_values(by="size", ascending=False)
230
+
231
+ for _, row in df_sorted.iterrows():
232
+ coords = sector_polygon_coordinates(row)
233
+ lons = [coord[0] for coord in coords]
234
+ lats = [coord[1] for coord in coords]
235
+ rgba = kml_color_to_rgba(row["color"])
236
+ fig.add_trace(
237
+ go.Scattermapbox(
238
+ lon=lons,
239
+ lat=lats,
240
+ mode="lines",
241
+ fill="toself",
242
+ fillcolor=_rgba_css(rgba),
243
+ line={"color": "black", "width": 1},
244
+ hovertext=_hover_text(row),
245
+ hoverinfo="text",
246
+ name=str(row["name"]),
247
+ showlegend=False,
248
+ )
249
+ )
250
+
251
+ site_df = map_df.drop_duplicates(subset=["code"]).copy()
252
+ mode = "markers+text" if show_labels else "markers"
253
+ fig.add_trace(
254
+ go.Scattermapbox(
255
+ lat=site_df["Latitude"],
256
+ lon=site_df["Longitude"],
257
+ mode=mode,
258
+ text=site_df["code"].astype(str),
259
+ textposition="top center",
260
+ marker={"size": 10, "color": "#111111"},
261
+ hovertext=site_df.apply(_hover_text, axis=1),
262
+ hoverinfo="text",
263
+ name="Sites",
264
+ )
265
+ )
266
+
267
+ fig.update_layout(
268
+ title="Sector map preview",
269
+ mapbox_style="open-street-map",
270
+ mapbox_center={
271
+ "lat": float(map_df["Latitude"].mean()),
272
+ "lon": float(map_df["Longitude"].mean()),
273
+ },
274
+ mapbox_zoom=_estimate_zoom(map_df, "Latitude", "Longitude"),
275
+ height=650,
276
+ margin={"r": 0, "t": 45, "l": 0, "b": 0},
277
+ )
278
+ st.plotly_chart(fig, use_container_width=True)
279
 
280
 
281
+ def _render_sector_help() -> None:
282
+ col1, col2 = st.columns(2)
283
+
284
+ with col1:
285
+ st.write("Mandatory columns:")
286
+ st.markdown(
287
+ """
288
+ | Column Name | Description |
289
+ | --- | --- |
290
+ | code | Code of the site |
291
+ | name | Name of the sector |
292
+ | Azimut | Azimuth of the sector |
293
+ | Longitude | Longitude of the sector |
294
+ | Latitude | Latitude of the sector |
295
+ | size | Size of the sector, for example `100` |
296
+ | color | KML color code |
297
+ """
298
+ )
299
+ st.write(
300
+ "All other columns added in the file will be displayed in the KML description for each sector."
301
+ )
302
+
303
+ with col2:
304
+ st.markdown(
305
+ """
306
+ | Color Name | KML Color Code (AABBGGRR) |
307
+ | --- | --- |
308
+ | Red | 7f0000ff |
309
+ | Green | 7f00ff00 |
310
+ | Blue | 7fff0000 |
311
+ | Yellow | 7f00ffff |
312
+ | Cyan | 7fffff00 |
313
+ | Magenta | 7fff00ff |
314
+ | Orange | 7f007fff |
315
+ | Purple | 7f7f00ff |
316
+ | Pink | 7fcc99ff |
317
+ | Brown | 7f2a2aa5 |
318
+ """
319
+ )
320
+
321
+
322
+ def _render_sector_generator() -> None:
323
+ _render_sector_help()
324
+
325
+ sector_kml_sample_file = "samples/Sector_kml.xlsx"
326
+ st.download_button(
327
+ label="Download Sector KML sample File",
328
+ data=open(sector_kml_sample_file, "rb").read(),
329
+ file_name="Sector_kml.xlsx",
330
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
331
+ )
332
+
333
+ st.write("Upload an Excel file containing sectors data to generate a KML file.")
334
+ uploaded_file = st.file_uploader(
335
+ "Upload sector XLSX file", type=["xlsx"], key="sector_kml_file"
336
+ )
337
+
338
+ if uploaded_file is None:
339
+ return
340
 
 
 
341
  df = pd.read_excel(uploaded_file, keep_default_na=False)
342
+ missing_columns = REQUIRED_SECTOR_COLUMNS.difference(df.columns)
343
+
344
+ if missing_columns:
345
+ st.error(f"Uploaded file must contain columns: {', '.join(sorted(missing_columns))}")
346
+ return
347
+
348
+ kml_data = generate_kml_from_df(df)
349
+ st.download_button(
350
+ label="Download Sector KML",
351
+ data=kml_data,
352
+ file_name=f"Sectors_kml_{_timestamp()}.kml",
353
+ mime="application/vnd.google-earth.kml+xml",
354
+ )
355
+
356
+ st.success("Sector KML file generated successfully.")
357
+ st.dataframe(df.head(100), use_container_width=True)
358
+ show_labels = st.checkbox("Show site labels on map", value=True, key="sector_labels")
359
+ _show_sector_map(df, show_labels=show_labels)
360
 
361
+
362
+ def _render_site_position_generator() -> None:
363
+ st.write(
364
+ "Upload an Excel or CSV file containing site positions. Minimum required data: site name/code, latitude, longitude."
365
+ )
366
+ uploaded_file = st.file_uploader(
367
+ "Upload site position file", type=["xlsx", "csv"], key="site_position_file"
368
+ )
369
+
370
+ if uploaded_file is None:
371
+ return
372
+
373
+ df = _read_uploaded_table(uploaded_file)
374
+ if df.empty:
375
+ st.warning("Uploaded file is empty.")
376
+ return
377
+
378
+ columns = df.columns.tolist()
379
+ col1, col2, col3, col4 = st.columns(4)
380
+
381
+ with col1:
382
+ site_col = st.selectbox(
383
+ "Site column",
384
+ columns,
385
+ index=columns.index(_default_column(columns, ["site", "code", "name", "site_code"])),
386
+ )
387
+ with col2:
388
+ lat_col = st.selectbox(
389
+ "Latitude column",
390
+ columns,
391
+ index=columns.index(_default_column(columns, ["Latitude", "lat", "y"])),
392
  )
393
+ with col3:
394
+ lon_col = st.selectbox(
395
+ "Longitude column",
396
+ columns,
397
+ index=columns.index(_default_column(columns, ["Longitude", "lon", "lng", "x"])),
398
+ )
399
+ with col4:
400
+ icon_name = st.selectbox(
401
+ "Site icon",
402
+ list(SITE_ICON_OPTIONS.keys()),
403
+ index=0,
404
+ )
405
+
406
+ map_df = _prepare_coordinate_df(df, lat_col, lon_col)
407
+ if map_df.empty:
408
+ st.warning("No valid latitude/longitude rows found after cleaning.")
409
+ return
410
+
411
+ icon_config = SITE_ICON_OPTIONS[icon_name]
412
+ kml_data = generate_site_kml_from_df(
413
+ map_df,
414
+ site_col,
415
+ lat_col,
416
+ lon_col,
417
+ icon_href=icon_config["href"],
418
+ )
419
+ st.download_button(
420
+ label="Download Site Position KML",
421
+ data=kml_data,
422
+ file_name=f"Site_positions_{_timestamp()}.kml",
423
+ mime="application/vnd.google-earth.kml+xml",
424
+ )
425
+
426
+ st.success(f"Site position KML generated successfully for {len(map_df)} valid sites.")
427
+ st.dataframe(map_df.head(100), use_container_width=True)
428
+ show_labels = st.checkbox("Show site labels on map", value=True, key="site_labels")
429
+ _show_site_position_map(
430
+ map_df,
431
+ site_col=site_col,
432
+ lat_col=lat_col,
433
+ lon_col=lon_col,
434
+ title="Site position map preview",
435
+ show_labels=show_labels,
436
+ marker_color=icon_config["preview_color"],
437
+ marker_size=icon_config["size"],
438
+ )
439
+
440
 
441
+ tab_sectors, tab_sites = st.tabs(["Sectors", "Site positions"])
442
+ with tab_sectors:
443
+ _render_sector_generator()
444
+ with tab_sites:
445
+ _render_site_position_generator()
documentations/sector_kml_doc.py CHANGED
@@ -2,22 +2,25 @@
2
 
3
  st.markdown(
4
  """
5
- # Sector KML Generator Documentation
6
 
7
  ## 1. Objective
8
- Generate a KML file from sector-level Excel data for geographic visualization.
9
 
10
  ## 2. When to use this tool
11
  Use this page when you need to:
12
  - visualize telecom sectors in GIS/KML viewers
 
 
13
  - share sector orientation and metadata
14
  - prepare map overlays for field/optimization teams
15
 
16
  ## 3. Input files and accepted formats
17
- - Required: one `.xlsx` file containing sector data.
 
18
 
19
  ## 4. Required columns
20
- The uploaded file must contain all required columns:
21
  - `code`
22
  - `name`
23
  - `Azimut`
@@ -28,36 +31,57 @@ The uploaded file must contain all required columns:
28
 
29
  Any additional column is exported in sector description metadata.
30
 
 
 
 
 
 
 
 
 
31
  ## 5. Step-by-step usage
32
- 1. Open `Apps > Sector KML Generator`.
33
  2. (Optional) Download sample file from the page.
34
- 3. Upload your Excel file.
35
- 4. Ensure required columns are present.
36
- 5. Download generated KML.
 
 
37
 
38
  ## 6. Outputs generated
39
- - downloadable KML file named like `Sectors_kml_<timestamp>.kml`
 
 
40
 
41
  ## 7. Frequent errors and fixes
42
  - Missing required columns error.
43
  - Fix: rename columns exactly as required.
44
  - Empty/invalid geometry in output.
45
  - Fix: verify `Latitude`/`Longitude` and azimuth values.
 
 
46
  - Unexpected style/color rendering.
47
  - Fix: validate color codes and supported color naming.
48
 
49
  ## 8. Minimal reproducible example
50
- - Input: `samples/Sector_kml.xlsx`
51
- - Action: upload file and click download when generated.
52
- - Expected result: valid KML file ready for map tools.
 
 
 
 
 
 
53
 
54
  ## 9. Known limitations
55
  - Input schema is case-sensitive for required column names.
56
- - Only `.xlsx` is supported.
 
57
  - Invalid coordinate values may produce unusable geometry.
58
 
59
  ## 10. Version and update date
60
- - Documentation version: 1.0
61
- - Last update: 2026-02-23
62
  """
63
  )
 
2
 
3
  st.markdown(
4
  """
5
+ # Site & Sector KML Creator Documentation
6
 
7
  ## 1. Objective
8
+ Generate KML files and map previews from sector-level or site-position input data.
9
 
10
  ## 2. When to use this tool
11
  Use this page when you need to:
12
  - visualize telecom sectors in GIS/KML viewers
13
+ - preview sectors directly on an OpenStreetMap map before download
14
+ - plot simple site positions without sector polygons
15
  - share sector orientation and metadata
16
  - prepare map overlays for field/optimization teams
17
 
18
  ## 3. Input files and accepted formats
19
+ - Sector mode: one `.xlsx` file containing sector data.
20
+ - Site positions mode: one `.xlsx` or `.csv` file containing at least site, latitude, and longitude columns.
21
 
22
  ## 4. Required columns
23
+ For sector generation, the uploaded file must contain all required columns:
24
  - `code`
25
  - `name`
26
  - `Azimut`
 
31
 
32
  Any additional column is exported in sector description metadata.
33
 
34
+ For site-position generation, select the columns that represent:
35
+ - site name/code
36
+ - latitude
37
+ - longitude
38
+
39
+ Any additional column is exported in site description metadata.
40
+ The selected site icon is applied to the generated KML point placemarks.
41
+
42
  ## 5. Step-by-step usage
43
+ 1. Open `Apps > Site & Sector KML Creator`.
44
  2. (Optional) Download sample file from the page.
45
+ 3. Choose `Sectors` or `Site positions`.
46
+ 4. Upload your file.
47
+ 5. Ensure required columns are present or select the matching site/latitude/longitude columns.
48
+ 6. Review the map preview.
49
+ 7. Download generated KML.
50
 
51
  ## 6. Outputs generated
52
+ - downloadable sector KML file named like `Sectors_kml_<timestamp>.kml`
53
+ - downloadable site-position KML file named like `Site_positions_<timestamp>.kml`
54
+ - interactive map preview inside the app
55
 
56
  ## 7. Frequent errors and fixes
57
  - Missing required columns error.
58
  - Fix: rename columns exactly as required.
59
  - Empty/invalid geometry in output.
60
  - Fix: verify `Latitude`/`Longitude` and azimuth values.
61
+ - Empty map preview.
62
+ - Fix: verify selected latitude/longitude columns contain decimal coordinates.
63
  - Unexpected style/color rendering.
64
  - Fix: validate color codes and supported color naming.
65
 
66
  ## 8. Minimal reproducible example
67
+ - Sector input: `samples/Sector_kml.xlsx`
68
+ - Action: upload file, review preview map, then download generated KML.
69
+ - Expected result: valid KML file ready for map tools and matching map preview.
70
+
71
+ Site-position minimal input:
72
+
73
+ | site | lat | lon |
74
+ | --- | --- | --- |
75
+ | S001 | 12.3 | -7.1 |
76
 
77
  ## 9. Known limitations
78
  - Input schema is case-sensitive for required column names.
79
+ - Sector mode supports `.xlsx`.
80
+ - Site-position mode supports `.xlsx` and `.csv`.
81
  - Invalid coordinate values may produce unusable geometry.
82
 
83
  ## 10. Version and update date
84
+ - Documentation version: 1.1
85
+ - Last update: 2026-05-02
86
  """
87
  )
tests/test_kml_creator.py CHANGED
@@ -1,6 +1,11 @@
1
  import pandas as pd
2
 
3
- from utils.kml_creator import generate_kml_from_df
 
 
 
 
 
4
 
5
 
6
  def test_generate_kml_from_df_formats_integer_like_site_codes_without_decimal():
@@ -24,3 +29,48 @@ def test_generate_kml_from_df_formats_integer_like_site_codes_without_decimal():
24
 
25
  assert "<name>694</name>" in kml_text
26
  assert "<name>694.0</name>" not in kml_text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import pandas as pd
2
 
3
+ from utils.kml_creator import (
4
+ generate_kml_from_df,
5
+ generate_site_kml_from_df,
6
+ kml_color_to_rgba,
7
+ sector_polygon_coordinates,
8
+ )
9
 
10
 
11
  def test_generate_kml_from_df_formats_integer_like_site_codes_without_decimal():
 
29
 
30
  assert "<name>694</name>" in kml_text
31
  assert "<name>694.0</name>" not in kml_text
32
+
33
+
34
+ def test_sector_polygon_coordinates_closes_polygon():
35
+ row = {
36
+ "Longitude": -7.1,
37
+ "Latitude": 12.3,
38
+ "Azimut": 90,
39
+ "size": 100,
40
+ }
41
+
42
+ coords = sector_polygon_coordinates(row)
43
+
44
+ assert len(coords) == 22
45
+ assert coords[0] == (-7.1, 12.3)
46
+ assert coords[-1] == coords[0]
47
+
48
+
49
+ def test_kml_color_to_rgba_converts_aabbggrr():
50
+ assert kml_color_to_rgba("7f0000ff") == [255, 0, 0, 127]
51
+ assert kml_color_to_rgba("bad") == [31, 119, 180, 127]
52
+
53
+
54
+ def test_generate_site_kml_from_df_exports_site_points_and_metadata():
55
+ df = pd.DataFrame(
56
+ [
57
+ {
58
+ "site": "S001",
59
+ "lat": 12.3,
60
+ "lon": -7.1,
61
+ "region": "North",
62
+ }
63
+ ]
64
+ )
65
+
66
+ icon_href = "http://maps.google.com/mapfiles/kml/shapes/star.png"
67
+ kml_text = (
68
+ generate_site_kml_from_df(df, "site", "lat", "lon", icon_href=icon_href)
69
+ .getvalue()
70
+ .decode("utf-8")
71
+ )
72
+
73
+ assert "<name>S001</name>" in kml_text
74
+ assert f"<href>{icon_href}</href>" in kml_text
75
+ assert "&lt;b&gt;region:&lt;/b&gt; North&lt;br&gt;" in kml_text
76
+ assert "-7.1,12.3,0.0" in kml_text
utils/kml_creator.py CHANGED
@@ -6,6 +6,11 @@ import pandas as pd
6
  import simplekml
7
 
8
 
 
 
 
 
 
9
  def _format_kml_value(value) -> str:
10
  """Render integer-like numeric values without a trailing .0 in KML text."""
11
  if pd.isna(value):
@@ -26,42 +31,70 @@ def _format_kml_value(value) -> str:
26
  return str(value)
27
 
28
 
29
- def create_sector(kml: simplekml.Kml, row, arc_angle=65):
30
- """Create a sector shape for the telecom antenna in KML with sector details."""
31
- code, name, azimuth, lon, lat, size, color = (
32
- _format_kml_value(row["code"]),
33
- _format_kml_value(row["name"]),
34
- row["Azimut"],
35
- row["Longitude"],
36
- row["Latitude"],
37
- row["size"],
38
- row["color"],
39
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- num_points = 20 # Number of points for smooth arc
42
  start_angle = azimuth - (arc_angle / 2)
43
  end_angle = azimuth + (arc_angle / 2)
 
44
 
45
- coords = [(lon, lat)] # Start with the site location (center point)
46
-
47
- # Generate points for the sector arc
48
  for angle in np.linspace(start_angle, end_angle, num_points):
49
  angle_rad = math.radians(angle)
50
  arc_lon = lon + (size / 111320) * math.sin(angle_rad)
51
  arc_lat = lat + (size / 111320) * math.cos(angle_rad)
52
  coords.append((arc_lon, arc_lat))
53
 
54
- coords.append((lon, lat)) # Close the polygon
 
55
 
56
- # Create the sector polygon
57
- pol = kml.newpolygon(name=name, outerboundaryis=coords)
58
 
59
- # Dynamically create the description from all DataFrame columns
60
- description = "<b>Sector Details:</b><br>"
61
- for column, value in row.items():
62
- description += f"<b>{column}:</b> {_format_kml_value(value)}<br>"
 
 
 
 
63
 
64
- pol.description = description
 
 
65
  pol.style.polystyle.color = color # Set color from DataFrame
66
  pol.style.polystyle.outline = 1 # Outline enabled
67
  pol.style.linestyle.color = "ff000000" # Black outline
@@ -84,17 +117,36 @@ def generate_kml_from_df(df: pd.DataFrame):
84
  # Add site name as a point only once
85
  if code not in site_added:
86
  pnt = kml.newpoint(name=code, coords=[(lon, lat)])
87
- pnt.style.iconstyle.icon.href = (
88
- "http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png"
89
- )
90
  pnt.style.labelstyle.scale = 1.2 # Adjust label size
91
  pnt.description = f"Site: {code}<br>Location: {lat}, {lon}"
92
  site_added.add(code)
93
 
94
  create_sector(kml, row)
95
 
96
- kml_data = io.BytesIO()
97
- kml_str = kml.kml() # Get KML as string
98
- kml_data.write(kml_str.encode("utf-8")) # Write KML to BytesIO
99
- kml_data.seek(0) # Move to beginning of BytesIO
100
- return kml_data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  import simplekml
7
 
8
 
9
+ DEFAULT_SITE_ICON = "http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png"
10
+ DEFAULT_SITE_COLOR = "ff1f77b4"
11
+ DEFAULT_SECTOR_COLOR_RGBA = [31, 119, 180, 127]
12
+
13
+
14
  def _format_kml_value(value) -> str:
15
  """Render integer-like numeric values without a trailing .0 in KML text."""
16
  if pd.isna(value):
 
31
  return str(value)
32
 
33
 
34
+ def _kml_bytes(kml: simplekml.Kml) -> io.BytesIO:
35
+ kml_data = io.BytesIO()
36
+ kml_data.write(kml.kml().encode("utf-8"))
37
+ kml_data.seek(0)
38
+ return kml_data
39
+
40
+
41
+ def _row_description(row, title: str) -> str:
42
+ description = f"<b>{title}</b><br>"
43
+ for column, value in row.items():
44
+ description += f"<b>{column}:</b> {_format_kml_value(value)}<br>"
45
+ return description
46
+
47
+
48
+ def kml_color_to_rgba(color) -> list[int]:
49
+ """Convert KML AABBGGRR colors to pydeck/Plotly RGBA values."""
50
+ value = str(color).strip().lstrip("#")
51
+ if len(value) != 8:
52
+ return DEFAULT_SECTOR_COLOR_RGBA.copy()
53
+
54
+ try:
55
+ alpha = int(value[0:2], 16)
56
+ blue = int(value[2:4], 16)
57
+ green = int(value[4:6], 16)
58
+ red = int(value[6:8], 16)
59
+ except ValueError:
60
+ return DEFAULT_SECTOR_COLOR_RGBA.copy()
61
+
62
+ return [red, green, blue, alpha]
63
+
64
+
65
+ def sector_polygon_coordinates(row, arc_angle=65, num_points=20) -> list[tuple[float, float]]:
66
+ """Return sector polygon coordinates as (lon, lat), matching the KML geometry."""
67
+ azimuth = row["Azimut"]
68
+ lon = row["Longitude"]
69
+ lat = row["Latitude"]
70
+ size = row["size"]
71
 
 
72
  start_angle = azimuth - (arc_angle / 2)
73
  end_angle = azimuth + (arc_angle / 2)
74
+ coords = [(lon, lat)]
75
 
 
 
 
76
  for angle in np.linspace(start_angle, end_angle, num_points):
77
  angle_rad = math.radians(angle)
78
  arc_lon = lon + (size / 111320) * math.sin(angle_rad)
79
  arc_lat = lat + (size / 111320) * math.cos(angle_rad)
80
  coords.append((arc_lon, arc_lat))
81
 
82
+ coords.append((lon, lat))
83
+ return coords
84
 
 
 
85
 
86
+ def create_sector(kml: simplekml.Kml, row, arc_angle=65):
87
+ """Create a sector shape for the telecom antenna in KML with sector details."""
88
+ name, color = (
89
+ _format_kml_value(row["name"]),
90
+ row["color"],
91
+ )
92
+
93
+ coords = sector_polygon_coordinates(row, arc_angle=arc_angle)
94
 
95
+ # Create the sector polygon
96
+ pol = kml.newpolygon(name=name, outerboundaryis=coords)
97
+ pol.description = _row_description(row, "Sector Details:")
98
  pol.style.polystyle.color = color # Set color from DataFrame
99
  pol.style.polystyle.outline = 1 # Outline enabled
100
  pol.style.linestyle.color = "ff000000" # Black outline
 
117
  # Add site name as a point only once
118
  if code not in site_added:
119
  pnt = kml.newpoint(name=code, coords=[(lon, lat)])
120
+ pnt.style.iconstyle.icon.href = DEFAULT_SITE_ICON
 
 
121
  pnt.style.labelstyle.scale = 1.2 # Adjust label size
122
  pnt.description = f"Site: {code}<br>Location: {lat}, {lon}"
123
  site_added.add(code)
124
 
125
  create_sector(kml, row)
126
 
127
+ return _kml_bytes(kml)
128
+
129
+
130
+ def generate_site_kml_from_df(
131
+ df: pd.DataFrame,
132
+ site_col: str = "site",
133
+ lat_col: str = "Latitude",
134
+ lon_col: str = "Longitude",
135
+ icon_href: str = DEFAULT_SITE_ICON,
136
+ icon_color: str = DEFAULT_SITE_COLOR,
137
+ ):
138
+ """Generate a KML file from site coordinates without sector polygons."""
139
+ kml = simplekml.Kml()
140
+
141
+ for _, row in df.iterrows():
142
+ site_name = _format_kml_value(row[site_col])
143
+ lat = row[lat_col]
144
+ lon = row[lon_col]
145
+
146
+ pnt = kml.newpoint(name=site_name, coords=[(lon, lat)])
147
+ pnt.style.iconstyle.icon.href = icon_href
148
+ pnt.style.iconstyle.color = icon_color
149
+ pnt.style.labelstyle.scale = 1.1
150
+ pnt.description = _row_description(row, "Site Details:")
151
+
152
+ return _kml_bytes(kml)