add convertor and implement 100% notebook logic
Browse files- algo.ipynb +0 -0
- algorithms/PERFORMANCE_GUIDE.md +272 -0
- algorithms/README.md +6 -4
- algorithms/backend/algorithm.py +382 -13
- algorithms/backend/dxf_utils.py +164 -0
- algorithms/backend/models.py +5 -5
- algorithms/backend/requirements.txt +4 -0
- algorithms/backend/routes.py +227 -10
- algorithms/frontend/app.py +359 -38
- algorithms/frontend/requirements.txt +2 -0
algo.ipynb
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
algorithms/PERFORMANCE_GUIDE.md
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hướng Dẫn Tối Ưu Hiệu Suất
|
| 2 |
+
|
| 3 |
+
## 📊 Các Thông Số Ảnh Hưởng Đến Thời Gian Chạy
|
| 4 |
+
|
| 5 |
+
### 1. **Population Size** (Kích thước quần thể)
|
| 6 |
+
- **Phạm vi**: 20 - 200
|
| 7 |
+
- **Mặc định**: 50
|
| 8 |
+
- **Ảnh hưởng**:
|
| 9 |
+
- ⬆️ Tăng: Thuật toán khám phá nhiều giải pháp hơn → Kết quả tốt hơn nhưng **chậm hơn nhiều**
|
| 10 |
+
- ⬇️ Giảm: Ít giải pháp được thử → Nhanh nhưng có thể bỏ lỡ giải pháp tối ưu
|
| 11 |
+
- **Độ phức tạp**: `O(population_size × generations)`
|
| 12 |
+
|
| 13 |
+
### 2. **Generations** (Số thế hệ)
|
| 14 |
+
- **Phạm vi**: 50 - 500
|
| 15 |
+
- **Mặc định**: 50
|
| 16 |
+
- **Ảnh hưởng**:
|
| 17 |
+
- ⬆️ Tăng: Thuật toán tiến hóa lâu hơn → Hội tụ tốt hơn nhưng **tốn nhiều thời gian**
|
| 18 |
+
- ⬇️ Giảm: Kết thúc sớm → Nhanh nhưng có thể chưa tối ưu
|
| 19 |
+
- **Độ phức tạp**: `O(population_size × generations)`
|
| 20 |
+
|
| 21 |
+
### 3. **OR-Tools Time/Block** (Thời gian tối ưu mỗi block)
|
| 22 |
+
- **Phạm vi**: 0.1 - 60.0 giây
|
| 23 |
+
- **Mặc định**: 5.0 giây
|
| 24 |
+
- **Ảnh hưởng**:
|
| 25 |
+
- ⬆️ Tăng: OR-Tools có nhiều thời gian tìm giải pháp tốt hơn cho mỗi block
|
| 26 |
+
- ⬇️ Giảm: OR-Tools dừng sớm, có thể chưa tối ưu
|
| 27 |
+
- **Lưu ý**: Thời gian này được nhân với số lượng blocks
|
| 28 |
+
|
| 29 |
+
### 4. **Kích Thước Đất** (Gián tiếp)
|
| 30 |
+
- Đất lớn → Nhiều blocks → Tổng thời gian tăng tuyến tính
|
| 31 |
+
- Công thức ước lượng số blocks: `(Area / (spacing²))`
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
|
| 35 |
+
## ⚡ Các Preset Cấu Hình Được Khuyến Nghị
|
| 36 |
+
|
| 37 |
+
### 🚀 **Kết Quả Nhanh Nhất** (Test/Preview)
|
| 38 |
+
**Thời gian ước tính**: 30 giây - 2 phút
|
| 39 |
+
|
| 40 |
+
```
|
| 41 |
+
Population Size: 20
|
| 42 |
+
Generations: 50
|
| 43 |
+
OR-Tools Time/Block: 0.5s
|
| 44 |
+
Spacing Min/Max: 25-35m
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
**Khi nào dùng**:
|
| 48 |
+
- ✅ Test nhanh với đất mới
|
| 49 |
+
- ✅ Xem trước kết quả sơ bộ
|
| 50 |
+
- ✅ Điều chỉnh parameters
|
| 51 |
+
- ❌ **KHÔNG** dùng cho kết quả cuối cùng
|
| 52 |
+
|
| 53 |
+
**Trade-offs**:
|
| 54 |
+
- ✅ Cực kỳ nhanh
|
| 55 |
+
- ⚠️ Chất lượng thấp
|
| 56 |
+
- ⚠️ Có thể không tìm được giải pháp tốt
|
| 57 |
+
|
| 58 |
+
---
|
| 59 |
+
|
| 60 |
+
### ⚖️ **Kết Quả Cân Bằng** (Recommended)
|
| 61 |
+
**Thời gian ước tính**: 3-8 phút
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
Population Size: 50
|
| 65 |
+
Generations: 50-75
|
| 66 |
+
OR-Tools Time/Block: 3.0-5.0s
|
| 67 |
+
Spacing Min/Max: 20-30m
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
**Khi nào dùng**:
|
| 71 |
+
- ✅ **Sử dụng hàng ngày** (khuyến nghị)
|
| 72 |
+
- ✅ Đất có kích thước trung bình (< 5 ha)
|
| 73 |
+
- ✅ Cần kết quả tốt trong thời gian chấp nhận được
|
| 74 |
+
- ✅ Đủ tốt cho hầu hết các trường hợp
|
| 75 |
+
|
| 76 |
+
**Trade-offs**:
|
| 77 |
+
- ✅ Cân bằng giữa tốc độ và chất lượng
|
| 78 |
+
- ✅ Kết quả đủ tốt (80-90% tối ưu)
|
| 79 |
+
- ✅ Thời gian chấp nhận được
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
### 🏆 **Kết Quả Tốt Nhất** (Production Quality)
|
| 84 |
+
**Thời gian ước tính**: 10-30 phút
|
| 85 |
+
|
| 86 |
+
```
|
| 87 |
+
Population Size: 100-150
|
| 88 |
+
Generations: 100-150
|
| 89 |
+
OR-Tools Time/Block: 10.0-15.0s
|
| 90 |
+
Spacing Min/Max: 20-30m
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
**Khi nào dùng**:
|
| 94 |
+
- ✅ Dự án thực tế quan trọng
|
| 95 |
+
- ✅ Cần kết quả tối ưu nhất có thể
|
| 96 |
+
- ✅ Có thời gian chờ đợi
|
| 97 |
+
- ✅ Đất lớn, phức tạp
|
| 98 |
+
|
| 99 |
+
**Trade-offs**:
|
| 100 |
+
- ✅ Chất lượng cao nhất (95-99% tối ưu)
|
| 101 |
+
- ✅ Khám phá nhiều giải pháp
|
| 102 |
+
- ⚠️ Tốn thời gian
|
| 103 |
+
- ⚠️ Có thể timeout nếu đất quá lớn
|
| 104 |
+
|
| 105 |
+
---
|
| 106 |
+
|
| 107 |
+
### 🔥 **Aggressive Optimization** (Maximum Quality)
|
| 108 |
+
**Thời gian ước tính**: 30-60+ phút
|
| 109 |
+
|
| 110 |
+
```
|
| 111 |
+
Population Size: 200
|
| 112 |
+
Generations: 200-300
|
| 113 |
+
OR-Tools Time/Block: 20.0-30.0s
|
| 114 |
+
Spacing Min/Max: 20-30m
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
**Khi nào dùng**:
|
| 118 |
+
- ✅ Dự án cực kỳ quan trọng
|
| 119 |
+
- ✅ Muốn kết quả **tốt nhất tuyệt đối**
|
| 120 |
+
- ✅ Có thể để chạy qua đêm
|
| 121 |
+
- ⚠️ Chỉ với đất nhỏ/trung bình
|
| 122 |
+
|
| 123 |
+
**Trade-offs**:
|
| 124 |
+
- ✅ Gần như tối ưu toàn cục
|
| 125 |
+
- ⚠️ Rất chậm
|
| 126 |
+
- ⚠️ Diminishing returns (cải thiện ít so với thời gian tăng)
|
| 127 |
+
|
| 128 |
+
---
|
| 129 |
+
|
| 130 |
+
## 📈 Bảng So Sánh Nhanh
|
| 131 |
+
|
| 132 |
+
| Preset | Population | Generations | OR-Tools Time | Thời gian | Chất lượng | Use Case |
|
| 133 |
+
|--------|------------|-------------|---------------|-----------|------------|----------|
|
| 134 |
+
| 🚀 Fastest | 20 | 50 | 0.5s | 0.5-2 min | ⭐⭐ | Test/Preview |
|
| 135 |
+
| ⚖️ Balanced | 50 | 50-75 | 3-5s | 3-8 min | ⭐⭐⭐⭐ | **Recommended** |
|
| 136 |
+
| 🏆 Best | 100-150 | 100-150 | 10-15s | 10-30 min | ⭐⭐⭐⭐⭐ | Production |
|
| 137 |
+
| 🔥 Maximum | 200 | 200-300 | 20-30s | 30-60+ min | ⭐⭐⭐⭐⭐ | Critical Projects |
|
| 138 |
+
|
| 139 |
+
---
|
| 140 |
+
|
| 141 |
+
## 💡 Mẹo Tối Ưu
|
| 142 |
+
|
| 143 |
+
### 1. **Điều chỉnh theo kích thước đất**
|
| 144 |
+
|
| 145 |
+
```
|
| 146 |
+
Đất nhỏ (< 1 ha):
|
| 147 |
+
→ Dùng preset "Best" hoặc "Maximum"
|
| 148 |
+
→ Thời gian chấp nhận được
|
| 149 |
+
|
| 150 |
+
Đất trung bình (1-5 ha):
|
| 151 |
+
→ Dùng preset "Balanced"
|
| 152 |
+
→ Tăng Generations lên 100 nếu cần
|
| 153 |
+
|
| 154 |
+
Đất lớn (> 5 ha):
|
| 155 |
+
→ Dùng preset "Fastest" hoặc "Balanced"
|
| 156 |
+
→ KHÔNG dùng "Maximum" (sẽ quá chậm)
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
### 2. **Tăng dần theo bước**
|
| 160 |
+
|
| 161 |
+
Thay vì nhảy thẳng lên "Maximum", hãy:
|
| 162 |
+
1. Chạy "Fastest" để xem kết quả sơ bộ
|
| 163 |
+
2. Chạy "Balanced" để có kết quả tốt
|
| 164 |
+
3. Nếu cần, chạy "Best" vào cuối
|
| 165 |
+
|
| 166 |
+
### 3. **OR-Tools Time Strategy**
|
| 167 |
+
|
| 168 |
+
```
|
| 169 |
+
Block đơn giản (hình chữ nhật):
|
| 170 |
+
→ 0.5-2.0s là đủ
|
| 171 |
+
|
| 172 |
+
Block phức tạp (hình bất quy tắc):
|
| 173 |
+
→ 5.0-10.0s
|
| 174 |
+
|
| 175 |
+
Block cực kỳ phức tạp:
|
| 176 |
+
→ 15.0-30.0s
|
| 177 |
+
```
|
| 178 |
+
|
| 179 |
+
### 4. **Parallel Testing (Nếu có nhiều máy)**
|
| 180 |
+
|
| 181 |
+
Chạy song song nhiều cấu hình:
|
| 182 |
+
- Machine 1: Balanced (50/75/5s)
|
| 183 |
+
- Machine 2: Best (100/100/10s)
|
| 184 |
+
- Machine 3: Maximum (200/200/20s)
|
| 185 |
+
|
| 186 |
+
→ Chọn kết quả tốt nhất
|
| 187 |
+
|
| 188 |
+
---
|
| 189 |
+
|
| 190 |
+
## 🎯 Công Thức Ước Lượng Thời Gian
|
| 191 |
+
|
| 192 |
+
```python
|
| 193 |
+
# Rough estimate (seconds)
|
| 194 |
+
time_estimate = (population_size × generations × 0.5) + (num_blocks × ortools_time)
|
| 195 |
+
|
| 196 |
+
# Where:
|
| 197 |
+
num_blocks ≈ land_area / (spacing_avg²)
|
| 198 |
+
|
| 199 |
+
# Example: Đất 10,000 m², spacing 25m, pop=50, gen=75, ort=5s
|
| 200 |
+
num_blocks ≈ 10000 / (25²) = 16 blocks
|
| 201 |
+
time_estimate = (50 × 75 × 0.5) + (16 × 5) = 1875 + 80 = ~1955s ≈ 33 phút
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## ⚠️ Lưu Ý Quan Trọng
|
| 207 |
+
|
| 208 |
+
1. **Timeout 10 phút**:
|
| 209 |
+
- Frontend có timeout 600s (10 phút)
|
| 210 |
+
- Nếu cần chạy lâu hơn, tăng timeout trong `app.py`
|
| 211 |
+
|
| 212 |
+
2. **Diminishing Returns**:
|
| 213 |
+
- Tăng từ 50 → 100 generations: Cải thiện ~15-20%
|
| 214 |
+
- Tăng từ 100 → 200 generations: Cải thiện ~5-10%
|
| 215 |
+
- Tăng từ 200 → 500 generations: Cải thiện ~1-5%
|
| 216 |
+
|
| 217 |
+
3. **Memory Usage**:
|
| 218 |
+
- Population lớn (>150) có thể tốn nhiều RAM
|
| 219 |
+
- Đất rất lớn với population cao: risk of OOM
|
| 220 |
+
|
| 221 |
+
4. **Stage 1 vs Stage 2**:
|
| 222 |
+
- Stage 1 (NSGA-II): Chi phối thời gian với nhiều generations
|
| 223 |
+
- Stage 2 (OR-Tools): Chi phối với đất lớn (nhiều blocks)
|
| 224 |
+
|
| 225 |
+
---
|
| 226 |
+
|
| 227 |
+
## 🔧 Troubleshooting
|
| 228 |
+
|
| 229 |
+
### Timeout sau 10 phút?
|
| 230 |
+
→ Giảm Population hoặc Generations
|
| 231 |
+
→ Hoặc tăng timeout trong code
|
| 232 |
+
|
| 233 |
+
### Kết quả chưa tốt?
|
| 234 |
+
→ Tăng Generations (cheaper than population)
|
| 235 |
+
→ Tăng OR-Tools time/block
|
| 236 |
+
|
| 237 |
+
### Muốn nhanh hơn nữa?
|
| 238 |
+
→ Tăng Spacing Min/Max (ít blocks hơn)
|
| 239 |
+
→ Giảm OR-Tools time xuống 0.5-1.0s
|
| 240 |
+
|
| 241 |
+
### Đất rất lớn?
|
| 242 |
+
→ Chia thành nhiều phần nhỏ
|
| 243 |
+
→ Hoặc tăng spacing để giảm số blocks
|
| 244 |
+
|
| 245 |
+
---
|
| 246 |
+
|
| 247 |
+
## 📝 Recommended Workflow
|
| 248 |
+
|
| 249 |
+
```
|
| 250 |
+
1. Start: Chạy FASTEST preset
|
| 251 |
+
→ Xác nhận input đúng
|
| 252 |
+
→ Xem kết quả sơ bộ
|
| 253 |
+
|
| 254 |
+
2. Iterate: Chạy BALANCED preset
|
| 255 |
+
→ Điều chỉnh spacing, lot width
|
| 256 |
+
→ Xem kết quả có chấp nhận được không
|
| 257 |
+
|
| 258 |
+
3. Finalize: Chạy BEST preset
|
| 259 |
+
→ Với parameters đã điều chỉnh
|
| 260 |
+
→ Export DXF cho production
|
| 261 |
+
|
| 262 |
+
4. Optional: Chạy MAXIMUM nếu cực kỳ cần thiết
|
| 263 |
+
```
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 🎓 Kết Luận
|
| 268 |
+
|
| 269 |
+
**TL;DR - Quick Answer:**
|
| 270 |
+
- **Test nhanh**: Pop=20, Gen=50, ORT=0.5s
|
| 271 |
+
- **Khuyến nghị**: Pop=50, Gen=75, ORT=5s ⭐
|
| 272 |
+
- **Tốt nhất**: Pop=150, Gen=150, ORT=15s
|
algorithms/README.md
CHANGED
|
@@ -5,10 +5,12 @@ Test land subdivision and redistribution algorithms with FastAPI backend and Str
|
|
| 5 |
## Features
|
| 6 |
|
| 7 |
- **Algorithm Implementation**: 100% equivalent logic from `algo.ipynb`
|
| 8 |
-
- Stage 1: Grid
|
| 9 |
-
|
| 10 |
-
- **
|
| 11 |
-
- **
|
|
|
|
|
|
|
| 12 |
- **No Database**: In-memory processing for algorithm testing
|
| 13 |
|
| 14 |
## Project Structure
|
|
|
|
| 5 |
## Features
|
| 6 |
|
| 7 |
- **Algorithm Implementation**: 100% equivalent logic from `algo.ipynb`
|
| 8 |
+
- **Stage 1: Grid Optimization**: Uses NSGA-II (Genetic Algorithm) to find the optimal grid orientation and spacing.
|
| 9 |
+
- **Stage 2: Subdivision**: Uses OR-Tools (Constraint Programming) to subdivide blocks into individual lots, optimizing for target dimensions.
|
| 10 |
+
- **Stage 3: Infrastructure**: Generates technical networks (electricity/water MST) and drainage plans.
|
| 11 |
+
- **Zoning**: Automatically classifies lands into Residential, Service (Operations/Parking), and Wastewater Treatment (XLNT).
|
| 12 |
+
- **Visualization**: Interactive maps (Folium) and static notebook-style architectural plots (Matplotlib).
|
| 13 |
+
- **DXF Support**: Import site boundaries from DXF files and export results.ing and visualization
|
| 14 |
- **No Database**: In-memory processing for algorithm testing
|
| 15 |
|
| 16 |
## Project Structure
|
algorithms/backend/algorithm.py
CHANGED
|
@@ -9,10 +9,15 @@ This module contains the exact algorithm logic from algo.ipynb for:
|
|
| 9 |
import random
|
| 10 |
import numpy as np
|
| 11 |
from typing import List, Tuple, Dict, Any
|
| 12 |
-
from shapely.geometry import Polygon, Point, LineString, MultiPolygon
|
| 13 |
from shapely.affinity import translate, rotate
|
| 14 |
from deap import base, creator, tools, algorithms
|
| 15 |
from ortools.sat.python import cp_model
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
class GridOptimizer:
|
|
@@ -184,7 +189,7 @@ class SubdivisionSolver:
|
|
| 184 |
|
| 185 |
@staticmethod
|
| 186 |
def solve_subdivision(total_length: float, min_width: float, max_width: float,
|
| 187 |
-
target_width: float, time_limit:
|
| 188 |
"""
|
| 189 |
Solve optimal lot widths using constraint programming.
|
| 190 |
|
|
@@ -198,6 +203,10 @@ class SubdivisionSolver:
|
|
| 198 |
Returns:
|
| 199 |
List of lot widths
|
| 200 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
model = cp_model.CpModel()
|
| 202 |
|
| 203 |
# Estimate number of lots
|
|
@@ -253,7 +262,7 @@ class SubdivisionSolver:
|
|
| 253 |
|
| 254 |
@staticmethod
|
| 255 |
def subdivide_block(block_geom: Polygon, spacing: float, min_width: float,
|
| 256 |
-
max_width: float, target_width: float, time_limit:
|
| 257 |
"""
|
| 258 |
Subdivide a block into lots.
|
| 259 |
|
|
@@ -263,6 +272,7 @@ class SubdivisionSolver:
|
|
| 263 |
min_width: Minimum lot width
|
| 264 |
max_width: Maximum lot width
|
| 265 |
target_width: Target lot width
|
|
|
|
| 266 |
|
| 267 |
Returns:
|
| 268 |
Dictionary with subdivision info
|
|
@@ -294,8 +304,10 @@ class SubdivisionSolver:
|
|
| 294 |
total_width, min_width, max_width, target_width, time_limit
|
| 295 |
)
|
| 296 |
|
| 297 |
-
# Create lot geometries
|
| 298 |
current_x = minx
|
|
|
|
|
|
|
| 299 |
for width in lot_widths:
|
| 300 |
lot_poly = Polygon([
|
| 301 |
(current_x, miny),
|
|
@@ -306,15 +318,131 @@ class SubdivisionSolver:
|
|
| 306 |
# Clip to block
|
| 307 |
clipped = lot_poly.intersection(block_geom)
|
| 308 |
if not clipped.is_empty:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
result['lots'].append({
|
| 310 |
'geometry': clipped,
|
| 311 |
-
'width': width
|
|
|
|
| 312 |
})
|
| 313 |
current_x += width
|
| 314 |
|
| 315 |
return result
|
| 316 |
|
| 317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
class LandRedistributionPipeline:
|
| 319 |
"""Main pipeline orchestrating all optimization stages."""
|
| 320 |
|
|
@@ -331,6 +459,136 @@ class LandRedistributionPipeline:
|
|
| 331 |
self.land_poly = unary_union(land_polygons)
|
| 332 |
self.config = config
|
| 333 |
self.lake_poly = Polygon() # No lake by default
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
def run_stage1(self) -> Dict[str, Any]:
|
| 336 |
"""Run grid optimization stage."""
|
|
@@ -393,19 +651,130 @@ class LandRedistributionPipeline:
|
|
| 393 |
}
|
| 394 |
}
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
def run_full_pipeline(self) -> Dict[str, Any]:
|
| 397 |
-
"""Run complete optimization pipeline."""
|
| 398 |
-
# Stage
|
| 399 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
|
| 401 |
-
# Stage 2: Subdivision
|
| 402 |
stage2_result = self.run_stage2(
|
| 403 |
-
|
| 404 |
-
|
| 405 |
)
|
| 406 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
return {
|
| 408 |
-
'stage1':
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
'stage2': stage2_result,
|
| 410 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
}
|
|
|
|
| 9 |
import random
|
| 10 |
import numpy as np
|
| 11 |
from typing import List, Tuple, Dict, Any
|
| 12 |
+
from shapely.geometry import Polygon, Point, LineString, MultiPolygon, MultiPoint, mapping
|
| 13 |
from shapely.affinity import translate, rotate
|
| 14 |
from deap import base, creator, tools, algorithms
|
| 15 |
from ortools.sat.python import cp_model
|
| 16 |
+
import networkx as nx
|
| 17 |
+
from scipy.spatial.distance import pdist, squareform
|
| 18 |
+
from scipy.sparse.csgraph import minimum_spanning_tree
|
| 19 |
+
from sklearn.cluster import KMeans
|
| 20 |
+
from shapely.ops import unary_union, voronoi_diagram
|
| 21 |
|
| 22 |
|
| 23 |
class GridOptimizer:
|
|
|
|
| 189 |
|
| 190 |
@staticmethod
|
| 191 |
def solve_subdivision(total_length: float, min_width: float, max_width: float,
|
| 192 |
+
target_width: float, time_limit: float = 5.0) -> List[float]:
|
| 193 |
"""
|
| 194 |
Solve optimal lot widths using constraint programming.
|
| 195 |
|
|
|
|
| 203 |
Returns:
|
| 204 |
List of lot widths
|
| 205 |
"""
|
| 206 |
+
# Safety check: prevent division by zero
|
| 207 |
+
if total_length <= 0 or min_width <= 0 or total_length < min_width:
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
model = cp_model.CpModel()
|
| 211 |
|
| 212 |
# Estimate number of lots
|
|
|
|
| 262 |
|
| 263 |
@staticmethod
|
| 264 |
def subdivide_block(block_geom: Polygon, spacing: float, min_width: float,
|
| 265 |
+
max_width: float, target_width: float, time_limit: float = 5.0) -> Dict[str, Any]:
|
| 266 |
"""
|
| 267 |
Subdivide a block into lots.
|
| 268 |
|
|
|
|
| 272 |
min_width: Minimum lot width
|
| 273 |
max_width: Maximum lot width
|
| 274 |
target_width: Target lot width
|
| 275 |
+
setback: Setback distance in meters (default 6.0)
|
| 276 |
|
| 277 |
Returns:
|
| 278 |
Dictionary with subdivision info
|
|
|
|
| 304 |
total_width, min_width, max_width, target_width, time_limit
|
| 305 |
)
|
| 306 |
|
| 307 |
+
# Create lot geometries
|
| 308 |
current_x = minx
|
| 309 |
+
setback_dist = 6.0 # Default setback
|
| 310 |
+
|
| 311 |
for width in lot_widths:
|
| 312 |
lot_poly = Polygon([
|
| 313 |
(current_x, miny),
|
|
|
|
| 318 |
# Clip to block
|
| 319 |
clipped = lot_poly.intersection(block_geom)
|
| 320 |
if not clipped.is_empty:
|
| 321 |
+
# Calculate setback (buildable area)
|
| 322 |
+
buildable = clipped.buffer(-setback_dist)
|
| 323 |
+
if buildable.is_empty:
|
| 324 |
+
buildable = None
|
| 325 |
+
|
| 326 |
result['lots'].append({
|
| 327 |
'geometry': clipped,
|
| 328 |
+
'width': width,
|
| 329 |
+
'buildable': buildable
|
| 330 |
})
|
| 331 |
current_x += width
|
| 332 |
|
| 333 |
return result
|
| 334 |
|
| 335 |
|
| 336 |
+
class InfrastructurePlanner:
|
| 337 |
+
"""Stage 3: Plan infrastructure network."""
|
| 338 |
+
|
| 339 |
+
@staticmethod
|
| 340 |
+
def get_elevation(x: float, y: float) -> float:
|
| 341 |
+
"""Simulate elevation (sloping from NW to SE)."""
|
| 342 |
+
return 50.0 - (x * 0.02) - (y * 0.03)
|
| 343 |
+
|
| 344 |
+
def generate_network(lots: List[Polygon]) -> Tuple[List[Tuple[float, float]], List[LineString]]:
|
| 345 |
+
"""
|
| 346 |
+
Generate Loop Network for electrical infrastructure (MST + 15% redundancy).
|
| 347 |
+
Matches notebook's create_loop_network function.
|
| 348 |
+
|
| 349 |
+
Args:
|
| 350 |
+
lots: List of lot polygons
|
| 351 |
+
|
| 352 |
+
Returns:
|
| 353 |
+
(points, connection_lines)
|
| 354 |
+
"""
|
| 355 |
+
if len(lots) < 2:
|
| 356 |
+
return [], []
|
| 357 |
+
|
| 358 |
+
centroids = [lot.centroid for lot in lots]
|
| 359 |
+
points = np.array([(p.x, p.y) for p in centroids])
|
| 360 |
+
|
| 361 |
+
# 1. Create full graph with all nearby connections
|
| 362 |
+
G = nx.Graph()
|
| 363 |
+
for i, p in enumerate(centroids):
|
| 364 |
+
G.add_node(i, pos=(p.x, p.y))
|
| 365 |
+
|
| 366 |
+
# Add edges for all pairs within 500m
|
| 367 |
+
for i in range(len(centroids)):
|
| 368 |
+
for j in range(i+1, len(centroids)):
|
| 369 |
+
dist = centroids[i].distance(centroids[j])
|
| 370 |
+
if dist < 500:
|
| 371 |
+
G.add_edge(i, j, weight=dist)
|
| 372 |
+
|
| 373 |
+
# 2. Create MST (Minimum Spanning Tree)
|
| 374 |
+
if not nx.is_connected(G):
|
| 375 |
+
# Handle disconnected graph - use largest component
|
| 376 |
+
components = list(nx.connected_components(G))
|
| 377 |
+
largest_comp = max(components, key=len)
|
| 378 |
+
subgraph = G.subgraph(largest_comp).copy()
|
| 379 |
+
mst = nx.minimum_spanning_tree(subgraph)
|
| 380 |
+
else:
|
| 381 |
+
mst = nx.minimum_spanning_tree(G)
|
| 382 |
+
|
| 383 |
+
# 3. CREATE LOOP: Add back 15% of edges for redundancy (safety)
|
| 384 |
+
all_edges = sorted(G.edges(data=True), key=lambda x: x[2]['weight'])
|
| 385 |
+
loop_graph = mst.copy()
|
| 386 |
+
|
| 387 |
+
added_count = 0
|
| 388 |
+
target_extra = int(len(lots) * 0.15) # 15% extra edges
|
| 389 |
+
|
| 390 |
+
for u, v, data in all_edges:
|
| 391 |
+
if not loop_graph.has_edge(u, v):
|
| 392 |
+
loop_graph.add_edge(u, v, **data)
|
| 393 |
+
added_count += 1
|
| 394 |
+
if added_count >= target_extra:
|
| 395 |
+
break
|
| 396 |
+
|
| 397 |
+
# Convert NetworkX graph to LineString list
|
| 398 |
+
connections = []
|
| 399 |
+
for u, v in loop_graph.edges():
|
| 400 |
+
connections.append(LineString([centroids[u], centroids[v]]))
|
| 401 |
+
|
| 402 |
+
return points.tolist(), connections
|
| 403 |
+
|
| 404 |
+
@staticmethod
|
| 405 |
+
def generate_transformers(lots: List[Polygon], radius: float = 300.0) -> List[Tuple[float, float]]:
|
| 406 |
+
"""
|
| 407 |
+
Cluster lots to place transformers using K-Means.
|
| 408 |
+
"""
|
| 409 |
+
if not lots:
|
| 410 |
+
return []
|
| 411 |
+
|
| 412 |
+
lot_coords = np.array([(lot.centroid.x, lot.centroid.y) for lot in lots])
|
| 413 |
+
|
| 414 |
+
# Estimate number of transformers (approx 1 per 15 lots)
|
| 415 |
+
num_transformers = max(1, int(len(lots) / 15))
|
| 416 |
+
|
| 417 |
+
if len(lots) < num_transformers:
|
| 418 |
+
num_transformers = len(lots)
|
| 419 |
+
|
| 420 |
+
kmeans = KMeans(n_clusters=num_transformers, n_init=10).fit(lot_coords)
|
| 421 |
+
return kmeans.cluster_centers_.tolist()
|
| 422 |
+
|
| 423 |
+
@staticmethod
|
| 424 |
+
def calculate_drainage(lots: List[Polygon], wwtp_centroid: Point) -> List[Dict[str, Any]]:
|
| 425 |
+
"""
|
| 426 |
+
Calculate drainage flow direction towards Wastewater Treatment Plant (XLNT).
|
| 427 |
+
"""
|
| 428 |
+
arrows = []
|
| 429 |
+
if not wwtp_centroid:
|
| 430 |
+
return arrows
|
| 431 |
+
|
| 432 |
+
for lot in lots:
|
| 433 |
+
c = lot.centroid
|
| 434 |
+
dx = wwtp_centroid.x - c.x
|
| 435 |
+
dy = wwtp_centroid.y - c.y
|
| 436 |
+
length = (dx**2 + dy**2)**0.5
|
| 437 |
+
|
| 438 |
+
if length > 0:
|
| 439 |
+
# Normalize vector to 30m arrow length
|
| 440 |
+
arrows.append({
|
| 441 |
+
'start': (c.x, c.y),
|
| 442 |
+
'vector': (dx/length * 30, dy/length * 30)
|
| 443 |
+
})
|
| 444 |
+
return arrows
|
| 445 |
+
|
| 446 |
class LandRedistributionPipeline:
|
| 447 |
"""Main pipeline orchestrating all optimization stages."""
|
| 448 |
|
|
|
|
| 459 |
self.land_poly = unary_union(land_polygons)
|
| 460 |
self.config = config
|
| 461 |
self.lake_poly = Polygon() # No lake by default
|
| 462 |
+
|
| 463 |
+
def generate_road_network(self, num_seeds: int = 15) -> Tuple[Polygon, List[Polygon], List[Polygon]]:
|
| 464 |
+
"""
|
| 465 |
+
Generate road network using Voronoi diagram (matches notebook's generate_road_network).
|
| 466 |
+
|
| 467 |
+
Args:
|
| 468 |
+
num_seeds: Number of Voronoi seed points
|
| 469 |
+
|
| 470 |
+
Returns:
|
| 471 |
+
(road_network, service_blocks, commercial_blocks)
|
| 472 |
+
"""
|
| 473 |
+
# Constants from notebook
|
| 474 |
+
ROAD_MAIN_WIDTH = 25.0 # Main road width (m)
|
| 475 |
+
ROAD_INTERNAL_WIDTH = 15.0 # Internal road width (m)
|
| 476 |
+
SIDEWALK_WIDTH = 4.0 # Sidewalk width each side (m)
|
| 477 |
+
TURNING_RADIUS = 15.0 # Turning radius for intersections (m)
|
| 478 |
+
SERVICE_AREA_RATIO = 0.10 # 10% for service areas
|
| 479 |
+
MIN_BLOCK_AREA = 5000 # Minimum block area (m2)
|
| 480 |
+
|
| 481 |
+
site = self.land_poly
|
| 482 |
+
minx, miny, maxx, maxy = site.bounds
|
| 483 |
+
|
| 484 |
+
# 1. Generate random Voronoi seeds
|
| 485 |
+
seeds = []
|
| 486 |
+
for _ in range(num_seeds):
|
| 487 |
+
seeds.append(Point(random.uniform(minx, maxx), random.uniform(miny, maxy)))
|
| 488 |
+
|
| 489 |
+
# 2. Create Voronoi diagram
|
| 490 |
+
try:
|
| 491 |
+
regions = voronoi_diagram(MultiPoint(seeds), envelope=site)
|
| 492 |
+
except:
|
| 493 |
+
# Fallback if Voronoi fails
|
| 494 |
+
return Polygon(), [], [site]
|
| 495 |
+
|
| 496 |
+
# 3. Extract edges from Voronoi regions
|
| 497 |
+
edges = []
|
| 498 |
+
if hasattr(regions, 'geoms'):
|
| 499 |
+
for region in regions.geoms:
|
| 500 |
+
if region.geom_type == 'Polygon':
|
| 501 |
+
edges.append(region.exterior)
|
| 502 |
+
elif regions.geom_type == 'Polygon':
|
| 503 |
+
edges.append(regions.exterior)
|
| 504 |
+
|
| 505 |
+
# 4. Classify roads and create buffers
|
| 506 |
+
center = site.centroid
|
| 507 |
+
road_polys = []
|
| 508 |
+
|
| 509 |
+
all_lines = []
|
| 510 |
+
for geom in edges:
|
| 511 |
+
all_lines.append(geom)
|
| 512 |
+
|
| 513 |
+
merged_lines = unary_union(all_lines)
|
| 514 |
+
|
| 515 |
+
# Normalize to list of LineStrings
|
| 516 |
+
lines_to_process = []
|
| 517 |
+
if hasattr(merged_lines, 'geoms'):
|
| 518 |
+
lines_to_process = list(merged_lines.geoms)
|
| 519 |
+
else:
|
| 520 |
+
lines_to_process = [merged_lines]
|
| 521 |
+
|
| 522 |
+
for line in lines_to_process:
|
| 523 |
+
if line.geom_type != 'LineString':
|
| 524 |
+
continue
|
| 525 |
+
|
| 526 |
+
# Heuristic: roads near center or very long = main roads
|
| 527 |
+
dist_to_center = line.distance(center)
|
| 528 |
+
if dist_to_center < 100 or line.length > 400:
|
| 529 |
+
# Main road: wider + sidewalks
|
| 530 |
+
width = ROAD_MAIN_WIDTH + 2 * SIDEWALK_WIDTH
|
| 531 |
+
road_polys.append(line.buffer(width / 2, cap_style=2, join_style=2))
|
| 532 |
+
else:
|
| 533 |
+
# Internal road: narrower
|
| 534 |
+
width = ROAD_INTERNAL_WIDTH + 2 * SIDEWALK_WIDTH
|
| 535 |
+
road_polys.append(line.buffer(width / 2, cap_style=2, join_style=2))
|
| 536 |
+
|
| 537 |
+
if not road_polys:
|
| 538 |
+
# No roads generated - fallback
|
| 539 |
+
return Polygon(), [], [site]
|
| 540 |
+
|
| 541 |
+
network_poly = unary_union(road_polys)
|
| 542 |
+
|
| 543 |
+
# 5. Apply turning radius smoothing (vạt góc)
|
| 544 |
+
smooth_network = network_poly.buffer(TURNING_RADIUS, join_style=1).buffer(-TURNING_RADIUS, join_style=1)
|
| 545 |
+
|
| 546 |
+
# 6. Extract blocks (land minus roads)
|
| 547 |
+
blocks_rough = site.difference(smooth_network)
|
| 548 |
+
|
| 549 |
+
service_blocks = []
|
| 550 |
+
commercial_blocks = []
|
| 551 |
+
|
| 552 |
+
# Normalize blocks list
|
| 553 |
+
candidates = []
|
| 554 |
+
if hasattr(blocks_rough, 'geoms'):
|
| 555 |
+
candidates = list(blocks_rough.geoms)
|
| 556 |
+
else:
|
| 557 |
+
candidates = [blocks_rough]
|
| 558 |
+
|
| 559 |
+
# Filter by minimum area
|
| 560 |
+
valid_blocks = [b for b in candidates if b.geom_type == 'Polygon' and b.area >= MIN_BLOCK_AREA]
|
| 561 |
+
|
| 562 |
+
if not valid_blocks:
|
| 563 |
+
return smooth_network, [], []
|
| 564 |
+
|
| 565 |
+
# 7. Sort by elevation to find XLNT (lowest)
|
| 566 |
+
blocks_with_elev = [(b, InfrastructurePlanner.get_elevation(b.centroid.x, b.centroid.y)) for b in valid_blocks]
|
| 567 |
+
blocks_with_elev.sort(key=lambda x: x[1])
|
| 568 |
+
|
| 569 |
+
# 8. Allocate service areas (10% of total)
|
| 570 |
+
total_area = sum(b.area for b in valid_blocks)
|
| 571 |
+
|
| 572 |
+
# Safety check: prevent division issues
|
| 573 |
+
if total_area <= 0 or len(valid_blocks) == 0:
|
| 574 |
+
return smooth_network, [], valid_blocks
|
| 575 |
+
|
| 576 |
+
service_area_needed = total_area * SERVICE_AREA_RATIO
|
| 577 |
+
|
| 578 |
+
accumulated_service_area = 0
|
| 579 |
+
for block, elev in blocks_with_elev:
|
| 580 |
+
if accumulated_service_area < service_area_needed:
|
| 581 |
+
service_blocks.append(block)
|
| 582 |
+
accumulated_service_area += block.area
|
| 583 |
+
else:
|
| 584 |
+
commercial_blocks.append(block)
|
| 585 |
+
|
| 586 |
+
# Ensure at least one commercial block exists
|
| 587 |
+
if not commercial_blocks and service_blocks:
|
| 588 |
+
# Move one service block to commercial
|
| 589 |
+
commercial_blocks.append(service_blocks.pop())
|
| 590 |
+
|
| 591 |
+
return smooth_network, service_blocks, commercial_blocks
|
| 592 |
|
| 593 |
def run_stage1(self) -> Dict[str, Any]:
|
| 594 |
"""Run grid optimization stage."""
|
|
|
|
| 651 |
}
|
| 652 |
}
|
| 653 |
|
| 654 |
+
def classify_blocks(self, blocks: List[Polygon]) -> Dict[str, List[Polygon]]:
|
| 655 |
+
"""
|
| 656 |
+
Classify blocks into Service (XLNT, Operations) and Commercial.
|
| 657 |
+
Logic:
|
| 658 |
+
- Sort by elevation (lowest -> XLNT)
|
| 659 |
+
- Reserve 10% for Service/Parking
|
| 660 |
+
- Rest -> Commercial (Residential/Industrial)
|
| 661 |
+
"""
|
| 662 |
+
if not blocks:
|
| 663 |
+
return {'service': [], 'commercial': [], 'xlnt': []}
|
| 664 |
+
|
| 665 |
+
# Sort by elevation
|
| 666 |
+
sorted_blocks = sorted(blocks, key=lambda b: InfrastructurePlanner.get_elevation(b.centroid.x, b.centroid.y))
|
| 667 |
+
|
| 668 |
+
total_area = sum(b.area for b in blocks)
|
| 669 |
+
service_area_target = total_area * 0.10
|
| 670 |
+
current_service_area = 0
|
| 671 |
+
|
| 672 |
+
service_blocks = []
|
| 673 |
+
commercial_blocks = []
|
| 674 |
+
xlnt_block = []
|
| 675 |
+
|
| 676 |
+
# Lowest block is XLNT
|
| 677 |
+
if sorted_blocks:
|
| 678 |
+
xlnt = sorted_blocks.pop(0)
|
| 679 |
+
xlnt_block.append(xlnt)
|
| 680 |
+
current_service_area += xlnt.area
|
| 681 |
+
|
| 682 |
+
# Fill remaining service quota
|
| 683 |
+
for b in sorted_blocks:
|
| 684 |
+
if current_service_area < service_area_target:
|
| 685 |
+
service_blocks.append(b)
|
| 686 |
+
current_service_area += b.area
|
| 687 |
+
else:
|
| 688 |
+
commercial_blocks.append(b)
|
| 689 |
+
|
| 690 |
+
return {
|
| 691 |
+
'xlnt': xlnt_block,
|
| 692 |
+
'service': service_blocks,
|
| 693 |
+
'commercial': commercial_blocks
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
def run_full_pipeline(self) -> Dict[str, Any]:
|
| 697 |
+
"""Run complete optimization pipeline with Voronoi road generation."""
|
| 698 |
+
# NEW: Stage 0 - Voronoi Road Network Generation
|
| 699 |
+
road_network, service_blocks_voronoi, commercial_blocks_voronoi = self.generate_road_network(num_seeds=15)
|
| 700 |
+
|
| 701 |
+
# If Voronoi fails, fallback to old approach
|
| 702 |
+
if not commercial_blocks_voronoi:
|
| 703 |
+
# Old approach: Grid-based
|
| 704 |
+
stage1_result = self.run_stage1()
|
| 705 |
+
classification = self.classify_blocks(stage1_result['blocks'])
|
| 706 |
+
commercial_blocks_voronoi = classification['commercial']
|
| 707 |
+
service_blocks_voronoi = classification['service']
|
| 708 |
+
xlnt_blocks = classification['xlnt']
|
| 709 |
+
# Old road network
|
| 710 |
+
all_blocks = stage1_result['blocks']
|
| 711 |
+
road_network = self.land_poly.difference(unary_union(all_blocks))
|
| 712 |
+
spacing_for_subdivision = stage1_result['spacing']
|
| 713 |
+
else:
|
| 714 |
+
# Voronoi succeeded - separate XLNT from service blocks
|
| 715 |
+
# XLNT is the first service block (lowest elevation)
|
| 716 |
+
if service_blocks_voronoi:
|
| 717 |
+
xlnt_blocks = [service_blocks_voronoi[0]]
|
| 718 |
+
service_blocks_voronoi = service_blocks_voronoi[1:]
|
| 719 |
+
else:
|
| 720 |
+
xlnt_blocks = []
|
| 721 |
+
|
| 722 |
+
# Estimate spacing for subdivision (use average block dimension)
|
| 723 |
+
if commercial_blocks_voronoi and len(commercial_blocks_voronoi) > 0:
|
| 724 |
+
avg_area = sum(b.area for b in commercial_blocks_voronoi) / len(commercial_blocks_voronoi)
|
| 725 |
+
spacing_for_subdivision = max(20.0, (avg_area ** 0.5) * 0.7) # Heuristic, min 20m
|
| 726 |
+
else:
|
| 727 |
+
spacing_for_subdivision = 25.0
|
| 728 |
|
| 729 |
+
# Stage 2: Subdivision (only for commercial blocks)
|
| 730 |
stage2_result = self.run_stage2(
|
| 731 |
+
commercial_blocks_voronoi,
|
| 732 |
+
spacing_for_subdivision
|
| 733 |
)
|
| 734 |
|
| 735 |
+
# Construct final list of all network nodes
|
| 736 |
+
all_network_nodes = stage2_result['lots'] + \
|
| 737 |
+
[{'geometry': b, 'type': 'service'} for b in service_blocks_voronoi] + \
|
| 738 |
+
[{'geometry': b, 'type': 'xlnt'} for b in xlnt_blocks]
|
| 739 |
+
|
| 740 |
+
# Extract polygons for Infrastructure
|
| 741 |
+
infra_polys = [item['geometry'] for item in all_network_nodes]
|
| 742 |
+
|
| 743 |
+
# Stage 3: Infrastructure
|
| 744 |
+
points, connections = InfrastructurePlanner.generate_network(infra_polys)
|
| 745 |
+
|
| 746 |
+
# Transformers
|
| 747 |
+
transformers = InfrastructurePlanner.generate_transformers(infra_polys)
|
| 748 |
+
|
| 749 |
+
# Drainage
|
| 750 |
+
wwtp_center = xlnt_blocks[0].centroid if xlnt_blocks else None
|
| 751 |
+
drainage = InfrastructurePlanner.calculate_drainage(infra_polys, wwtp_center)
|
| 752 |
+
|
| 753 |
return {
|
| 754 |
+
'stage1': {
|
| 755 |
+
'blocks': commercial_blocks_voronoi + service_blocks_voronoi + xlnt_blocks,
|
| 756 |
+
'metrics': {
|
| 757 |
+
'total_blocks': len(commercial_blocks_voronoi) + len(service_blocks_voronoi) + len(xlnt_blocks)
|
| 758 |
+
},
|
| 759 |
+
'spacing': spacing_for_subdivision,
|
| 760 |
+
'angle': 0.0 # Voronoi doesn't use angle
|
| 761 |
+
},
|
| 762 |
'stage2': stage2_result,
|
| 763 |
+
'classification': {
|
| 764 |
+
'xlnt_count': len(xlnt_blocks),
|
| 765 |
+
'service_count': len(service_blocks_voronoi),
|
| 766 |
+
'commercial_count': len(commercial_blocks_voronoi),
|
| 767 |
+
'xlnt': xlnt_blocks,
|
| 768 |
+
'service': service_blocks_voronoi
|
| 769 |
+
},
|
| 770 |
+
'stage3': {
|
| 771 |
+
'points': points,
|
| 772 |
+
'connections': [list(line.coords) for line in connections],
|
| 773 |
+
'drainage': drainage,
|
| 774 |
+
'transformers': transformers,
|
| 775 |
+
'road_network': mapping(road_network)
|
| 776 |
+
},
|
| 777 |
+
'total_lots': stage2_result['metrics']['total_lots'],
|
| 778 |
+
'service_blocks': [list(b.exterior.coords) for b in service_blocks_voronoi],
|
| 779 |
+
'xlnt_blocks': [list(b.exterior.coords) for b in xlnt_blocks]
|
| 780 |
}
|
algorithms/backend/dxf_utils.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DXF file handling utilities for importing and exporting geometry."""
|
| 2 |
+
|
| 3 |
+
import ezdxf
|
| 4 |
+
from shapely.geometry import Polygon, mapping
|
| 5 |
+
from shapely.ops import unary_union
|
| 6 |
+
from typing import Optional, List, Tuple
|
| 7 |
+
import io
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_boundary_from_dxf(dxf_content: bytes) -> Optional[Polygon]:
|
| 11 |
+
"""
|
| 12 |
+
Load site boundary from DXF file content.
|
| 13 |
+
|
| 14 |
+
Args:
|
| 15 |
+
dxf_content: Bytes content of DXF file
|
| 16 |
+
|
| 17 |
+
Returns:
|
| 18 |
+
Shapely Polygon or None if no valid boundary found
|
| 19 |
+
"""
|
| 20 |
+
try:
|
| 21 |
+
# Load DXF from bytes
|
| 22 |
+
dxf_stream = io.BytesIO(dxf_content)
|
| 23 |
+
doc = ezdxf.readfile(dxf_stream)
|
| 24 |
+
msp = doc.modelspace()
|
| 25 |
+
|
| 26 |
+
polygons = []
|
| 27 |
+
|
| 28 |
+
# Extract LWPOLYLINE and POLYLINE entities
|
| 29 |
+
for entity in msp.query('LWPOLYLINE, POLYLINE'):
|
| 30 |
+
if entity.is_closed:
|
| 31 |
+
points = list(entity.get_points())
|
| 32 |
+
if len(points) >= 3:
|
| 33 |
+
coords = [(p[0], p[1]) for p in points]
|
| 34 |
+
# Close the polygon
|
| 35 |
+
if coords[0] != coords[-1]:
|
| 36 |
+
coords.append(coords[0])
|
| 37 |
+
try:
|
| 38 |
+
poly = Polygon(coords)
|
| 39 |
+
if poly.is_valid:
|
| 40 |
+
polygons.append(poly)
|
| 41 |
+
except:
|
| 42 |
+
continue
|
| 43 |
+
|
| 44 |
+
# Also try to extract from LINE entities forming closed loops
|
| 45 |
+
lines = list(msp.query('LINE'))
|
| 46 |
+
if lines and not polygons:
|
| 47 |
+
# Try to connect lines into closed loops
|
| 48 |
+
# This is a simplified approach - could be improved
|
| 49 |
+
for line in lines:
|
| 50 |
+
start = (line.dxf.start.x, line.dxf.start.y)
|
| 51 |
+
end = (line.dxf.end.x, line.dxf.end.y)
|
| 52 |
+
# Simple heuristic: if it looks like a rectangle
|
| 53 |
+
# You might need more sophisticated logic here
|
| 54 |
+
|
| 55 |
+
if polygons:
|
| 56 |
+
# Union all polygons and take the largest one
|
| 57 |
+
if len(polygons) > 1:
|
| 58 |
+
union = unary_union(polygons)
|
| 59 |
+
if union.geom_type == 'Polygon':
|
| 60 |
+
return union
|
| 61 |
+
elif union.geom_type == 'MultiPolygon':
|
| 62 |
+
# Return the largest polygon
|
| 63 |
+
return max(union.geoms, key=lambda p: p.area)
|
| 64 |
+
return polygons[0]
|
| 65 |
+
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
except Exception as e:
|
| 69 |
+
print(f"Error loading DXF: {e}")
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def export_to_dxf(geometries: List[dict], output_type: str = 'final') -> bytes:
|
| 74 |
+
"""
|
| 75 |
+
Export geometries to DXF format.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
geometries: List of geometry dicts with 'geometry' and 'properties'
|
| 79 |
+
output_type: Type of output ('stage1', 'stage2', 'final')
|
| 80 |
+
|
| 81 |
+
Returns:
|
| 82 |
+
DXF file content as bytes
|
| 83 |
+
"""
|
| 84 |
+
try:
|
| 85 |
+
# Create new DXF document
|
| 86 |
+
doc = ezdxf.new('R2010')
|
| 87 |
+
msp = doc.modelspace()
|
| 88 |
+
|
| 89 |
+
# Create layers
|
| 90 |
+
doc.layers.add('BLOCKS', color=5) # Blue for blocks
|
| 91 |
+
doc.layers.add('LOTS', color=3) # Green for lots
|
| 92 |
+
doc.layers.add('PARKS', color=2) # Yellow for parks
|
| 93 |
+
doc.layers.add('BOUNDARY', color=7) # White for boundary
|
| 94 |
+
|
| 95 |
+
# Add geometries
|
| 96 |
+
for item in geometries:
|
| 97 |
+
geom = item.get('geometry')
|
| 98 |
+
props = item.get('properties', {})
|
| 99 |
+
geom_type = props.get('type', 'lot')
|
| 100 |
+
|
| 101 |
+
# Determine layer
|
| 102 |
+
if geom_type == 'block':
|
| 103 |
+
layer = 'BLOCKS'
|
| 104 |
+
elif geom_type == 'park':
|
| 105 |
+
layer = 'PARKS'
|
| 106 |
+
else:
|
| 107 |
+
layer = 'LOTS'
|
| 108 |
+
|
| 109 |
+
# Get coordinates
|
| 110 |
+
if geom and 'coordinates' in geom:
|
| 111 |
+
coords = geom['coordinates']
|
| 112 |
+
if coords and len(coords) > 0:
|
| 113 |
+
points = coords[0] # Exterior ring
|
| 114 |
+
|
| 115 |
+
# Add as LWPOLYLINE
|
| 116 |
+
if len(points) >= 3:
|
| 117 |
+
# Convert to 2D points (x, y)
|
| 118 |
+
points_2d = [(p[0], p[1]) for p in points]
|
| 119 |
+
|
| 120 |
+
# Create closed polyline
|
| 121 |
+
msp.add_lwpolyline(
|
| 122 |
+
points_2d,
|
| 123 |
+
dxfattribs={
|
| 124 |
+
'layer': layer,
|
| 125 |
+
'closed': True
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
# Save to bytes
|
| 130 |
+
stream = io.StringIO()
|
| 131 |
+
doc.write(stream, fmt='asc') # ASCII format for better compatibility
|
| 132 |
+
return stream.getvalue().encode('utf-8')
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
print(f"Error exporting DXF: {e}")
|
| 136 |
+
return b''
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def validate_dxf(dxf_content: bytes) -> Tuple[bool, str]:
|
| 140 |
+
"""
|
| 141 |
+
Validate DXF file and return status.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
dxf_content: DXF file bytes
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
(is_valid, message)
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
dxf_stream = io.BytesIO(dxf_content)
|
| 151 |
+
doc = ezdxf.readfile(dxf_stream)
|
| 152 |
+
msp = doc.modelspace()
|
| 153 |
+
|
| 154 |
+
# Count entities
|
| 155 |
+
polylines = len(list(msp.query('LWPOLYLINE, POLYLINE')))
|
| 156 |
+
lines = len(list(msp.query('LINE')))
|
| 157 |
+
|
| 158 |
+
if polylines == 0 and lines == 0:
|
| 159 |
+
return False, "No polylines or lines found in DXF"
|
| 160 |
+
|
| 161 |
+
return True, f"Valid DXF: {polylines} polylines, {lines} lines"
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
return False, f"Invalid DXF: {str(e)}"
|
algorithms/backend/models.py
CHANGED
|
@@ -13,10 +13,10 @@ class AlgorithmConfig(BaseModel):
|
|
| 13 |
angle_min: float = Field(default=0.0, ge=0.0, le=90.0, description="Minimum grid angle in degrees")
|
| 14 |
angle_max: float = Field(default=90.0, ge=0.0, le=90.0, description="Maximum grid angle in degrees")
|
| 15 |
|
| 16 |
-
# Stage 2: Subdivision parameters
|
| 17 |
-
min_lot_width: float = Field(default=
|
| 18 |
-
max_lot_width: float = Field(default=
|
| 19 |
-
target_lot_width: float = Field(default=
|
| 20 |
|
| 21 |
# Infrastructure parameters
|
| 22 |
road_width: float = Field(default=6.0, ge=3.0, le=10.0, description="Road width in meters")
|
|
@@ -25,7 +25,7 @@ class AlgorithmConfig(BaseModel):
|
|
| 25 |
# Optimization parameters
|
| 26 |
population_size: int = Field(default=50, ge=20, le=200, description="NSGA-II population size")
|
| 27 |
generations: int = Field(default=100, ge=50, le=500, description="Number of generations")
|
| 28 |
-
ortools_time_limit:
|
| 29 |
|
| 30 |
|
| 31 |
class LandPlot(BaseModel):
|
|
|
|
| 13 |
angle_min: float = Field(default=0.0, ge=0.0, le=90.0, description="Minimum grid angle in degrees")
|
| 14 |
angle_max: float = Field(default=90.0, ge=0.0, le=90.0, description="Maximum grid angle in degrees")
|
| 15 |
|
| 16 |
+
# Stage 2: Subdivision parameters (Industrial lots)
|
| 17 |
+
min_lot_width: float = Field(default=20.0, ge=10.0, le=40.0, description="Minimum lot width in meters")
|
| 18 |
+
max_lot_width: float = Field(default=80.0, ge=40.0, le=120.0, description="Maximum lot width in meters")
|
| 19 |
+
target_lot_width: float = Field(default=40.0, ge=20.0, le=100.0, description="Target lot width in meters")
|
| 20 |
|
| 21 |
# Infrastructure parameters
|
| 22 |
road_width: float = Field(default=6.0, ge=3.0, le=10.0, description="Road width in meters")
|
|
|
|
| 25 |
# Optimization parameters
|
| 26 |
population_size: int = Field(default=50, ge=20, le=200, description="NSGA-II population size")
|
| 27 |
generations: int = Field(default=100, ge=50, le=500, description="Number of generations")
|
| 28 |
+
ortools_time_limit: float = Field(default=5.0, ge=0.1, le=60.0, description="OR-Tools solver time limit per block (seconds)")
|
| 29 |
|
| 30 |
|
| 31 |
class LandPlot(BaseModel):
|
algorithms/backend/requirements.txt
CHANGED
|
@@ -7,3 +7,7 @@ matplotlib==3.8.2
|
|
| 7 |
ortools==9.8.3296
|
| 8 |
deap==1.4.1
|
| 9 |
python-multipart==0.0.6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
ortools==9.8.3296
|
| 8 |
deap==1.4.1
|
| 9 |
python-multipart==0.0.6
|
| 10 |
+
ezdxf==1.1.3
|
| 11 |
+
scipy==1.11.4
|
| 12 |
+
networkx==3.2.1
|
| 13 |
+
scikit-learn==1.3.2
|
algorithms/backend/routes.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
"""API routes for land redistribution algorithm."""
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter, HTTPException
|
|
|
|
| 4 |
from typing import List
|
| 5 |
import traceback
|
| 6 |
-
from shapely.geometry import Polygon, mapping
|
| 7 |
|
| 8 |
from models import (
|
| 9 |
OptimizationRequest,
|
|
@@ -11,6 +12,7 @@ from models import (
|
|
| 11 |
StageResult
|
| 12 |
)
|
| 13 |
from algorithm import LandRedistributionPipeline
|
|
|
|
| 14 |
|
| 15 |
router = APIRouter()
|
| 16 |
|
|
@@ -80,15 +82,29 @@ async def optimize_full(request: OptimizationRequest):
|
|
| 80 |
|
| 81 |
# Add lots
|
| 82 |
for lot in result['stage2']['lots']:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
stage2_features.append({
|
| 84 |
"type": "Feature",
|
| 85 |
"geometry": polygon_to_geojson(lot['geometry']),
|
| 86 |
-
"properties":
|
| 87 |
-
"stage": "subdivision",
|
| 88 |
-
"type": "lot",
|
| 89 |
-
"width": lot['width']
|
| 90 |
-
}
|
| 91 |
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
# Add parks
|
| 94 |
for park in result['stage2']['parks']:
|
|
@@ -101,6 +117,31 @@ async def optimize_full(request: OptimizationRequest):
|
|
| 101 |
}
|
| 102 |
})
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
stage2_geoms = {
|
| 105 |
"type": "FeatureCollection",
|
| 106 |
"features": stage2_features
|
|
@@ -109,7 +150,11 @@ async def optimize_full(request: OptimizationRequest):
|
|
| 109 |
stages.append(StageResult(
|
| 110 |
stage_name="Block Subdivision (OR-Tools)",
|
| 111 |
geometry=stage2_geoms,
|
| 112 |
-
metrics=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
parameters={
|
| 114 |
"min_lot_width": config['min_lot_width'],
|
| 115 |
"max_lot_width": config['max_lot_width'],
|
|
@@ -117,12 +162,85 @@ async def optimize_full(request: OptimizationRequest):
|
|
| 117 |
}
|
| 118 |
))
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
# Build response
|
| 121 |
return OptimizationResponse(
|
| 122 |
success=True,
|
| 123 |
message="Optimization completed successfully",
|
| 124 |
stages=stages,
|
| 125 |
-
final_layout=
|
| 126 |
total_lots=result['total_lots'],
|
| 127 |
statistics={
|
| 128 |
"total_blocks": result['stage1']['metrics']['total_blocks'],
|
|
@@ -130,7 +248,8 @@ async def optimize_full(request: OptimizationRequest):
|
|
| 130 |
"total_parks": result['stage2']['metrics']['total_parks'],
|
| 131 |
"optimal_spacing": result['stage1']['spacing'],
|
| 132 |
"optimal_angle": result['stage1']['angle'],
|
| 133 |
-
"avg_lot_width": result['stage2']['metrics']['avg_lot_width']
|
|
|
|
| 134 |
}
|
| 135 |
)
|
| 136 |
|
|
@@ -179,3 +298,101 @@ async def optimize_stage1(request: OptimizationRequest):
|
|
| 179 |
|
| 180 |
except Exception as e:
|
| 181 |
raise HTTPException(status_code=500, detail=f"Stage 1 failed: {str(e)}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""API routes for land redistribution algorithm."""
|
| 2 |
|
| 3 |
+
from fastapi import APIRouter, HTTPException, UploadFile, File
|
| 4 |
+
from fastapi.responses import Response
|
| 5 |
from typing import List
|
| 6 |
import traceback
|
| 7 |
+
from shapely.geometry import Polygon, mapping, LineString, Point
|
| 8 |
|
| 9 |
from models import (
|
| 10 |
OptimizationRequest,
|
|
|
|
| 12 |
StageResult
|
| 13 |
)
|
| 14 |
from algorithm import LandRedistributionPipeline
|
| 15 |
+
from dxf_utils import load_boundary_from_dxf, export_to_dxf, validate_dxf
|
| 16 |
|
| 17 |
router = APIRouter()
|
| 18 |
|
|
|
|
| 82 |
|
| 83 |
# Add lots
|
| 84 |
for lot in result['stage2']['lots']:
|
| 85 |
+
lot_props = {
|
| 86 |
+
"stage": "subdivision",
|
| 87 |
+
"type": "lot",
|
| 88 |
+
"width": lot['width']
|
| 89 |
+
}
|
| 90 |
stage2_features.append({
|
| 91 |
"type": "Feature",
|
| 92 |
"geometry": polygon_to_geojson(lot['geometry']),
|
| 93 |
+
"properties": lot_props
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
})
|
| 95 |
+
|
| 96 |
+
# Setback (Network visualization will need this as separate line usually,
|
| 97 |
+
# or frontend can render it if passed as property geometry)
|
| 98 |
+
if lot.get('buildable'):
|
| 99 |
+
stage2_features.append({
|
| 100 |
+
"type": "Feature",
|
| 101 |
+
"geometry": polygon_to_geojson(lot['buildable']),
|
| 102 |
+
"properties": {
|
| 103 |
+
"stage": "subdivision",
|
| 104 |
+
"type": "setback",
|
| 105 |
+
"parent_lot": str(lot['geometry'])
|
| 106 |
+
}
|
| 107 |
+
})
|
| 108 |
|
| 109 |
# Add parks
|
| 110 |
for park in result['stage2']['parks']:
|
|
|
|
| 117 |
}
|
| 118 |
})
|
| 119 |
|
| 120 |
+
|
| 121 |
+
# Add Service Blocks
|
| 122 |
+
for block in result['classification'].get('service', []):
|
| 123 |
+
stage2_features.append({
|
| 124 |
+
"type": "Feature",
|
| 125 |
+
"geometry": polygon_to_geojson(block),
|
| 126 |
+
"properties": {
|
| 127 |
+
"stage": "subdivision",
|
| 128 |
+
"type": "service",
|
| 129 |
+
"label": "Operating Center/Parking"
|
| 130 |
+
}
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
# Add XLNT Block
|
| 134 |
+
for block in result['classification'].get('xlnt', []):
|
| 135 |
+
stage2_features.append({
|
| 136 |
+
"type": "Feature",
|
| 137 |
+
"geometry": polygon_to_geojson(block),
|
| 138 |
+
"properties": {
|
| 139 |
+
"stage": "subdivision",
|
| 140 |
+
"type": "xlnt",
|
| 141 |
+
"label": "Wastewater Treatment"
|
| 142 |
+
}
|
| 143 |
+
})
|
| 144 |
+
|
| 145 |
stage2_geoms = {
|
| 146 |
"type": "FeatureCollection",
|
| 147 |
"features": stage2_features
|
|
|
|
| 150 |
stages.append(StageResult(
|
| 151 |
stage_name="Block Subdivision (OR-Tools)",
|
| 152 |
geometry=stage2_geoms,
|
| 153 |
+
metrics={
|
| 154 |
+
**result['stage2']['metrics'],
|
| 155 |
+
"service_count": result['classification']['service_count'],
|
| 156 |
+
"xlnt_count": result['classification']['xlnt_count']
|
| 157 |
+
},
|
| 158 |
parameters={
|
| 159 |
"min_lot_width": config['min_lot_width'],
|
| 160 |
"max_lot_width": config['max_lot_width'],
|
|
|
|
| 162 |
}
|
| 163 |
))
|
| 164 |
|
| 165 |
+
# Stage 3: Infrastructure
|
| 166 |
+
stage3_features = []
|
| 167 |
+
|
| 168 |
+
# Add road network
|
| 169 |
+
if 'road_network' in result['stage3']:
|
| 170 |
+
road_feat = {
|
| 171 |
+
"type": "Feature",
|
| 172 |
+
"geometry": result['stage3']['road_network'],
|
| 173 |
+
"properties": {
|
| 174 |
+
"stage": "infrastructure",
|
| 175 |
+
"type": "road_network",
|
| 176 |
+
"label": "Transportation Infra"
|
| 177 |
+
}
|
| 178 |
+
}
|
| 179 |
+
# PREPEND roads so they are at bottom layer
|
| 180 |
+
stage3_features.insert(0, road_feat)
|
| 181 |
+
|
| 182 |
+
# Add connection lines
|
| 183 |
+
for conn_coords in result['stage3']['connections']:
|
| 184 |
+
stage3_features.append({
|
| 185 |
+
"type": "Feature",
|
| 186 |
+
"geometry": mapping(LineString(conn_coords)),
|
| 187 |
+
"properties": {
|
| 188 |
+
"stage": "infrastructure",
|
| 189 |
+
"type": "connection",
|
| 190 |
+
"layer": "electricity_water"
|
| 191 |
+
}
|
| 192 |
+
})
|
| 193 |
+
|
| 194 |
+
# Add Transformers
|
| 195 |
+
if 'transformers' in result['stage3']:
|
| 196 |
+
for tf_coords in result['stage3']['transformers']:
|
| 197 |
+
stage3_features.append({
|
| 198 |
+
"type": "Feature",
|
| 199 |
+
"geometry": mapping(Point(tf_coords)),
|
| 200 |
+
"properties": {
|
| 201 |
+
"stage": "infrastructure",
|
| 202 |
+
"type": "transformer",
|
| 203 |
+
"label": "Transformer Station"
|
| 204 |
+
}
|
| 205 |
+
})
|
| 206 |
+
|
| 207 |
+
# Add drainage
|
| 208 |
+
for drainage in result['stage3']['drainage']:
|
| 209 |
+
# Create a line for the arrow
|
| 210 |
+
start = drainage['start']
|
| 211 |
+
vec = drainage['vector']
|
| 212 |
+
end = (start[0] + vec[0], start[1] + vec[1])
|
| 213 |
+
stage3_features.append({
|
| 214 |
+
"type": "Feature",
|
| 215 |
+
"geometry": mapping(LineString([start, end])),
|
| 216 |
+
"properties": {
|
| 217 |
+
"stage": "infrastructure",
|
| 218 |
+
"type": "drainage"
|
| 219 |
+
}
|
| 220 |
+
})
|
| 221 |
+
|
| 222 |
+
stage3_geoms = {
|
| 223 |
+
"type": "FeatureCollection",
|
| 224 |
+
"features": stage3_features + stage2_features # Include base map
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
stages.append(StageResult(
|
| 228 |
+
stage_name="Infrastructure (MST & Drainage & Roads)",
|
| 229 |
+
geometry=stage3_geoms,
|
| 230 |
+
metrics={
|
| 231 |
+
"total_connections": len(result['stage3']['connections']),
|
| 232 |
+
"drainage_points": len(result['stage3']['drainage']),
|
| 233 |
+
"transformers": len(result.get('stage3', {}).get('transformers', []))
|
| 234 |
+
},
|
| 235 |
+
parameters={}
|
| 236 |
+
))
|
| 237 |
+
|
| 238 |
# Build response
|
| 239 |
return OptimizationResponse(
|
| 240 |
success=True,
|
| 241 |
message="Optimization completed successfully",
|
| 242 |
stages=stages,
|
| 243 |
+
final_layout=stage3_geoms,
|
| 244 |
total_lots=result['total_lots'],
|
| 245 |
statistics={
|
| 246 |
"total_blocks": result['stage1']['metrics']['total_blocks'],
|
|
|
|
| 248 |
"total_parks": result['stage2']['metrics']['total_parks'],
|
| 249 |
"optimal_spacing": result['stage1']['spacing'],
|
| 250 |
"optimal_angle": result['stage1']['angle'],
|
| 251 |
+
"avg_lot_width": result['stage2']['metrics']['avg_lot_width'],
|
| 252 |
+
"service_area_count": result['classification']['service_count'] + result['classification']['xlnt_count']
|
| 253 |
}
|
| 254 |
)
|
| 255 |
|
|
|
|
| 298 |
|
| 299 |
except Exception as e:
|
| 300 |
raise HTTPException(status_code=500, detail=f"Stage 1 failed: {str(e)}")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@router.post("/upload-dxf")
|
| 304 |
+
async def upload_dxf(file: UploadFile = File(...)):
|
| 305 |
+
"""
|
| 306 |
+
Upload and parse DXF file to extract boundary polygon.
|
| 307 |
+
|
| 308 |
+
Returns GeoJSON polygon that can be used as input.
|
| 309 |
+
"""
|
| 310 |
+
try:
|
| 311 |
+
# Read file content
|
| 312 |
+
content = await file.read()
|
| 313 |
+
|
| 314 |
+
# Validate DXF
|
| 315 |
+
is_valid, message = validate_dxf(content)
|
| 316 |
+
if not is_valid:
|
| 317 |
+
raise HTTPException(status_code=400, detail=message)
|
| 318 |
+
|
| 319 |
+
# Load boundary
|
| 320 |
+
polygon = load_boundary_from_dxf(content)
|
| 321 |
+
|
| 322 |
+
if polygon is None:
|
| 323 |
+
raise HTTPException(
|
| 324 |
+
status_code=400,
|
| 325 |
+
detail="Could not extract boundary polygon from DXF. Make sure it contains closed polylines."
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
# Convert to GeoJSON
|
| 329 |
+
geojson = {
|
| 330 |
+
"type": "Polygon",
|
| 331 |
+
"coordinates": [list(polygon.exterior.coords)],
|
| 332 |
+
"properties": {
|
| 333 |
+
"source": "dxf",
|
| 334 |
+
"filename": file.filename,
|
| 335 |
+
"area": polygon.area
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
return {
|
| 340 |
+
"success": True,
|
| 341 |
+
"message": f"Successfully extracted boundary from {file.filename}",
|
| 342 |
+
"polygon": geojson,
|
| 343 |
+
"area": polygon.area,
|
| 344 |
+
"bounds": polygon.bounds
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
except HTTPException:
|
| 348 |
+
raise
|
| 349 |
+
except Exception as e:
|
| 350 |
+
raise HTTPException(status_code=500, detail=f"Failed to process DXF: {str(e)}")
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
@router.post("/export-dxf")
|
| 354 |
+
async def export_dxf_endpoint(request: dict):
|
| 355 |
+
"""
|
| 356 |
+
Export optimization results to DXF format.
|
| 357 |
+
|
| 358 |
+
Expects: {"result": OptimizationResponse}
|
| 359 |
+
Returns: DXF file
|
| 360 |
+
"""
|
| 361 |
+
try:
|
| 362 |
+
result = request.get('result')
|
| 363 |
+
if not result:
|
| 364 |
+
raise HTTPException(status_code=400, detail="No result data provided")
|
| 365 |
+
|
| 366 |
+
# Get final layout or last stage
|
| 367 |
+
geometries = []
|
| 368 |
+
|
| 369 |
+
if 'final_layout' in result and result['final_layout']:
|
| 370 |
+
features = result['final_layout'].get('features', [])
|
| 371 |
+
geometries = features
|
| 372 |
+
elif 'stages' in result and len(result['stages']) > 0:
|
| 373 |
+
last_stage = result['stages'][-1]
|
| 374 |
+
features = last_stage.get('geometry', {}).get('features', [])
|
| 375 |
+
geometries = features
|
| 376 |
+
|
| 377 |
+
if not geometries:
|
| 378 |
+
raise HTTPException(status_code=400, detail="No geometries to export")
|
| 379 |
+
|
| 380 |
+
# Export to DXF
|
| 381 |
+
dxf_bytes = export_to_dxf(geometries)
|
| 382 |
+
|
| 383 |
+
if not dxf_bytes:
|
| 384 |
+
raise HTTPException(status_code=500, detail="Failed to generate DXF")
|
| 385 |
+
|
| 386 |
+
# Return as downloadable file
|
| 387 |
+
return Response(
|
| 388 |
+
content=dxf_bytes,
|
| 389 |
+
media_type="application/dxf",
|
| 390 |
+
headers={
|
| 391 |
+
"Content-Disposition": "attachment; filename=land_redistribution.dxf"
|
| 392 |
+
}
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
except HTTPException:
|
| 396 |
+
raise
|
| 397 |
+
except Exception as e:
|
| 398 |
+
raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
|
algorithms/frontend/app.py
CHANGED
|
@@ -13,6 +13,12 @@ import plotly.graph_objects as go
|
|
| 13 |
from plotly.subplots import make_subplots
|
| 14 |
import pandas as pd
|
| 15 |
from typing import Dict, Any
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
# Configuration
|
| 18 |
API_URL = "http://localhost:8000"
|
|
@@ -113,38 +119,169 @@ col_config, col_action, col_result = st.columns([1.2, 1, 2])
|
|
| 113 |
with col_config:
|
| 114 |
st.markdown("### ⚙️ Configuration")
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
# Grid Optimization Parameters
|
| 117 |
with st.expander("🔲 Grid Optimization", expanded=True):
|
|
|
|
| 118 |
c1, c2 = st.columns(2)
|
| 119 |
with c1:
|
| 120 |
-
spacing_min = st.number_input(
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
with c2:
|
| 123 |
-
spacing_max = st.number_input(
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
# Subdivision Parameters
|
| 127 |
with st.expander("📐 Lot Subdivision", expanded=True):
|
|
|
|
| 128 |
c1, c2, c3 = st.columns(3)
|
| 129 |
with c1:
|
| 130 |
-
min_lot_width = st.number_input(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
with c2:
|
| 132 |
-
target_lot_width = st.number_input(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
with c3:
|
| 134 |
-
max_lot_width = st.number_input(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
|
| 136 |
-
#
|
| 137 |
-
with st.expander("⚡
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
# ==================== COLUMN 2: Input & Action ====================
|
| 150 |
with col_action:
|
|
@@ -153,33 +290,66 @@ with col_action:
|
|
| 153 |
# Input method selection
|
| 154 |
input_method = st.radio(
|
| 155 |
"Input method:",
|
| 156 |
-
["Sample", "Upload", "Manual"],
|
| 157 |
-
horizontal=
|
| 158 |
-
label_visibility="collapsed"
|
| 159 |
)
|
| 160 |
|
| 161 |
if input_method == "Sample":
|
| 162 |
# Predefined sample
|
| 163 |
sample_type = st.selectbox(
|
| 164 |
"Sample type:",
|
| 165 |
-
["Rectangle 100x100", "L-Shape", "Irregular"]
|
| 166 |
)
|
| 167 |
|
| 168 |
if sample_type == "Rectangle 100x100":
|
| 169 |
coords = [[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]
|
| 170 |
elif sample_type == "L-Shape":
|
| 171 |
coords = [[[0, 0], [60, 0], [60, 40], [40, 40], [40, 100], [0, 100], [0, 0]]]
|
| 172 |
-
|
| 173 |
coords = [[[0, 0], [80, 10], [100, 50], [90, 100], [20, 90], [0, 0]]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
st.session_state.land_plot = {
|
| 176 |
"type": "Polygon",
|
| 177 |
"coordinates": coords,
|
| 178 |
"properties": {"name": sample_type}
|
| 179 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if uploaded:
|
| 184 |
try:
|
| 185 |
data = json.load(uploaded)
|
|
@@ -187,20 +357,22 @@ with col_action:
|
|
| 187 |
st.session_state.land_plot = data['features'][0]['geometry']
|
| 188 |
else:
|
| 189 |
st.session_state.land_plot = data
|
|
|
|
| 190 |
except Exception as e:
|
| 191 |
st.error(f"Invalid file: {e}")
|
|
|
|
| 192 |
|
| 193 |
else: # Manual
|
| 194 |
coords_input = st.text_area(
|
| 195 |
"Coordinates (JSON):",
|
| 196 |
'''[
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
]''',
|
| 205 |
height=150
|
| 206 |
)
|
|
@@ -352,11 +524,132 @@ with col_result:
|
|
| 352 |
with p2:
|
| 353 |
st.info(f"📐 Angle: **{stats.get('optimal_angle', 0):.1f}°**")
|
| 354 |
|
| 355 |
-
|
| 356 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
|
|
|
|
|
|
|
| 358 |
if len(stages) >= 2:
|
| 359 |
-
# Create side-by-side comparison
|
| 360 |
fig = make_subplots(
|
| 361 |
rows=1, cols=2,
|
| 362 |
subplot_titles=('Stage 1: Grid Optimization', 'Stage 2: Subdivision'),
|
|
@@ -428,21 +721,49 @@ with col_result:
|
|
| 428 |
|
| 429 |
# Download section
|
| 430 |
st.markdown("---")
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
| 432 |
with d1:
|
| 433 |
if result.get('final_layout'):
|
| 434 |
st.download_button(
|
| 435 |
-
"
|
| 436 |
data=json.dumps(result['final_layout'], indent=2),
|
| 437 |
file_name="layout.geojson",
|
| 438 |
mime="application/json",
|
| 439 |
use_container_width=True
|
| 440 |
)
|
|
|
|
| 441 |
with d2:
|
| 442 |
st.download_button(
|
| 443 |
-
"
|
| 444 |
data=json.dumps(result, indent=2),
|
| 445 |
file_name="report.json",
|
| 446 |
mime="application/json",
|
| 447 |
use_container_width=True
|
| 448 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
from plotly.subplots import make_subplots
|
| 14 |
import pandas as pd
|
| 15 |
from typing import Dict, Any
|
| 16 |
+
import matplotlib.pyplot as plt
|
| 17 |
+
from shapely.geometry import shape, Polygon
|
| 18 |
+
import numpy as np
|
| 19 |
+
from plotly.subplots import make_subplots
|
| 20 |
+
import pandas as pd
|
| 21 |
+
from typing import Dict, Any
|
| 22 |
|
| 23 |
# Configuration
|
| 24 |
API_URL = "http://localhost:8000"
|
|
|
|
| 119 |
with col_config:
|
| 120 |
st.markdown("### ⚙️ Configuration")
|
| 121 |
|
| 122 |
+
# Quick Presets
|
| 123 |
+
with st.expander("🎯 Quick Presets", expanded=True):
|
| 124 |
+
preset = st.selectbox(
|
| 125 |
+
"Choose a preset:",
|
| 126 |
+
["Custom", "🚀 Fastest", "⚖️ Balanced", "🏆 Best Quality"],
|
| 127 |
+
help="Select a preset or use Custom to set your own values"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Apply preset values
|
| 131 |
+
if preset == "🚀 Fastest":
|
| 132 |
+
default_pop = 20
|
| 133 |
+
default_gen = 50
|
| 134 |
+
default_ort = 0.5
|
| 135 |
+
elif preset == "⚖️ Balanced":
|
| 136 |
+
default_pop = 50
|
| 137 |
+
default_gen = 75
|
| 138 |
+
default_ort = 5.0
|
| 139 |
+
elif preset == "🏆 Best Quality":
|
| 140 |
+
default_pop = 150
|
| 141 |
+
default_gen = 150
|
| 142 |
+
default_ort = 15.0
|
| 143 |
+
else: # Custom
|
| 144 |
+
default_pop = 50
|
| 145 |
+
default_gen = 50
|
| 146 |
+
default_ort = 5.0
|
| 147 |
+
|
| 148 |
# Grid Optimization Parameters
|
| 149 |
with st.expander("🔲 Grid Optimization", expanded=True):
|
| 150 |
+
st.markdown("**Spacing (meters):**")
|
| 151 |
c1, c2 = st.columns(2)
|
| 152 |
with c1:
|
| 153 |
+
spacing_min = st.number_input(
|
| 154 |
+
"Min",
|
| 155 |
+
min_value=10.0,
|
| 156 |
+
max_value=50.0,
|
| 157 |
+
value=20.0,
|
| 158 |
+
step=0.5,
|
| 159 |
+
help="Minimum grid spacing"
|
| 160 |
+
)
|
| 161 |
with c2:
|
| 162 |
+
spacing_max = st.number_input(
|
| 163 |
+
"Max",
|
| 164 |
+
min_value=10.0,
|
| 165 |
+
max_value=50.0,
|
| 166 |
+
value=30.0,
|
| 167 |
+
step=0.5,
|
| 168 |
+
help="Maximum grid spacing"
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
st.markdown("**Rotation Angle (degrees):**")
|
| 172 |
+
c1, c2 = st.columns(2)
|
| 173 |
+
with c1:
|
| 174 |
+
angle_min = st.number_input(
|
| 175 |
+
"Min Angle",
|
| 176 |
+
min_value=0.0,
|
| 177 |
+
max_value=90.0,
|
| 178 |
+
value=0.0,
|
| 179 |
+
step=1.0,
|
| 180 |
+
help="Minimum rotation angle"
|
| 181 |
+
)
|
| 182 |
+
with c2:
|
| 183 |
+
angle_max = st.number_input(
|
| 184 |
+
"Max Angle",
|
| 185 |
+
min_value=0.0,
|
| 186 |
+
max_value=90.0,
|
| 187 |
+
value=90.0,
|
| 188 |
+
step=1.0,
|
| 189 |
+
help="Maximum rotation angle"
|
| 190 |
+
)
|
| 191 |
|
| 192 |
# Subdivision Parameters
|
| 193 |
with st.expander("📐 Lot Subdivision", expanded=True):
|
| 194 |
+
st.markdown("**Lot Width (meters):**")
|
| 195 |
c1, c2, c3 = st.columns(3)
|
| 196 |
with c1:
|
| 197 |
+
min_lot_width = st.number_input(
|
| 198 |
+
"Min",
|
| 199 |
+
min_value=10.0,
|
| 200 |
+
max_value=40.0,
|
| 201 |
+
value=20.0,
|
| 202 |
+
step=1.0,
|
| 203 |
+
help="Minimum lot width"
|
| 204 |
+
)
|
| 205 |
with c2:
|
| 206 |
+
target_lot_width = st.number_input(
|
| 207 |
+
"Target",
|
| 208 |
+
min_value=20.0,
|
| 209 |
+
max_value=100.0,
|
| 210 |
+
value=40.0,
|
| 211 |
+
step=5.0,
|
| 212 |
+
help="Target lot width"
|
| 213 |
+
)
|
| 214 |
with c3:
|
| 215 |
+
max_lot_width = st.number_input(
|
| 216 |
+
"Max",
|
| 217 |
+
min_value=40.0,
|
| 218 |
+
max_value=120.0,
|
| 219 |
+
value=80.0,
|
| 220 |
+
step=5.0,
|
| 221 |
+
help="Maximum lot width"
|
| 222 |
+
)
|
| 223 |
|
| 224 |
+
# Optimization Parameters
|
| 225 |
+
with st.expander("⚡ Optimization", expanded=False):
|
| 226 |
+
st.markdown("**NSGA-II Genetic Algorithm:**")
|
| 227 |
+
c1, c2 = st.columns(2)
|
| 228 |
+
with c1:
|
| 229 |
+
population_size = st.number_input(
|
| 230 |
+
"Population Size",
|
| 231 |
+
min_value=20,
|
| 232 |
+
max_value=200,
|
| 233 |
+
value=default_pop,
|
| 234 |
+
step=10,
|
| 235 |
+
help="Number of solutions per generation"
|
| 236 |
+
)
|
| 237 |
+
with c2:
|
| 238 |
+
generations = st.number_input(
|
| 239 |
+
"Generations",
|
| 240 |
+
min_value=50,
|
| 241 |
+
max_value=500,
|
| 242 |
+
value=default_gen,
|
| 243 |
+
step=10,
|
| 244 |
+
help="Number of evolution iterations"
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
st.markdown("**OR-Tools Solver:**")
|
| 248 |
+
ortools_time_limit = st.number_input(
|
| 249 |
+
"Time per Block (seconds)",
|
| 250 |
+
min_value=0.1,
|
| 251 |
+
max_value=60.0,
|
| 252 |
+
value=default_ort,
|
| 253 |
+
step=0.1,
|
| 254 |
+
help="Maximum time for solving each block"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Show time estimate
|
| 258 |
+
est_time = (population_size * generations) / 50
|
| 259 |
+
if est_time > 60:
|
| 260 |
+
st.info(f"⏱️ Estimated time: ~{est_time//60:.0f} minutes")
|
| 261 |
+
else:
|
| 262 |
+
st.info(f"⏱️ Estimated time: ~{est_time:.0f} seconds")
|
| 263 |
|
| 264 |
+
if est_time > 600:
|
| 265 |
+
st.warning("⚠️ May timeout (>10 min). Consider reducing parameters.")
|
| 266 |
+
|
| 267 |
+
# Infrastructure Parameters
|
| 268 |
+
with st.expander("🏗️ Infrastructure", expanded=False):
|
| 269 |
+
road_width = st.number_input(
|
| 270 |
+
"Road Width (m)",
|
| 271 |
+
min_value=3.0,
|
| 272 |
+
max_value=10.0,
|
| 273 |
+
value=6.0,
|
| 274 |
+
step=0.5,
|
| 275 |
+
help="Width of roads between blocks"
|
| 276 |
+
)
|
| 277 |
+
block_depth = st.number_input(
|
| 278 |
+
"Block Depth (m)",
|
| 279 |
+
min_value=30.0,
|
| 280 |
+
max_value=100.0,
|
| 281 |
+
value=50.0,
|
| 282 |
+
step=5.0,
|
| 283 |
+
help="Depth of each block"
|
| 284 |
+
)
|
| 285 |
|
| 286 |
# ==================== COLUMN 2: Input & Action ====================
|
| 287 |
with col_action:
|
|
|
|
| 290 |
# Input method selection
|
| 291 |
input_method = st.radio(
|
| 292 |
"Input method:",
|
| 293 |
+
["Sample", "DXF Upload", "GeoJSON Upload", "Manual"],
|
| 294 |
+
horizontal=False
|
|
|
|
| 295 |
)
|
| 296 |
|
| 297 |
if input_method == "Sample":
|
| 298 |
# Predefined sample
|
| 299 |
sample_type = st.selectbox(
|
| 300 |
"Sample type:",
|
| 301 |
+
["Rectangle 100x100", "L-Shape", "Irregular", "Large Site"]
|
| 302 |
)
|
| 303 |
|
| 304 |
if sample_type == "Rectangle 100x100":
|
| 305 |
coords = [[[0, 0], [100, 0], [100, 100], [0, 100], [0, 0]]]
|
| 306 |
elif sample_type == "L-Shape":
|
| 307 |
coords = [[[0, 0], [60, 0], [60, 40], [40, 40], [40, 100], [0, 100], [0, 0]]]
|
| 308 |
+
elif sample_type == "Irregular":
|
| 309 |
coords = [[[0, 0], [80, 10], [100, 50], [90, 100], [20, 90], [0, 0]]]
|
| 310 |
+
else: # Large Site
|
| 311 |
+
coords = [[
|
| 312 |
+
[0, 0], [950, 50], [1000, 800], [400, 1100],
|
| 313 |
+
[100, 900], [-50, 400], [0, 0]
|
| 314 |
+
]]
|
| 315 |
|
| 316 |
st.session_state.land_plot = {
|
| 317 |
"type": "Polygon",
|
| 318 |
"coordinates": coords,
|
| 319 |
"properties": {"name": sample_type}
|
| 320 |
}
|
| 321 |
+
|
| 322 |
+
elif input_method == "DXF Upload":
|
| 323 |
+
st.info("📐 Upload DXF file containing site boundary (closed polyline)")
|
| 324 |
+
uploaded = st.file_uploader(
|
| 325 |
+
"DXF file",
|
| 326 |
+
type=['dxf'],
|
| 327 |
+
key="dxf_upload",
|
| 328 |
+
help="File should contain closed LWPOLYLINE or POLYLINE for site boundary"
|
| 329 |
+
)
|
| 330 |
|
| 331 |
+
if uploaded:
|
| 332 |
+
with st.spinner("⏳ Parsing DXF..."):
|
| 333 |
+
try:
|
| 334 |
+
# Upload to backend API
|
| 335 |
+
files = {"file": (uploaded.name, uploaded.getvalue(), "application/dxf")}
|
| 336 |
+
response = requests.post(f"{API_URL}/api/upload-dxf", files=files)
|
| 337 |
+
|
| 338 |
+
if response.status_code == 200:
|
| 339 |
+
data = response.json()
|
| 340 |
+
st.session_state.land_plot = data['polygon']
|
| 341 |
+
st.success(f"✅ {data['message']}")
|
| 342 |
+
st.info(f"📊 Area: {data['area']:.2f} m²")
|
| 343 |
+
else:
|
| 344 |
+
st.error(f"Failed to parse DXF: {response.text}")
|
| 345 |
+
st.session_state.land_plot = None
|
| 346 |
+
|
| 347 |
+
except Exception as e:
|
| 348 |
+
st.error(f"Error uploading DXF: {str(e)}")
|
| 349 |
+
st.session_state.land_plot = None
|
| 350 |
+
|
| 351 |
+
elif input_method == "GeoJSON Upload":
|
| 352 |
+
uploaded = st.file_uploader("GeoJSON file", type=['json', 'geojson'], key="geojson_upload")
|
| 353 |
if uploaded:
|
| 354 |
try:
|
| 355 |
data = json.load(uploaded)
|
|
|
|
| 357 |
st.session_state.land_plot = data['features'][0]['geometry']
|
| 358 |
else:
|
| 359 |
st.session_state.land_plot = data
|
| 360 |
+
st.success(f"✅ Loaded {uploaded.name}")
|
| 361 |
except Exception as e:
|
| 362 |
st.error(f"Invalid file: {e}")
|
| 363 |
+
st.session_state.land_plot = None
|
| 364 |
|
| 365 |
else: # Manual
|
| 366 |
coords_input = st.text_area(
|
| 367 |
"Coordinates (JSON):",
|
| 368 |
'''[
|
| 369 |
+
[0, 0],
|
| 370 |
+
[950, 50],
|
| 371 |
+
[1000, 800],
|
| 372 |
+
[400, 1100],
|
| 373 |
+
[100, 900],
|
| 374 |
+
[-50, 400],
|
| 375 |
+
[0, 0]
|
| 376 |
]''',
|
| 377 |
height=150
|
| 378 |
)
|
|
|
|
| 524 |
with p2:
|
| 525 |
st.info(f"📐 Angle: **{stats.get('optimal_angle', 0):.1f}°**")
|
| 526 |
|
| 527 |
+
p1, p2 = st.columns(2)
|
| 528 |
+
with p1:
|
| 529 |
+
st.info(f"🔲 Spacing: **{stats.get('optimal_spacing', 0):.1f}m**")
|
| 530 |
+
with p2:
|
| 531 |
+
st.info(f"📐 Angle: **{stats.get('optimal_angle', 0):.1f}°**")
|
| 532 |
+
|
| 533 |
+
# === Notebook-Style Visualization (Matplotlib) ===
|
| 534 |
+
st.markdown("### 🗺️ Master Plan Visualization")
|
| 535 |
+
|
| 536 |
+
def plot_notebook_style(result_data):
|
| 537 |
+
"""
|
| 538 |
+
Replicate the Detailed 1/500 Planning Plot.
|
| 539 |
+
Includes: Roads, Setbacks, Zoning, Loop Network, Transformers, Drainage.
|
| 540 |
+
"""
|
| 541 |
+
try:
|
| 542 |
+
# Setup figure
|
| 543 |
+
fig, ax = plt.subplots(figsize=(12, 12))
|
| 544 |
+
ax.set_aspect('equal')
|
| 545 |
+
ax.set_facecolor('#f0f0f0')
|
| 546 |
+
|
| 547 |
+
# Retrieve features from final layout (Stage 3 includes everything)
|
| 548 |
+
features = result_data.get('final_layout', {}).get('features', [])
|
| 549 |
+
|
| 550 |
+
# 1. Draw Roads & Sidewalks (Layer 0)
|
| 551 |
+
# We specifically look for type='road_network' or we draw the inverse using plot background if needed
|
| 552 |
+
# But our backend now sends 'road_network' feature
|
| 553 |
+
for f in features:
|
| 554 |
+
if f['properties'].get('type') == 'road_network':
|
| 555 |
+
geom = shape(f['geometry'])
|
| 556 |
+
if geom.is_empty: continue
|
| 557 |
+
if geom.geom_type == 'Polygon':
|
| 558 |
+
xs, ys = geom.exterior.xy
|
| 559 |
+
ax.fill(xs, ys, color='#607d8b', alpha=0.3, label='Hạ tầng giao thông')
|
| 560 |
+
elif geom.geom_type == 'MultiPolygon':
|
| 561 |
+
for poly in geom.geoms:
|
| 562 |
+
xs, ys = poly.exterior.xy
|
| 563 |
+
ax.fill(xs, ys, color='#607d8b', alpha=0.3)
|
| 564 |
+
|
| 565 |
+
# 2. Draw Commercial Lots & Setbacks (Layer 1)
|
| 566 |
+
for f in features:
|
| 567 |
+
props = f['properties']
|
| 568 |
+
ftype = props.get('type')
|
| 569 |
+
|
| 570 |
+
if ftype == 'lot':
|
| 571 |
+
poly = shape(f['geometry'])
|
| 572 |
+
xs, ys = poly.exterior.xy
|
| 573 |
+
ax.plot(xs, ys, color='black', linewidth=0.5)
|
| 574 |
+
ax.fill(xs, ys, color='#fff9c4', alpha=0.5) # Yellow
|
| 575 |
+
|
| 576 |
+
elif ftype == 'setback':
|
| 577 |
+
poly = shape(f['geometry'])
|
| 578 |
+
xs, ys = poly.exterior.xy
|
| 579 |
+
ax.plot(xs, ys, color='red', linestyle='--', linewidth=0.8, alpha=0.7)
|
| 580 |
+
|
| 581 |
+
# 3. Draw Service / Technical Areas (Layer 2)
|
| 582 |
+
for f in features:
|
| 583 |
+
props = f['properties']
|
| 584 |
+
ftype = props.get('type')
|
| 585 |
+
poly = shape(f['geometry'])
|
| 586 |
+
|
| 587 |
+
if ftype == 'xlnt':
|
| 588 |
+
xs, ys = poly.exterior.xy
|
| 589 |
+
ax.fill(xs, ys, color='#b2dfdb', alpha=0.9) # Cyan/Blue
|
| 590 |
+
ax.text(poly.centroid.x, poly.centroid.y, "XLNT", ha='center', fontsize=8, color='black', weight='bold')
|
| 591 |
+
elif ftype == 'service':
|
| 592 |
+
xs, ys = poly.exterior.xy
|
| 593 |
+
ax.fill(xs, ys, color='#d1c4e9', alpha=0.9) # Purple
|
| 594 |
+
ax.text(poly.centroid.x, poly.centroid.y, "Điều hành", ha='center', fontsize=8, color='black', weight='bold')
|
| 595 |
+
elif ftype == 'park':
|
| 596 |
+
xs, ys = poly.exterior.xy
|
| 597 |
+
ax.fill(xs, ys, color='#f6ffed', alpha=0.5) # Green
|
| 598 |
+
ax.plot(xs, ys, color='green', linewidth=0.5, linestyle=':')
|
| 599 |
+
|
| 600 |
+
# 4. Draw Electrical Infrastructure (Loop)
|
| 601 |
+
for f in features:
|
| 602 |
+
if f['properties'].get('type') == 'connection':
|
| 603 |
+
line = shape(f['geometry'])
|
| 604 |
+
xs, ys = line.xy
|
| 605 |
+
ax.plot(xs, ys, color='blue', linestyle='-', linewidth=0.5, alpha=0.4)
|
| 606 |
+
|
| 607 |
+
# 5. Draw Transformers
|
| 608 |
+
for f in features:
|
| 609 |
+
if f['properties'].get('type') == 'transformer':
|
| 610 |
+
pt = shape(f['geometry'])
|
| 611 |
+
ax.scatter(pt.x, pt.y, c='red', marker='^', s=100, zorder=10)
|
| 612 |
+
|
| 613 |
+
# 6. Draw Drainage (Arrows)
|
| 614 |
+
for i, f in enumerate([feat for feat in features if feat['properties'].get('type') == 'drainage']):
|
| 615 |
+
if i % 3 == 0: # Sample to avoid clutter
|
| 616 |
+
line = shape(f['geometry'])
|
| 617 |
+
# Shapely LineString to Arrow
|
| 618 |
+
start = line.coords[0]
|
| 619 |
+
end = line.coords[1]
|
| 620 |
+
dx = end[0] - start[0]
|
| 621 |
+
dy = end[1] - start[1]
|
| 622 |
+
ax.arrow(start[0], start[1], dx, dy, head_width=5, head_length=5, fc='cyan', ec='cyan', alpha=0.6)
|
| 623 |
+
|
| 624 |
+
# Title
|
| 625 |
+
ax.set_title("QUY HOẠCH CHI TIẾT 1/500 (PRODUCTION READY)\n"
|
| 626 |
+
"Bao gồm: Đường phân cấp, Vạt góc, Chỉ giới XD, Điện mạch vòng, Thoát nước tự chảy", fontsize=14)
|
| 627 |
+
|
| 628 |
+
# Custom Legend
|
| 629 |
+
from matplotlib.lines import Line2D
|
| 630 |
+
custom_lines = [Line2D([0], [0], color='#fff9c4', lw=4),
|
| 631 |
+
Line2D([0], [0], color='red', linestyle='--', lw=1),
|
| 632 |
+
Line2D([0], [0], color='#607d8b', lw=4),
|
| 633 |
+
Line2D([0], [0], color='blue', lw=1),
|
| 634 |
+
Line2D([0], [0], marker='^', color='w', markerfacecolor='red', markersize=10),
|
| 635 |
+
Line2D([0], [0], color='cyan', lw=1, marker='>')]
|
| 636 |
+
|
| 637 |
+
ax.legend(custom_lines, ['Đất CN', 'Chỉ giới XD (Setback)', 'Đường giao thông', 'Cáp điện ngầm (Loop)', 'Trạm biến áp', 'Hướng thoát nước'], loc='lower right')
|
| 638 |
+
|
| 639 |
+
plt.tight_layout()
|
| 640 |
+
return fig
|
| 641 |
+
except Exception as e:
|
| 642 |
+
st.error(f"Plotting error: {e}")
|
| 643 |
+
return None
|
| 644 |
+
|
| 645 |
+
# Display Plot
|
| 646 |
+
fig = plot_notebook_style(result)
|
| 647 |
+
if fig:
|
| 648 |
+
st.pyplot(fig)
|
| 649 |
|
| 650 |
+
# Visualization (Plotly)
|
| 651 |
+
stages = result.get('stages', [])
|
| 652 |
if len(stages) >= 2:
|
|
|
|
| 653 |
fig = make_subplots(
|
| 654 |
rows=1, cols=2,
|
| 655 |
subplot_titles=('Stage 1: Grid Optimization', 'Stage 2: Subdivision'),
|
|
|
|
| 721 |
|
| 722 |
# Download section
|
| 723 |
st.markdown("---")
|
| 724 |
+
st.markdown("**📥 Download Results:**")
|
| 725 |
+
|
| 726 |
+
d1, d2, d3 = st.columns(3)
|
| 727 |
+
|
| 728 |
with d1:
|
| 729 |
if result.get('final_layout'):
|
| 730 |
st.download_button(
|
| 731 |
+
"📄 GeoJSON",
|
| 732 |
data=json.dumps(result['final_layout'], indent=2),
|
| 733 |
file_name="layout.geojson",
|
| 734 |
mime="application/json",
|
| 735 |
use_container_width=True
|
| 736 |
)
|
| 737 |
+
|
| 738 |
with d2:
|
| 739 |
st.download_button(
|
| 740 |
+
"📊 Full Report",
|
| 741 |
data=json.dumps(result, indent=2),
|
| 742 |
file_name="report.json",
|
| 743 |
mime="application/json",
|
| 744 |
use_container_width=True
|
| 745 |
)
|
| 746 |
+
|
| 747 |
+
with d3:
|
| 748 |
+
# DXF Export button
|
| 749 |
+
if st.button("📐 Export DXF", use_container_width=True, key="export_dxf"):
|
| 750 |
+
with st.spinner("Generating DXF..."):
|
| 751 |
+
try:
|
| 752 |
+
response = requests.post(
|
| 753 |
+
f"{API_URL}/api/export-dxf",
|
| 754 |
+
json={"result": result}
|
| 755 |
+
)
|
| 756 |
+
|
| 757 |
+
if response.status_code == 200:
|
| 758 |
+
st.download_button(
|
| 759 |
+
"⬇️ Download DXF",
|
| 760 |
+
data=response.content,
|
| 761 |
+
file_name="land_redistribution.dxf",
|
| 762 |
+
mime="application/dxf",
|
| 763 |
+
use_container_width=True,
|
| 764 |
+
key="download_dxf"
|
| 765 |
+
)
|
| 766 |
+
else:
|
| 767 |
+
st.error("Failed to generate DXF")
|
| 768 |
+
except Exception as e:
|
| 769 |
+
st.error(f"DXF export error: {str(e)}")
|
algorithms/frontend/requirements.txt
CHANGED
|
@@ -5,3 +5,5 @@ folium==0.14.0
|
|
| 5 |
streamlit-folium==0.16.0
|
| 6 |
pandas==2.1.4
|
| 7 |
streamlit-drawable-canvas==0.9.3
|
|
|
|
|
|
|
|
|
| 5 |
streamlit-folium==0.16.0
|
| 6 |
pandas==2.1.4
|
| 7 |
streamlit-drawable-canvas==0.9.3
|
| 8 |
+
matplotlib==3.8.2
|
| 9 |
+
shapely==2.0.2
|