diff --git a/.gitignore b/.gitignore
index dd750cb..9091e25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,8 @@ __pycache__/
*.db
*.pkl
app.log
+src/nopywer/ortools_solverV1_with_inventory.py
+*.csv
+*.pdf
+telemetry data/PV Investment estimation.xlsx
+optimizer.log
diff --git a/README.md b/README.md
index d58a6df..c030c65 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,8 @@ export NOPYWER_INPUT=input.geojson
export NOPYWER_OUTPUT=output.geojson
export NOPYWER_INVENTORY=inventory.xlsx
nopywer-analyze
+
+They can be also included in a .env file at parent directory.
```
To start the companion API server:
diff --git a/nopywer_export.geojson b/nopywer_export.geojson
new file mode 100644
index 0000000..fa86cd1
--- /dev/null
+++ b/nopywer_export.geojson
@@ -0,0 +1,984 @@
+{
+ "type": "FeatureCollection",
+ "crs": {
+ "type": "name",
+ "properties": {
+ "name": "EPSG:4326"
+ }
+ },
+ "power_unit": "W",
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14046027066882047,
+ 41.70048109809566
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 1,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "Mini Camp"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1403126048615206,
+ 41.70021165965277
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 2,
+ "power": 1000.0,
+ "phase": "3",
+ "name": "EYAO"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13986458645846486,
+ 41.70012838821719
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 3,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "Glitch"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14001451226101178,
+ 41.69988172981668
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 4,
+ "power": 1000.0,
+ "phase": "1",
+ "name": "Belly Town"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14032067533151324,
+ 41.699387585580126
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 5,
+ "power": 1000.0,
+ "phase": "1",
+ "name": "PDR of Fiascostan"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14037095139108816,
+ 41.69906229826065
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 6,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "Mirage"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1402769285712588,
+ 41.69884015144679
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 7,
+ "power": 1400.0,
+ "phase": "3",
+ "name": "Wonderever"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13983096674250983,
+ 41.698769513641636
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 8,
+ "power": 3000.0,
+ "phase": "1",
+ "name": "Oasis Playground"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1394393620604287,
+ 41.69898872265349
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 9,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "Planet Pulpo"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1400824723373084,
+ 41.699631722152795
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 10,
+ "power": 1000.0,
+ "phase": "3",
+ "name": "Curious Creatures"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1403046266028607,
+ 41.700765329482905
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 11,
+ "power": 1000.0,
+ "phase": "1",
+ "name": "Glowy Owl"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14061462479929102,
+ 41.701104168536816
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 12,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "\nBarrio Espace\n"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14105640391758656,
+ 41.70179263095132
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 13,
+ "power": 4000.0,
+ "phase": "3",
+ "name": "Ran'Dome"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14100409056756252,
+ 41.70136886602274
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 14,
+ "power": 6800.0,
+ "phase": "1",
+ "name": "Desert Dessert"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1407273698750896,
+ 41.701997572463505
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 15,
+ "power": 20000.0,
+ "phase": "2",
+ "name": "Garden of Joy"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13944698019201643,
+ 41.70162190531119
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 17,
+ "power": 3680.0,
+ "phase": "1",
+ "name": "Barrio del Sol"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1391394682682699,
+ 41.69927405035179
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 19,
+ "power": 1000.0,
+ "phase": "2,3",
+ "name": "Olive Odissey"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13952647308927593,
+ 41.69998517673939
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 21,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "distro9"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1398948350326917,
+ 41.70057458012552
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 22,
+ "power": 0.0,
+ "phase": "",
+ "name": "generator"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13987204105721388,
+ 41.70115113523339
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 23,
+ "power": 0.0,
+ "phase": "",
+ "name": "distro1"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14052384215401315,
+ 41.70162166285516
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 24,
+ "power": 1000.0,
+ "phase": "2",
+ "name": "ZAZ"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13961203403388528,
+ 41.70130951714179
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 25,
+ "power": 1000.0,
+ "phase": "3",
+ "name": "RDTFGH"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13989622438430727,
+ 41.69911385446002
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 26,
+ "power": 1000.0,
+ "phase": "1",
+ "name": "distro8"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13951008153297134,
+ 41.70022595079415
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 28,
+ "power": 0.0,
+ "phase": "",
+ "name": "distro2"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.13988830557411927,
+ 41.701143908996386
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 30,
+ "power": 0.0,
+ "phase": null,
+ "name": "distro"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.14040007075315172,
+ 41.69928240812505
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 31,
+ "power": 0.0,
+ "phase": null,
+ "name": "JamHouse"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ -0.1399063474789402,
+ 41.69912586313198
+ ],
+ "type": "Point"
+ },
+ "properties": {
+ "fid": 32,
+ "power": 0.0,
+ "phase": null,
+ "name": "distro6"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1358634919732599,
+ 41.700722885105534
+ ],
+ [
+ -0.13589487185959379,
+ 41.700491356829055
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 38,
+ "id": null,
+ "notes": null,
+ "phase": 2,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13721678687118344,
+ 41.69897332773713
+ ],
+ [
+ -0.13723473774240005,
+ 41.699480301357305
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 50,
+ "id": null,
+ "notes": null,
+ "phase": 3,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.136946721189126,
+ 41.70058175298905
+ ],
+ [
+ -0.13696022057941495,
+ 41.70077074108049
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 68,
+ "id": null,
+ "notes": null,
+ "phase": 1,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13775783580145348,
+ 41.701017637026574
+ ],
+ [
+ -0.1377138674373942,
+ 41.701476697407855
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 73,
+ "id": null,
+ "notes": null,
+ "phase": 1,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13776074711101063,
+ 41.70040172314188
+ ],
+ [
+ -0.1383265065243817,
+ 41.70047734086633
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 77,
+ "id": null,
+ "notes": null,
+ "phase": 3,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1383660635519354,
+ 41.701110669984104
+ ],
+ [
+ -0.13867822792326492,
+ 41.70159538737178
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 78,
+ "id": null,
+ "notes": null,
+ "phase": 2,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13647667037934735,
+ 41.700389376705765
+ ],
+ [
+ -0.13654311634408184,
+ 41.70064621259186
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 80,
+ "id": null,
+ "notes": null,
+ "phase": 2,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1367241540902938,
+ 41.70023576191971
+ ],
+ [
+ -0.13647580663116396,
+ 41.70038758805697
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 82,
+ "id": null,
+ "notes": null,
+ "phase": 2,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13826592688444675,
+ 41.701472011492505
+ ],
+ [
+ -0.13836265394923009,
+ 41.70111121926144
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 89,
+ "id": null,
+ "notes": null,
+ "phase": 3,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13749588811049746,
+ 41.699105845853566
+ ],
+ [
+ -0.13723239955287345,
+ 41.69947916905125
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 90,
+ "id": null,
+ "notes": null,
+ "phase": 1,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1357408714998589,
+ 41.700355165532265
+ ],
+ [
+ -0.13589549609553478,
+ 41.70048969150146
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 91,
+ "id": null,
+ "notes": null,
+ "phase": 2,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13744505973057222,
+ 41.70067635384229
+ ],
+ [
+ -0.13711069462072983,
+ 41.700737144769114
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 92,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": 2.5,
+ "plugs&sockets": 16.0,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1377555218615635,
+ 41.700402571544025
+ ],
+ [
+ -0.1380753282011498,
+ 41.700557438031595
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 93,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13775686180885907,
+ 41.700402604944074
+ ],
+ [
+ -0.13793216684153273,
+ 41.7001356003461
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 94,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1377575602537182,
+ 41.7004026716304
+ ],
+ [
+ -0.13767942576414527,
+ 41.70010668154157
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 95,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1377578671371608,
+ 41.701016930964016
+ ],
+ [
+ -0.13748051264301162,
+ 41.70142233907045
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 96,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13738260788357232,
+ 41.70126408080498
+ ],
+ [
+ -0.13776072486584146,
+ 41.70101629535332
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 97,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13733529820089352,
+ 41.701104171330584
+ ],
+ [
+ -0.1377578671371608,
+ 41.701016930964016
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 98,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.13743084611499873,
+ 41.70096529812862
+ ],
+ [
+ -0.13775601421089068,
+ 41.70101617793353
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 99,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "coordinates": [
+ [
+ [
+ -0.1367370904032387,
+ 41.70022974030239
+ ],
+ [
+ -0.13661579870963406,
+ 41.69984596268292
+ ]
+ ]
+ ],
+ "type": "MultiLineString"
+ },
+ "properties": {
+ "fid": 100,
+ "id": null,
+ "notes": null,
+ "phase": null,
+ "area": null,
+ "plugs&sockets": null,
+ "length": 0.0
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/nopywer_export.geojson.png b/nopywer_export.geojson.png
new file mode 100644
index 0000000..10dd890
Binary files /dev/null and b/nopywer_export.geojson.png differ
diff --git a/nopywer_output_9evk5ki_.geojson.png b/nopywer_output_9evk5ki_.geojson.png
new file mode 100644
index 0000000..a488e71
Binary files /dev/null and b/nopywer_output_9evk5ki_.geojson.png differ
diff --git a/optimizer.log b/optimizer.log
new file mode 100644
index 0000000..f45b634
--- /dev/null
+++ b/optimizer.log
@@ -0,0 +1,82 @@
+05-18 18:52 nopywer.optimize_mixed INFO [HUB] scores: [('distro2', 48100.0), ('distro8', 37430.0), ('malfare', 31100.0), ('barrio support', 30770.0), ('le gratin', 25820.0)]
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro9: 35.0mm² (95.4A, 46m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro4: 2.5mm² (7.5A, 73m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro1: 6.0mm² (22.9A, 39m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro5-1: 35.0mm² (82.1A, 90m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro3: 16.0mm² (50.8A, 45m)
+05-18 18:52 nopywer.optimize_mixed WARNING [TIER] No suitable tier for generator\u2192distro8: flow=26830W, current=129.6A, d=88m — using largest tier (35.0mm²)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro8: 35.0mm² (129.6A, 88m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro7: 2.5mm² (0.0A, 87m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro6-1: 16.0mm² (22.6A, 129m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] generator\u2192distro2: 35.0mm² (96.0A, 57m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro9\u2192artinstallation1: 2.5mm² (4.8A, 21m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro9\u2192noinfo: 6.0mm² (23.9A, 26m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro9\u2192mon: 35.0mm² (66.7A, 52m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro4\u2192ohana haus: 2.5mm² (0.3A, 29m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro4\u2192peach please!: 2.5mm² (7.2A, 44m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro1\u2192kunsthaus: 6.0mm² (22.9A, 13m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro5-1\u2192jugaton: 2.5mm² (4.8A, 41m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro5-1\u2192mooncows: 6.0mm² (14.5A, 52m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro5-1\u2192camp cambio: 16.0mm² (62.8A, 61m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro3\u2192le gratin: 2.5mm² (14.5A, 36m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro3\u2192pink poney paradise: 2.5mm² (4.8A, 49m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro3\u2192dusty dreamers: 2.5mm² (4.8A, 60m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro3\u2192nobarrio: 6.0mm² (26.7A, 61m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro8\u2192la cantina: 16.0mm² (50.7A, 18m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro8\u2192production: 16.0mm² (62.8A, 24m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro8\u2192toolhaus: 6.0mm² (16.1A, 43m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro6-1\u2192hyperflow: 2.5mm² (4.8A, 53m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro6-1\u2192here & meow: 6.0mm² (17.8A, 54m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro2\u2192malfare: 16.0mm² (33.8A, 13m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro2\u2192werkhaus: 6.0mm² (25.5A, 22m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro2\u2192barrio support: 6.0mm² (17.4A, 27m)
+05-18 18:52 nopywer.optimize_mixed DEBUG [TIER] distro2\u2192welfare: 6.0mm² (19.3A, 27m)
+05-18 18:52 nopywer.optimize_mixed DEBUG Assigned tiers from MST: {('generator', 'distro9'): , ('generator', 'distro4'): , ('generator', 'distro1'): , ('generator', 'distro5-1'): , ('generator', 'distro3'): , ('generator', 'distro8'): , ('generator', 'distro7'): , ('generator', 'distro6-1'): , ('generator', 'distro2'): , ('distro9', 'artinstallation1'): , ('distro9', 'noinfo'): , ('distro9', 'mon'): , ('distro4', 'ohana haus'): , ('distro4', 'peach please!'): , ('distro1', 'kunsthaus'): , ('distro5-1', 'jugaton'): , ('distro5-1', 'mooncows'): , ('distro5-1', 'camp cambio'): , ('distro3', 'le gratin'): , ('distro3', 'pink poney paradise'): , ('distro3', 'dusty dreamers'): , ('distro3', 'nobarrio'): , ('distro8', 'la cantina'): , ('distro8', 'production'): , ('distro8', 'toolhaus'): , ('distro6-1', 'hyperflow'): , ('distro6-1', 'here & meow'): , ('distro2', 'malfare'): , ('distro2', 'werkhaus'): , ('distro2', 'barrio support'): , ('distro2', 'welfare'): }
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro8: 26830W \u2192 L0=8943W, L1=8943W, L2=8943W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro2: 19880W \u2192 L0=6627W, L1=6627W, L2=6627W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro9: 19740W \u2192 L0=6580W, L1=6580W, L2=6580W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro5-1: 17000W \u2192 L0=5667W, L1=5667W, L2=5667W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro3: 10520W \u2192 L0=5260W, L1=5260W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro1: 4730W \u2192 L2=4730W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] distro6-1: 4680W \u2192 L2=4680W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] production: 13000W \u2192 L0=4333W, L1=4333W, L2=4333W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] la cantina: 10490W \u2192 L1=5245W, L0=5245W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] mon: 13800W \u2192 L0=4600W, L1=4600W, L2=4600W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE SPLIT] camp cambio: 13000W \u2192 L0=4333W, L1=4333W, L2=4333W
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE] L0: 35462W (34.3%)
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE] L1: 36192W (35.0%)
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE] L2: 31727W (30.7%)
+05-18 18:52 nopywer.optimize_mixed INFO [PHASE] imbalance: 4465W (4.3%)
+05-18 18:52 nopywer.analyze INFO compute_distro_requirements...
+05-18 18:52 nopywer.analyze DEBUG noinfo:
+05-18 18:52 nopywer.analyze DEBUG mon:
+05-18 18:52 nopywer.analyze DEBUG malfare:
+05-18 18:52 nopywer.analyze DEBUG kunsthaus:
+05-18 18:52 nopywer.analyze DEBUG dusty dreamers:
+05-18 18:52 nopywer.analyze DEBUG mooncows:
+05-18 18:52 nopywer.analyze DEBUG jugaton:
+05-18 18:52 nopywer.analyze DEBUG camp cambio:
+05-18 18:52 nopywer.analyze DEBUG la cantina:
+05-18 18:52 nopywer.analyze DEBUG toolhaus:
+05-18 18:52 nopywer.analyze DEBUG production:
+05-18 18:52 nopywer.analyze DEBUG peach please!:
+05-18 18:52 nopywer.analyze DEBUG here & meow:
+05-18 18:52 nopywer.analyze DEBUG hyperflow:
+05-18 18:52 nopywer.analyze DEBUG generator:
+05-18 18:52 nopywer.analyze DEBUG distro9:
+05-18 18:52 nopywer.analyze DEBUG distro4:
+05-18 18:52 nopywer.analyze DEBUG distro1:
+05-18 18:52 nopywer.analyze DEBUG distro5-1:
+05-18 18:52 nopywer.analyze DEBUG distro3:
+05-18 18:52 nopywer.analyze DEBUG werkhaus:
+05-18 18:52 nopywer.analyze DEBUG nobarrio:
+05-18 18:52 nopywer.analyze DEBUG pink poney paradise:
+05-18 18:52 nopywer.analyze DEBUG le gratin:
+05-18 18:52 nopywer.analyze DEBUG distro8:
+05-18 18:52 nopywer.analyze DEBUG distro7:
+05-18 18:52 nopywer.analyze DEBUG distro6-1:
+05-18 18:52 nopywer.analyze DEBUG artinstallation1:
+05-18 18:52 nopywer.analyze DEBUG ohana haus:
+05-18 18:52 nopywer.analyze DEBUG welfare:
+05-18 18:52 nopywer.analyze DEBUG barrio support:
+05-18 18:52 nopywer.analyze DEBUG distro2:
diff --git a/orange_inventory.xlsx b/orange_inventory.xlsx
new file mode 100644
index 0000000..dd615eb
Binary files /dev/null and b/orange_inventory.xlsx differ
diff --git a/pyproject.toml b/pyproject.toml
index b9823d2..59706ce 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,6 +13,8 @@ dependencies = [
"pyproj>=3.7",
"typer>=0.24",
"uvicorn>=0.41",
+ "ortools",
+ "openpyxl",
]
[project.optional-dependencies]
diff --git a/src/nopywer/analyze.py b/src/nopywer/analyze.py
index 62738ef..96c0fa7 100644
--- a/src/nopywer/analyze.py
+++ b/src/nopywer/analyze.py
@@ -154,7 +154,7 @@ def _compute_voltage_drop(grid: PowerGrid, node_name: str | None = None) -> None
logger.debug(f"\t\t vdrop: {node.vdrop_percent:.1f}%")
if node.vdrop_percent > VDROP_THRESHOLD_PERCENT:
- logger.info(f"\t /!\\ vdrop of {node.vdrop_percent:.1f} percent at {node_name}")
+ logger.warning(f"\t /!\\ vdrop of {node.vdrop_percent:.1f} percent at {node_name}")
for child_name in node.children:
_compute_voltage_drop(grid, child_name)
diff --git a/src/nopywer/api.py b/src/nopywer/api.py
index fe42628..7289031 100644
--- a/src/nopywer/api.py
+++ b/src/nopywer/api.py
@@ -8,7 +8,11 @@
from .constants import EXTRA_CABLE_LENGTH_M
from .io import load_geojson
from .models import PowerGrid
-from .optimize import optimize_layout
+
+# from .optimize import optimize_layout
+from .ortools_solverV1 import optimize_layout
+from .optimize_mixed import optimize_layout
+
app = FastAPI(title="nopywer", version="1.0.0")
frontend_dir = Path(__file__).resolve().parent / "frontend"
@@ -35,12 +39,15 @@ async def disable_frontend_cache(request: Request, call_next):
class OptimizeRequest(BaseModel):
nodes_geojson: dict
extra_cable_m: float = EXTRA_CABLE_LENGTH_M
+ hub_discount: float = 0.7
+ radiality_factor: float = 0.3
class OptimizeResponse(BaseModel):
cables_geojson: dict
total_cable_length_m: float
num_cables: int
+ phase_loads: dict[int, float]
@app.post("/api/v1/optimize", response_model=OptimizeResponse)
@@ -58,7 +65,10 @@ def optimize(req: OptimizeRequest):
if not loads:
raise HTTPException(400, "At least one load is required")
- grid = optimize_layout(grid, extra_cable_m=req.extra_cable_m)
+ grid = optimize_layout(grid, extra_cable_m=req.extra_cable_m,
+ hub_discount=req.hub_discount,
+ radiality_factor=req.radiality_factor,
+ )
return OptimizeResponse(
cables_geojson=grid.to_geojson(),
@@ -67,6 +77,7 @@ def optimize(req: OptimizeRequest):
1,
),
num_cables=len(grid.cables),
+ phase_loads=grid.phase_loads,
)
diff --git a/src/nopywer/cli.py b/src/nopywer/cli.py
index 06e0a26..ca8b0b8 100644
--- a/src/nopywer/cli.py
+++ b/src/nopywer/cli.py
@@ -45,12 +45,14 @@ def analyze_grid(
if verbose:
logging.basicConfig(level=logging.INFO)
- grid = PowerGrid.from_geojson(input)
+ if type(input) is str:
+ grid = PowerGrid.from_geojson(input)
+ elif type(input) is PowerGrid:
+ grid = input
analyze(grid)
if verbose:
print_grid_info(grid.nodes, grid.cables, grid.tree, grid.generator)
-
if inventory_file:
inventory.choose_cables(inventory_file, grid.cables)
inventory.choose_distros(inventory_file, grid.nodes)
diff --git a/src/nopywer/debugger.py b/src/nopywer/debugger.py
new file mode 100644
index 0000000..c904278
--- /dev/null
+++ b/src/nopywer/debugger.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+
+from fastapi import HTTPException
+from pydantic import BaseModel
+
+from nopywer.api import OptimizeRequest
+from nopywer.io import load_geojson
+from nopywer.models import PowerGrid
+
+# from nopywer.optimize import optimize_layout
+# from nopywer.ortools_solverV1 import optimize_layout
+from nopywer.optimize_mixed import optimize_layout
+
+class OptimizeResponse(BaseModel):
+ cables_geojson: dict
+ total_cable_length_m: float
+ num_cables: int
+ phase_loads: dict[int, float]
+
+
+def optimize(req: OptimizeRequest):
+ nodes, _ = load_geojson(req.nodes_geojson)
+ if not nodes:
+ raise HTTPException(400, "No valid nodes found in GeoJSON")
+
+ try:
+ grid = PowerGrid(nodes=nodes, cables={})
+ except ValueError as exc:
+ raise HTTPException(400, str(exc))
+
+ loads = [n for n in grid.nodes.values() if not n.is_generator]
+ if not loads:
+ raise HTTPException(400, "At least one load is required")
+
+ grid = optimize_layout(grid, extra_cable_m=req.extra_cable_m,
+ hub_discount=0.7, radiality_factor=0.3)
+
+ return OptimizeResponse(
+ cables_geojson=grid.to_geojson(),
+ total_cable_length_m=round(
+ sum(c.length_m for c in grid.cables.values()),
+ 1,
+ ),
+ num_cables=len(grid.cables),
+ phase_loads=grid.phase_loads,
+ )
+
+
+if __name__ == "__main__":
+ import json
+
+ with open(Path(__file__).parent.parent.parent / "tests/fixtures/export.geojson") as f:
+ nodes_geojson = json.load(f)
+ req = OptimizeRequest(nodes_geojson=nodes_geojson)
+ res = optimize(req)
+ print(json.dumps(res.dict(), indent=2))
diff --git a/src/nopywer/frontend/app.js b/src/nopywer/frontend/app.js
index 60039a4..36685f4 100644
--- a/src/nopywer/frontend/app.js
+++ b/src/nopywer/frontend/app.js
@@ -23,14 +23,26 @@
const nodesLayer = L.layerGroup().addTo(map);
const optimizedCablesLayer = L.layerGroup().addTo(map);
+ const nodeMarkers = [];
+ let highlightedMarkers = [];
+
let nodesGeojson = null;
let defaultBounds = null;
- function setMessage(text, isError) {
- messageEl.textContent = text;
- messageEl.style.color = isError ? "#fca5a5" : "#cbd5e1";
+ // function setMessage(text, isError) {
+ // messageEl.textContent = text;
+ // messageEl.style.color = isError ? "#fca5a5" : "#cbd5e1";
+ // }
+ function setMessage(html, isError) {
+ messageEl.innerHTML = html; // ✅ allows
and
+ messageEl.style.color = isError ? "#fca5a5" : "#cbd5e1";
}
+
+ function capitalizeName(name) {
+ return name.replace(/\b\w/g, c => c.toUpperCase());
+ }
+
function setOptimizerStatus(text) {
optimizerStatusEl.textContent = text;
}
@@ -43,6 +55,113 @@
});
}
+ function clearNodeHighlight() {
+ highlightedMarkers.forEach((marker) => {
+ if (marker._originalStyle) {
+ marker.setStyle(marker._originalStyle);
+ }
+ });
+ highlightedMarkers = [];
+ }
+
+ function highlightNodeMarkers(markers) {
+ clearNodeHighlight();
+ if (!markers.length) {
+ return;
+ }
+
+ const bounds = L.latLngBounds(markers.map((marker) => marker.getLatLng()));
+ markers.forEach((marker) => {
+ marker.setStyle({
+ radius: Math.max(marker.options.radius, marker.options.radius * 1.5),
+ weight: Math.max(marker.options.weight, 4),
+ color: "#2563eb",
+ fillColor: "#60a5fa",
+ fillOpacity: 1,
+ opacity: 1,
+ });
+ highlightedMarkers.push(marker);
+ });
+
+ map.fitBounds(bounds.pad(0.15));
+ markers[0].openPopup();
+ }
+
+ function searchNodeByName() {
+ const input = document.getElementById("node-search-input");
+ if (!input) {
+ return;
+ }
+
+ const query = input.value.trim().toLowerCase();
+ clearNodeHighlight();
+
+ if (!query) {
+ setMessage("Enter a node name to find it on the map.", false);
+ return;
+ }
+
+ const matches = nodeMarkers
+ .filter((entry) => entry.name.toLowerCase().includes(query))
+ .map((entry) => entry.layer);
+
+ if (!matches.length) {
+ setMessage('No nodes found matching "' + input.value + '".', true);
+ return;
+ }
+
+ setMessage('Found ' + matches.length + ' node' + (matches.length === 1 ? '' : 's') + '.', false);
+ highlightNodeMarkers(matches);
+ }
+
+ const SearchControl = L.Control.extend({
+ onAdd: function () {
+ const container = L.DomUtil.create("div", "leaflet-bar leaflet-control leaflet-control-custom");
+ container.style.background = "#ffffffee";
+ container.style.padding = "6px 8px";
+ container.style.borderRadius = "4px";
+ container.style.boxShadow = "0 1px 4px rgba(0,0,0,0.3)";
+ container.style.minWidth = "220px";
+ container.style.fontFamily = "sans-serif";
+ container.style.fontSize = "13px";
+
+ const input = L.DomUtil.create("input", "", container);
+ input.type = "search";
+ input.placeholder = "Search node name...";
+ input.style.width = "100%";
+ input.style.marginBottom = "5px";
+ input.style.padding = "4px 6px";
+ input.style.border = "1px solid #ccc";
+ input.style.borderRadius = "3px";
+ input.id = "node-search-input";
+
+ const button = L.DomUtil.create("button", "", container);
+ button.type = "button";
+ button.textContent = "Find";
+ button.style.width = "100%";
+ button.style.padding = "4px 6px";
+ button.style.border = "1px solid #666";
+ button.style.borderRadius = "3px";
+ button.style.background = "#374151";
+ button.style.color = "#f8fafc";
+ button.style.cursor = "pointer";
+ button.id = "node-search-button";
+
+ L.DomEvent.disableClickPropagation(container);
+ L.DomEvent.on(button, "click", searchNodeByName);
+ L.DomEvent.on(input, "keydown", function (evt) {
+ if (evt.key === "Enter") {
+ evt.preventDefault();
+ searchNodeByName();
+ }
+ });
+
+ return container;
+ },
+ });
+
+ map.addControl(new SearchControl({ position: "topright" }));
+
function isGenerator(feature) {
const name = String(feature.properties && feature.properties.name ? feature.properties.name : "");
return name.toLowerCase().includes("generator");
@@ -65,25 +184,38 @@
const generator = isGenerator(feature);
const name = feature.properties && feature.properties.name ? feature.properties.name : "unnamed";
const power = feature.properties && typeof feature.properties.power === "number"
- ? feature.properties.power / 1000
- : 0;
-
- L.circleMarker(latlng, {
- radius: generator ? 8 : 6,
+ ? feature.properties.power / 1000
+ : 0;
+
+ const phaseRaw = feature.properties?.phase;
+ const phase = phaseRaw != null && phaseRaw !== ""
+ ? parseInt(phaseRaw, 10)
+ : null;
+
+ const phaseLabel = phase !== null && !isNaN(phase) ? `L${phase + 1}` : "No phase assigned";
+ const phaseCol = phaseColor(phase);
+ // Scale radius proportionally to power (min 4, max 20)
+ const radius = Math.max(4, Math.min(30, 4 + power * 1));
+ const markerOptions = {
+ radius: generator ? Math.max(radius, 8) : radius,
weight: generator ? 3 : 2,
- color: generator ? "#ef4444" : "#a16207",
- fillColor: generator ? "#f87171" : "#facc15",
+ color: "#eecb6b",
+ fillColor: generator ? "#f87171" : phaseCol,
+ // fillColor: generator ? "#f87171" : "#facc15",
fillOpacity: 0.95,
- })
- .bindPopup(
- "" +
- name +
- "
" +
- "Power: " +
- power.toFixed(2) +
- " kW",
- )
+ opacity: 1,
+ };
+
+ const marker = L.circleMarker(latlng, markerOptions)
+ .bindPopup(`
+ ${capitalizeName(name)}
+ Power: ${power.toFixed(2)} kW
+ Phase: ${phaseLabel}
+ `)
.addTo(nodesLayer);
+ marker._originalStyle = markerOptions;
+ marker._nodeName = name;
+ nodeMarkers.push({ name, layer: marker });
bounds.push(latlng);
});
@@ -100,67 +232,132 @@
const plugs = feature.properties && feature.properties.plugs_and_sockets_a
? feature.properties.plugs_and_sockets_a
: 16;
+ const phase = feature.properties?.phase ?? null;
+ const phaseCol = phaseColor(phase);
if (plugs >= 63) {
- return { color: "#dc2626", weight: 6 };
+ return {color: phaseCol, weight: 10 };
}
if (plugs >= 32) {
- return { color: "#ea580c", weight: 5 };
+ return { color: phaseCol, weight: 7 };
}
- return { color: "#f59e0b", weight: 4 };
+ return { color: phaseCol, weight: 3 };
+ }
+
+ function getCableWeight(plugs) {
+ if (plugs >= 63) return 8;
+ if (plugs >= 32) return 6;
+ return 4;
+ }
+ // function getCableColor(plugs) {
+ // if (plugs >= 63) return "#dc2626";
+ // if (plugs >= 32) return "#ea580c";
+ // return "#f59e0b";
+ // }
+
+ function phaseColor(phase) {
+ const p = phase !== null && phase !== "" ? parseInt(phase, 10) : null;
+ const colors = { 0: "#000000dc", 1: "#77261c", 2: "#7a7a7a", 3: "#ff00a6" };
+ return p !== null && !isNaN(p) ? (colors[p] ?? "#f8f7f7") : "#facc15";
}
function renderOptimizedCables(cablesGeojson) {
- optimizedCablesLayer.clearLayers();
+ console.log(cablesGeojson.features[0].properties);
+
+ optimizedCablesLayer.clearLayers();
- const cableLayer = L.geoJSON(cablesGeojson, {
- filter: function (feature) {
- return feature.geometry && feature.geometry.type === "LineString";
- },
- style: function (feature) {
- const style = cableStyle(feature);
+ const filter = function (feature) {
+ return feature.geometry && feature.geometry.type === "LineString";
+ };
+
+ const cableLayer = L.geoJSON(cablesGeojson, {
+ filter,
+ style: function (feature) {
+ const style = cableStyle(feature);
return {
color: style.color,
weight: style.weight,
- opacity: 0.95,
+ opacity: 1,
};
- },
- onEachFeature: function (feature, layer) {
- const props = feature.properties || {};
- const details =
- "" +
- (props.from || "?") +
- " → " +
- (props.to || "?") +
- "
" +
- "Length: " +
- String(props.length_m ?? "?") +
- " m
" +
- "Cable: " +
- String(props.cable_type || "?") +
- "
" +
- "Current: " +
- String(props.current_a ?? "?") +
- " A
" +
- "Load: " +
- String(props.cum_power_kw ?? "?") +
- " kW";
-
- layer.bindPopup(details);
- layer.bindTooltip(details, {
- sticky: true,
- direction: "top",
- className: "cable-tooltip",
- });
- },
- });
+ },
+ onEachFeature: function (feature, layer) {
+ const props = feature.properties || {};
+ const phaseRaw = props.phase ?? null;
+ const phase = phaseRaw !== null && phaseRaw !== "" ? parseInt(phaseRaw, 10) : null;
+ const plugs = props.plugs_and_sockets_a ?? 16;
+
+ const vdropStr = props.vdrop_pct !== null && props.vdrop_pct !== undefined
+ ? (() => {
+ const vdrop = props.vdrop_pct;
+ const vdropColor = vdrop > 5 ? "#e74c3c" : vdrop > 3 ? "#f39c12" : "#2ecc71";
+ return `â–¼ ${vdrop.toFixed(1)}%`;
+ })()
+ : "";
+
+
+ const phaseLabel =
+ phase === null || isNaN(phase) ? "?"
+ : phase === 3 ? "Triphasic"
+ : "L" + (phase + 1);
+
+ layer.bindPopup(
+ `${capitalizeName(props.from || "?")} → ${capitalizeName(props.to || "?")}
+ Phase: ${phaseLabel}
+ Cable: ${plugs}A
+ Length: ${props.length_m ?? "?"} m
+ Current: ${props.current_a ?? "?"} A
+ Load: ${props.cum_power_kw ?? "?"} kW`
+ );
+ },
+});
+
+cableLayer.addTo(optimizedCablesLayer);
+ bringNodesToFront();
+}
+function updateNodePhasesFromResponse(nodeFeatures) {
+ nodesLayer.clearLayers();
+ nodeMarkers.length = 0;
+ const seen = new Set();
+
+ nodeFeatures.forEach((feature) => {
+ if (!feature.geometry || feature.geometry.type !== "Point") return;
+
+ const coords = feature.geometry.coordinates;
+ const latlng = [coords[1], coords[0]];
+
+ const name = feature.properties?.name ?? "unnamed";
+ if (seen.has(name)) {
+ console.warn("Duplicate node skipped:", name);
+ return;
+ }
+ seen.add(name);
+
+ const phase = feature.properties?.phase ?? null;
+ const power = (feature.properties?.power_watts ?? 0) / 1000;
+ const nodeColor = phaseColor(phase);
+
+ const phaseLabel = phase !== null && !isNaN(phase) ? `L${phase + 1}` : "No phase";
+ const generator = name.toLowerCase().includes("generator");
+ const radius = Math.max(4, Math.min(20, 4 + power));
+
+ const markerOptions = {
+ radius: generator ? Math.max(radius, 8) : radius,
+ weight: 3,
+ color: "#eecb6b",
+ fillColor: nodeColor,
+ fillOpacity: 0.95,
+ opacity: 1,
+ };
- cableLayer.addTo(optimizedCablesLayer);
- if (cableLayer.getLayers().length > 0) {
- map.fitBounds(cableLayer.getBounds().pad(0.08));
- }
- bringNodesToFront();
- }
+ const marker = L.circleMarker(latlng, markerOptions)
+ .bindPopup(`${capitalizeName(name)}
Power: ${power.toFixed(2)} kW
Phase: ${phaseLabel}`)
+ .addTo(nodesLayer);
+
+ marker._originalStyle = markerOptions;
+ nodeMarkers.push({ name, layer: marker });
+ });
+
+}
function buildNodesGeojson() {
if (!nodesGeojson || !Array.isArray(nodesGeojson.features)) {
@@ -175,28 +372,58 @@
properties: {
name: feature.properties.name,
power: feature.properties.power,
+ phase: feature.properties.phase ?? null,
},
})),
};
}
- async function loadNodes() {
- const response = await fetch(DATASET_URL);
- if (!response.ok) {
- throw new Error("Bundled power nodes dataset is unavailable");
+ async function loadNodes(file = null) {
+ if (file) {
+ const text = await file.text();
+ nodesGeojson = JSON.parse(text);
+ setMessage("Loaded custom dataset.", false);
+ } else {
+ const response = await fetch(DATASET_URL);
+ if (!response.ok) {
+ throw new Error("Bundled power nodes dataset is unavailable");
+ }
+ nodesGeojson = await response.json();
+ setMessage("Loaded the minimal power-only dataset bundled with nopywer.", false);
}
-
- nodesGeojson = await response.json();
const features = Array.isArray(nodesGeojson.features) ? nodesGeojson.features : [];
nodeCountEl.textContent = String(features.length);
addNodes(features);
- setMessage("Loaded the minimal power-only dataset bundled with nopywer.", false);
}
+ const uploadButton = document.getElementById("upload-button");
+ const uploadInput = document.getElementById("upload-input");
+
+ uploadButton.addEventListener("click", () => uploadInput.click());
+
+ uploadInput.addEventListener("change", function () {
+ const file = this.files?.[0];
+ if (!file) return;
+
+ // Reset state before loading new nodes
+ optimizedCablesLayer.clearLayers();
+ nodesLayer.clearLayers();
+ nodeMarkers.length = 0;
+ setOptimizerStatus("idle");
+ loadNodes(file).catch((error) => {
+ nodeCountEl.textContent = "0";
+ setOptimizerStatus("error");
+ setMessage(escapeHtml(error.message) || "Failed to load file", true);
+ });
+
+ this.value = ""; // ✅ allows re-uploading the same file
+ });
async function runOptimization() {
optimizeButton.disabled = true;
setOptimizerStatus("running");
setMessage("Optimizing the current bundled power nodes dataset...", false);
+ const hubDiscount = parseFloat(document.getElementById("hub-discount").value);
+ const radialityFactor = parseFloat(document.getElementById("radiality-factor").value);
try {
const response = await fetch(OPTIMIZE_URL, {
@@ -207,6 +434,8 @@
body: JSON.stringify({
nodes_geojson: buildNodesGeojson(),
extra_cable_m: 10,
+ hub_discount: hubDiscount,
+ radiality_factor: radialityFactor,
}),
});
@@ -216,19 +445,26 @@
}
renderOptimizedCables(payload.cables_geojson);
+ updateNodePhasesFromResponse(
+ payload.cables_geojson.features.filter(f => f.geometry.type === "Point"));
setOptimizerStatus("done");
+ bringNodesToFront();
setMessage(
- "Computed and drew " +
- payload.num_cables +
- " cables for " +
- Math.round(payload.total_cable_length_m) +
- " m total.",
- false,
+ `Computed and drew ${payload.num_cables} cables for ${Math.round(payload.total_cable_length_m)} m total — ` +
+ `Phase loads: L1=${Math.round(payload.phase_loads[0])} kW, ` +
+ `L2=${Math.round(payload.phase_loads[1])} kW, ` +
+ `L3=${Math.round(payload.phase_loads[2])} kW`,
+ false
);
- } catch (error) {
+
+ } catch (error) {
setOptimizerStatus("error");
- setMessage(error.message || "Optimization failed", true);
- } finally {
+ const line = error.stack
+ ? `
${escapeHtml(error.stack.split("\n")[1]?.trim() ?? "")}`
+ : "";
+ setMessage((escapeHtml(error.message) || "Optimization failed") + line, true);
+ }
+ finally {
optimizeButton.disabled = false;
}
}
diff --git a/src/nopywer/frontend/data/power-nodes.geojson b/src/nopywer/frontend/data/power-nodes.geojson
index 2328905..61483f9 100644
--- a/src/nopywer/frontend/data/power-nodes.geojson
+++ b/src/nopywer/frontend/data/power-nodes.geojson
@@ -319,8 +319,8 @@
]
},
"properties": {
- "name": "Gloria Garden",
- "power": 200.0
+ "name": "distro example",
+ "power": 0.0
}
},
{
@@ -504,216 +504,6 @@
"name": "Dreamdust Field",
"power": 3680.0
}
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011537103633567,
- 41.69234806304274
- ]
- },
- "properties": {
- "name": "Practice Patch",
- "power": 0.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011918685307272,
- 41.69233028850832
- ]
- },
- "properties": {
- "name": "Ninth Lantern Well",
- "power": 0.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011454117307237,
- 41.69206124659373
- ]
- },
- "properties": {
- "name": "Roundbelly Row",
- "power": 1750.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.01038655752624,
- 41.69173572377932
- ]
- },
- "properties": {
- "name": "Question Trail",
- "power": 1750.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.010501165300565,
- 41.69218235691341
- ]
- },
- "properties": {
- "name": "Tranquil Grove",
- "power": 1750.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.010766203068702,
- 41.69146748741722
- ]
- },
- "properties": {
- "name": "Wonder Hollow",
- "power": 1750.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011262077140324,
- 41.69148675626195
- ]
- },
- "properties": {
- "name": "Playground Glade",
- "power": 1750.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011383061767252,
- 41.69185487542429
- ]
- },
- "properties": {
- "name": "Octopus Garden",
- "power": 0.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.010967332089935,
- 41.69193901038799
- ]
- },
- "properties": {
- "name": "Pebble Depot Six",
- "power": 0.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.010941211222839,
- 41.69189210024385
- ]
- },
- "properties": {
- "name": "Lantern House Six",
- "power": 1000.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011254531115862,
- 41.69252267281408
- ]
- },
- "properties": {
- "name": "Second Pump House",
- "power": 1000.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011064596212925,
- 41.69292156377749
- ]
- },
- "properties": {
- "name": "Glimmer Glen",
- "power": 1750.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.010981512031082,
- 41.69253687244502
- ]
- },
- "properties": {
- "name": "Curious Hollow",
- "power": 8800.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011275265114728,
- 41.69248508605406
- ]
- },
- "properties": {
- "name": "Fourth Pump House",
- "power": 1000.0
- }
- },
- {
- "type": "Feature",
- "geometry": {
- "type": "Point",
- "coordinates": [
- 0.011861951718222,
- 41.69255180775979
- ]
- },
- "properties": {
- "name": "Tenth Pump House",
- "power": 1000.0
- }
}
]
-}
+}
\ No newline at end of file
diff --git a/src/nopywer/frontend/favicon.ico b/src/nopywer/frontend/favicon.ico
new file mode 100644
index 0000000..0e5b65f
Binary files /dev/null and b/src/nopywer/frontend/favicon.ico differ
diff --git a/src/nopywer/frontend/index.html b/src/nopywer/frontend/index.html
index 847279f..e42f13a 100644
--- a/src/nopywer/frontend/index.html
+++ b/src/nopywer/frontend/index.html
@@ -20,7 +20,20 @@ Frontend for development only
Minimal embedded map for nopywer.
diff --git a/src/nopywer/inventory.py b/src/nopywer/inventory.py
index f89887c..08a6549 100644
--- a/src/nopywer/inventory.py
+++ b/src/nopywer/inventory.py
@@ -27,7 +27,6 @@ def find_combinations(arr: list, target_sum, th: float = 5.0):
def choose_cables(inventory_file: str, cables_dict: dict) -> None:
- verbose = 1
unmatched = []
logger.info(f"\nReading cables inventory from {inventory_file}")
df = pd.read_excel(
@@ -36,23 +35,11 @@ def choose_cables(inventory_file: str, cables_dict: dict) -> None:
skiprows=0,
)
- if verbose >= 3:
- logger.debug(f"\t {df}")
-
sorted_cables = sorted(cables_dict.values(), key=lambda c: c.length_m, reverse=True)
for idx, cable in enumerate(sorted_cables):
- if verbose >= 2:
- logger.debug(
- f"\n\t\t\t taking care of cable {idx + 1}/{len(sorted_cables)}, "
- f"length {cable.length_m} m"
- )
-
n_phases = 3 if cable.plugs_and_sockets_a > 16 else 1
- if verbose >= 2:
- logger.debug(f" n phases: {n_phases}")
-
compatible_rows = (
(df["number of phases"] == n_phases)
& (df["plugs&sockets [A]"] == cable.plugs_and_sockets_a)
@@ -60,13 +47,10 @@ def choose_cables(inventory_file: str, cables_dict: dict) -> None:
)
compatible_df = df[compatible_rows]
- if verbose >= 2:
- logger.debug(f"\t\t\t compatible cables dataframe: \n {compatible_df}")
-
comb = None
if compatible_df.empty:
- if verbose >= 2:
- logger.debug("DataFrame is empty --> no compatible cables in inventory!")
+ pass
+
else:
list_of_cables = [
length
@@ -76,18 +60,13 @@ def choose_cables(inventory_file: str, cables_dict: dict) -> None:
target_sum = cable.length_m
comb = find_combinations(list_of_cables, target_sum)
- if comb is None or verbose:
+ if comb is None:
nodes = [cable.from_node, cable.to_node]
- logger.debug(f"\t\t\t cable {nodes}: {comb}")
-
if comb is not None:
for c in comb:
- df.loc[compatible_rows & (df["length [m]"] == c), "quantity"] -= 1
- if verbose >= 2:
- remaining = df.loc[
- compatible_rows & (df["length [m]"] == c), "quantity"
- ].values
- logger.debug(f"\t\t\t qty of {c}m remaining: {remaining}")
+ df.loc[compatible_rows & (df["length [m]"] == c), "quantity"] -= (
+ 1 # One cable is used
+ )
if comb is None:
unmatched.append(cable)
@@ -99,7 +78,7 @@ def choose_cables(inventory_file: str, cables_dict: dict) -> None:
f"\t {unm.plugs_and_sockets_a}{'A':.<4} ({unm.length_m:.0f}m) {'-'.join(nodes)} "
)
- return None
+ return unmatched
def parse_distro_req(req: str) -> tuple[str, float]:
diff --git a/src/nopywer/models.py b/src/nopywer/models.py
index 17006ca..0ea47e0 100644
--- a/src/nopywer/models.py
+++ b/src/nopywer/models.py
@@ -4,7 +4,7 @@
import numpy as np
-from .constants import PF, RHO_COPPER, V0
+from .constants import PF, RHO_COPPER, T, T0, V0
@dataclass
@@ -14,7 +14,7 @@ class PowerNode:
lat: float
power_watts: float = 0.0
is_generator: bool = False
- phase: int | str | list | None = None
+ phase: int = 0
parent: str | None = None
children: dict[str, str] = field(default_factory=dict)
@@ -56,6 +56,7 @@ def to_geojson(self) -> dict:
"vdrop_percent": self.vdrop_percent,
"i_sc_ka": self.i_sc_ka,
"distro": self.distro,
+ "phase": self.phase,
},
}
@@ -66,7 +67,7 @@ class Cable:
length_m: float
area_mm2: float = 2.5
plugs_and_sockets_a: float = 16.0
- phase: int | str | list | None = None
+ phase: int = 0
from_node: str = ""
to_node: str = ""
@@ -91,8 +92,10 @@ def __setattr__(self, name, value):
@property
def resistance(self) -> float:
- return RHO_COPPER * self.length_m / self.area_mm2
-
+ # return RHO_COPPER * self.length_m / self.area_mm2
+ return RHO_COPPER*(1+0.0393*(T-T0)) * self.length_m / self.area_mm2 #Temperature correction for 50°C, see https://en.wikipedia.org/wiki/Electrical_resistivity_and_conductivity#Temperature_dependence
+
+
def to_geojson(self) -> dict:
max_current = max(self.current_per_phase) if self.current_per_phase else 0.0
cum_power_w = max_current * V0 * PF * type(self).num_phases
@@ -117,6 +120,7 @@ def to_geojson(self) -> dict:
"current_a": round(max_current, 1),
"cum_power_kw": round(cum_power_w / 1000, 2),
"vdrop_volts": self.vdrop_volts,
+ "phase": self.phase,
},
}
@@ -128,6 +132,7 @@ class Cable16A(Cable):
tier_cost: ClassVar[float] = 1.0
num_phases: ClassVar[int] = 1
max_current_a: ClassVar[int] = 16
+ area_mm2: float = 2.5
@dataclass
@@ -144,7 +149,7 @@ class Cable63A(Cable):
tier_cost: ClassVar[float] = 8.0
num_phases: ClassVar[int] = 3
max_current_a: ClassVar[int] = 63
- area_mm2: float = 16.0
+ area_mm2: float = 16.0
plugs_and_sockets_a: float = 63.0
@@ -162,7 +167,6 @@ class Cable125A(Cable):
def pick_cable_for(power_watts: float) -> type[Cable]:
"""Pick the smallest cable type that can handle the given power.
-
Each type is checked with: I = P / (num_phases x V0 x PF)
"""
for cable_cls in _CABLE_TYPES:
diff --git a/src/nopywer/optimization_tools.py b/src/nopywer/optimization_tools.py
new file mode 100644
index 0000000..c21777b
--- /dev/null
+++ b/src/nopywer/optimization_tools.py
@@ -0,0 +1,272 @@
+import logging
+
+import openpyxl
+
+from nopywer.models import _CABLE_TYPES
+MULTI_PHASE_THRESHOLD_W = 10000 # 10kW
+
+MULTI_PHASE_3_THRESHOLD_W = 11000 # >= 10kW → 3 phases
+MULTI_PHASE_2_THRESHOLD_W = 8000 # >= 5kW → 2 phases
+ # < 5kW → 1 phases
+
+
+def assign_phases_from_grid(nodes: dict, cables: list, logger: logging.Logger) -> tuple:
+ children = {n: [] for n in nodes}
+ cable_map = {}
+ for cable in cables:
+ children[cable.from_node].append(cable.to_node)
+ cable_map[(cable.from_node, cable.to_node)] = cable
+
+ generator = next(n for n, node in nodes.items() if node.is_generator)
+ load_cache = {}
+ descendant_cache = {}
+
+ def get_load(root):
+ if root in load_cache:
+ return load_cache[root]
+ total, stack = 0, [root]
+ while stack:
+ n = stack.pop()
+ total += nodes[n].power_watts
+ stack.extend(children[n])
+ load_cache[root] = total
+ return total
+
+ def get_descendants(root):
+ if root in descendant_cache:
+ return descendant_cache[root]
+ count, stack = 0, list(children[root])
+ while stack:
+ n = stack.pop()
+ count += 1
+ stack.extend(children[n])
+ descendant_cache[root] = count
+ return count
+
+ for n in nodes:
+ get_load(n)
+ get_descendants(n)
+
+ total_load = sum(n.power_watts for n in nodes.values() if not n.is_generator)
+ total_nodes = len(nodes) - 1
+ THREE_PHASE_DESCENDANT_THRESHOLD = total_nodes // 3
+ THREE_PHASE_LOAD_THRESHOLD = total_load // 3
+
+ def is_distro(name: str) -> bool:
+ return name.lower().startswith('distro')
+
+ def should_receive_three_phase(from_node, to_node) -> bool:
+ cable = cable_map.get((from_node, to_node))
+ num_phases = getattr(cable, 'num_phases', 3) if cable else 3
+ if num_phases < 3:
+ return False
+ descendants = get_descendants(to_node)
+ load = get_load(to_node)
+ return (
+ descendants >= THREE_PHASE_DESCENDANT_THRESHOLD or
+ load >= THREE_PHASE_LOAD_THRESHOLD or
+ is_distro(to_node) # distros always receive 3-phase if possible
+ )
+
+
+ # Almacena el reparto real de cada nodo: {node: {0: W, 1: W, 2: W}}
+ node_phase_split = {}
+
+ def split_load_across_phases(node, load_w, phase_loads) -> dict:
+ """
+ Decides how to split node load across phases based on power.
+ Returns {phase: watts} and updates phase_loads in place.
+ """
+ if load_w >= MULTI_PHASE_3_THRESHOLD_W:
+ # 3 phases: equitable distribution
+ split = {p: load_w / 3 for p in range(3)}
+
+ elif load_w >= MULTI_PHASE_2_THRESHOLD_W:
+ # 2 phases: the two least loaded
+ sorted_phases = sorted(phase_loads, key=lambda p: phase_loads[p])
+ p1, p2 = sorted_phases[0], sorted_phases[1]
+ split = {p1: load_w / 2, p2: load_w / 2}
+
+ else: #only one phase
+ assigned = min(phase_loads, key=lambda p: phase_loads[p])
+ split = {assigned: load_w}
+
+ # Global counter update
+ for p, w in split.items():
+ phase_loads[p] += w
+
+ logger.info(
+ f"[PHASE SPLIT] {node}: {load_w:.0f}W → "
+ + ", ".join(f"L{p}={w:.0f}W" for p, w in split.items())
+ )
+ return split
+
+ def assign_phases_to_kids_balanced(node, kids, phase_loads):
+ """
+ Greedy balanced assignment for kids of a 3-phase node.
+ Looks ahead at full subtree load of each kid.
+ Returns {kid: phase} assignments.
+ """
+ assignments = {}
+ local_loads = phase_loads.copy()
+
+ for kid in sorted(kids, key=get_load, reverse=True):
+ kid_load = get_load(kid)
+ cable = cable_map.get((node, kid))
+ num_phases = getattr(cable, 'num_phases', 3) if cable else 3
+
+ if should_receive_three_phase(node, kid) or (kid_load >= MULTI_PHASE_2_THRESHOLD_W and num_phases == 3):
+ assignments[kid] = 3 # cable trifásico
+ # Split de carga según potencia
+ split = split_load_across_phases(kid, kid_load, local_loads)
+ node_phase_split[kid] = split
+ else:
+ assigned = min(local_loads, key=lambda p: local_loads[p])
+ local_loads[assigned] += kid_load
+ assignments[kid] = assigned
+ node_phase_split[kid] = {assigned: kid_load}
+
+ # Sync local_loads back to phase_loads
+ for p in phase_loads:
+ phase_loads[p] = local_loads[p]
+
+ return assignments
+
+ node_phase = {generator: 3}
+ cable_phase = {}
+ phase_loads = {0: 0.0, 1: 0.0, 2: 0.0}
+
+ queue = [(generator, 3)]
+
+ while queue:
+ node, incoming_phase = queue.pop(0)
+ kids = sorted(children[node], key=get_load, reverse=True)
+
+ if not kids:
+ continue
+
+ if incoming_phase == 3:
+ assignments = assign_phases_to_kids_balanced(node, kids, phase_loads)
+
+ for kid, phase in assignments.items():
+ cable_phase[(node, kid)] = phase
+ node_phase[kid] = phase
+ queue.append((kid, phase))
+
+ else:
+ # Single-phase: check if kid is high-power enough to warrant 3-phase upgrade
+ for kid in kids:
+ kid_load = get_load(kid)
+ cable = cable_map.get((node, kid))
+ num_phases = getattr(cable, 'num_phases', 3) if cable else 3
+
+ if kid_load >= MULTI_PHASE_THRESHOLD_W and num_phases == 3:
+ # High power node on single-phase branch: upgrade to 3-phase
+ cable_phase[(node, kid)] = 3
+ node_phase[kid] = 3
+ for p in range(3):
+ phase_loads[p] += kid_load / 3
+ queue.append((kid, 3))
+ logger.info(
+ f"[PHASE] {kid}: upgraded to 3-phase "
+ f"(load={kid_load:.0f}W >= {MULTI_PHASE_THRESHOLD_W}W)"
+ )
+ else:
+ cable_phase[(node, kid)] = incoming_phase
+ node_phase[kid] = incoming_phase
+ queue.append((kid, incoming_phase))
+
+ # Balance report
+ real_phase_loads = {0: 0.0, 1: 0.0, 2: 0.0}
+ for n, split in node_phase_split.items():
+ if n.startswith('distro'):
+ continue
+ for p, w in split.items():
+ real_phase_loads[p] += w
+
+ total = sum(real_phase_loads.values())
+ for p, load in real_phase_loads.items():
+ pct = 100 * load / total if total else 0
+ logger.info(f"[PHASE] L{p}: {load:.0f}W ({pct:.1f}%)")
+ if total:
+ imbalance = max(real_phase_loads.values()) - min(real_phase_loads.values())
+ logger.info(f"[PHASE] imbalance: {imbalance:.0f}W ({100*imbalance/total:.1f}%)")
+
+ return node_phase, cable_phase, real_phase_loads
+
+
+def segments_cross(p1, p2, p3, p4):
+ """Returns True if segment p1-p2 crosses p3-p4"""
+ def cross(o, a, b):
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
+
+ def on_segment(p, q, r):
+ return min(p[0], r[0]) <= q[0] <= max(p[0], r[0]) and min(p[1], r[1]) <= q[1] <= max(
+ p[1], r[1]
+ )
+ d1 = cross(p3, p4, p1)
+ d2 = cross(p3, p4, p2)
+ d3 = cross(p1, p2, p3)
+ d4 = cross(p1, p2, p4)
+
+ if ((d1 > 0 and d2 < 0) or (d1 < 0 and d2 > 0)) and (
+ (d3 > 0 and d4 < 0) or (d3 < 0 and d4 > 0)
+ ):
+ return True
+ if d1 == 0 and on_segment(p3, p1, p4):
+ return True
+ if d2 == 0 and on_segment(p3, p2, p4):
+ return True
+ if d3 == 0 and on_segment(p1, p3, p2):
+ return True
+ if d4 == 0 and on_segment(p1, p4, p2):
+ return True
+ return False
+
+
+def load_inventory(path: str) -> list[dict]:
+ wb = openpyxl.load_workbook(path)
+ ws = wb.active
+ inventory = []
+ for row in ws.iter_rows(min_row=2, values_only=True):
+ phases, section, plugs, length, quantity = row
+ for i in range(int(quantity)):
+ inventory.append(
+ {
+ "id": f"cable_{section}mm2_{plugs}A_{length}m_{i}",
+ "section_mm2": float(section),
+ "plugs_a": float(plugs),
+ "length_m": float(length),
+ "num_phases": int(phases),
+ "tier": next(
+ t for t, tier in enumerate(_CABLE_TYPES) if tier.area_mm2 == float(section)
+ ),
+ }
+ )
+ return inventory
+
+
+# def phase_assignment_greedy(grid: dict):
+# """
+# each item has the foloowing:
+# - 'power' (or 'cum_power')
+# - name
+# - ....
+# """
+# phases = [{"total_load": 0}, {"total_load": 0}, {"total_load": 0}]
+
+# loads_unsorted = {key: value["power"].sum() for key, value in grid.items()}
+# loads = dict(sorted(loads_unsorted.items(), key=lambda x: x[1], reverse=True))
+# for key, value in loads.items():
+# assigned_phase = min(range(len(phases)), key=lambda i: phases[i]["total_load"])
+# # grid[key]['assigned_phase'] = assigned_phase
+# phases[assigned_phase]["total_load"] += value
+# print(f"{key}: {value:.0f}W, phase {assigned_phase}")
+
+# print(f"\ntotal on phases: {phases}")
+# phase_balance = 100 * np.std(
+# grid["generator"]["cum_power"] / np.mean(grid["generator"]["cum_power"])
+# )
+# print(f"balance : {phase_balance:.1f}%")
+
+# return loads
diff --git a/src/nopywer/optimize_mixed.py b/src/nopywer/optimize_mixed.py
new file mode 100644
index 0000000..ffbce88
--- /dev/null
+++ b/src/nopywer/optimize_mixed.py
@@ -0,0 +1,379 @@
+import math
+import networkx as nx
+import itertools
+import logging
+import os
+
+
+from nopywer.analyze import _compute_distro_requirements
+from nopywer.constants import EXTRA_CABLE_LENGTH_M, PF, RHO_COPPER, V0, VDROP_THRESHOLD_PERCENT
+from nopywer.geometry import geodesic_distance_m
+from nopywer.models import _CABLE_TYPES, Cable, PowerGrid
+from nopywer.optimization_tools import assign_phases_from_grid
+from nopywer.optimize import _compute_power_flow
+
+logger = logging.getLogger(__name__)
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
+ datefmt="%m-%d %H:%M",
+ filename="optimizer.log",
+ filemode="w",
+)
+console = logging.StreamHandler()
+console.setLevel(logging.INFO)
+formatter = logging.Formatter("%(name)-12s: %(levelname)-8s %(message)s")
+console.setFormatter(formatter)
+logging.getLogger().addHandler(console)
+
+
+MAX_POWER = 100000
+RADIUS_M = 50 # tune this: typical cluster radius in your layout
+
+INVENTORY = os.getenv("NOPYWER_INVENTORY", "orange_inventory.xlsx")
+
+# basically, for any grid generated by the optim algo:
+# you need to compute the voltage drop, current in each cables etc.
+# So phases need to be assigned first. Then you check if the grid is valid
+# then, only for those feasible solutions, you try to optimize voltage drop, total length, etc
+
+def optimize_layout(grid: PowerGrid, extra_cable_m=EXTRA_CABLE_LENGTH_M,
+ hub_discount=0.7, radiality_factor=0.3) -> PowerGrid:
+ # 0 = no discount, 1 = free connection to perfect hub
+ # Radiality factor: penalizes edges between non-generator nodes
+ # Higher RADIALITY_FACTOR = more star-shaped (all connect to generator)
+ # Lower RADIALITY_FACTOR = more chained (MST-like)
+
+ nodes = grid.nodes
+ node_names = list(nodes.keys())
+ generator = next(n for n, node in nodes.items() if node.is_generator)
+ vdrop = None
+
+ G = nx.Graph()
+ max_section = max(tier.area_mm2 for tier in _CABLE_TYPES)
+
+ dist_matrix = {}
+ define_distance_matrix(nodes, node_names, dist_matrix)
+
+ # Precompute hub score for each node:
+ # how much load is reachable within a radius from this node
+ hub_score = precompute_hub(nodes, node_names, dist_matrix)
+
+ max_hub = max(hub_score.values()) if hub_score else 1
+ generator_position = (nodes[generator].lon, nodes[generator].lat)
+
+ logger.info(f"[HUB] scores: {sorted(hub_score.items(), key=lambda x: x[1], reverse=True)[:5]}")
+ for i, j in itertools.combinations(node_names, 2):
+ d = dist_matrix[(i, j)]
+ base_weight = RHO_COPPER / max_section * d * d
+
+ if nodes[i].is_generator or nodes[j].is_generator:
+ hub_node = j if nodes[i].is_generator else i
+
+ if is_distro(hub_node):
+ # hub_discount: cuanto mayor, más barato conectar distros al generador
+ weight = base_weight * (1 - hub_discount) * 0.1
+ else:
+ # radiality_factor: cuanto mayor, más caro conectar non-distro al generador
+ weight = base_weight * (1 + radiality_factor * 10)
+
+ elif is_distro(i) and is_distro(j):
+ # distro↔distro: cheap only if closer to each other than to generator
+ d_i_gen = dist_matrix[(i, generator)]
+ d_j_gen = dist_matrix[(j, generator)]
+ if d < min(d_i_gen, d_j_gen):
+ weight = base_weight * (1 - hub_discount) * 0.1
+ else:
+ weight = base_weight * (1 + radiality_factor)
+
+ elif is_distro(i) or is_distro(j):
+ # non-distro↔distro: cheap
+ weight = base_weight * (1 - hub_discount * 0.8)
+
+ else:
+ # non-distro↔non-distro: expensive
+ weight = base_weight * (1 + radiality_factor * 5)
+
+ G.add_edge(i, j, weight=weight)
+
+ mst = nx.minimum_spanning_tree(G, algorithm='kruskal')
+ if generator not in mst:
+ logger.warning("Generator %s is not present in MST nodes, using fallback root", generator)
+ generator = next(i for i in mst.nodes if 'generator' in str(i).lower())
+
+ oriented_tree = nx.bfs_tree(mst, source=generator)
+ for node in oriented_tree.nodes:
+ if not is_distro(node) or nodes[node].is_generator:
+ continue
+ parent = next(iter(oriented_tree.predecessors(node)), None)
+ if parent and not nodes[parent].is_generator and not is_distro(parent):
+ logger.warning(
+ f"[DISTRO] {node} is connected to non-distro {parent} — "
+ f"constraint not satisfied, increase distro↔non-distro penalty"
+ )
+
+ tiers = assign_tiers_to_nodes(oriented_tree, nodes, dist_matrix)
+
+ # vdrop, warnings = compute_voltage_drop(oriented_tree, nodes, dist_matrix, tiers)
+
+ # if warnings:
+ # oriented_tree = fix_voltage_drop_warnings(oriented_tree, nodes, dist_matrix, warnings, tiers)
+ # # Recompute tiers and vdrop after fix
+ # tiers = assign_tiers_to_nodes(oriented_tree, nodes)
+ # vdrop, warnings = compute_voltage_drop(oriented_tree, nodes, dist_matrix, tiers)
+ # if warnings:
+ # logger.warning(f"[VDROP] {len(warnings)} edges still exceed threshold after fix")
+
+
+ cables = oriented_tree_to_grid(oriented_tree, dist_matrix,nodes, EXTRA_CABLE_LENGTH_M, vdrop=vdrop)
+ logger.debug("Assigned tiers from MST: %s", tiers)
+
+ computed_cables = _compute_power_flow(cables, grid.nodes)
+ node_phase, cable_phase, phase_loads = assign_phases_from_grid(grid.nodes, computed_cables, logger)
+
+ for n in node_names:
+ nodes[n].phase = node_phase.get(n, 0)
+
+ for cable in computed_cables:
+ cable.phase = cable_phase.get((cable.from_node, cable.to_node), 0)
+
+ grid = PowerGrid(
+ nodes=nodes,
+ cables={cable.id: cable for cable in computed_cables},
+ )
+ grid.phase_loads = phase_loads
+
+ _compute_distro_requirements(grid)
+ return grid
+
+def fix_voltage_drop_warnings(
+ oriented_tree: nx.DiGraph,
+ nodes: dict,
+ dist_matrix: dict,
+ warnings: list,
+ cable_tiers: dict,
+) -> nx.DiGraph:
+ """
+ For each warning edge, try to find a closer parent for the child node.
+ Returns a modified tree.
+ """
+ if not warnings:
+ return oriented_tree
+
+ tree = oriented_tree.copy()
+ generator = next(n for n, node in nodes.items() if node.is_generator)
+ node_names = list(nodes.keys())
+
+ for w in warnings:
+ node_i, node_j = w["edge"]
+ logger.info(f"[VDROP FIX] Trying to re-route {node_i}→{node_j} ({w['vdrop_pct']}%)")
+
+ # Find alternative parents: nodes already in tree, closer to node_j
+ current_dist = dist_matrix[(node_i, node_j)]
+ candidates = [
+ n for n in node_names
+ if n != node_j
+ and n in tree.nodes
+ and dist_matrix[(n, node_j)] < current_dist
+ and not nx.has_path(tree, node_j, n) # avoid cycles
+ ]
+ candidates.sort(key=lambda n: dist_matrix[(n, node_j)])
+
+ for candidate in candidates:
+ # Check if rerouting fixes the voltage drop
+ new_flow = sum(
+ nodes[n].power_watts
+ for n in nx.descendants(tree, node_j) | {node_j}
+ )
+ new_d = dist_matrix[(candidate, node_j)]
+ tier = cable_tiers.get((node_i, node_j))
+ if tier:
+ new_vdrop = 100 * (new_flow / (V0 * PF)) * (RHO_COPPER / tier.area_mm2) * new_d / V0
+ if new_vdrop <= VDROP_THRESHOLD_PERCENT:
+ # Apply reroute
+ tree.remove_edge(node_i, node_j)
+ tree.add_edge(candidate, node_j)
+ logger.info(
+ f"[VDROP FIX] Re-routed {node_j}: "
+ f"{node_i}→{node_j} ({w['vdrop_pct']:.1f}%) "
+ f"replaced by {candidate}→{node_j} ({new_vdrop:.1f}%)"
+ )
+ break
+ else:
+ logger.warning(
+ f"[VDROP FIX] No fix found for {node_i}→{node_j}, "
+ f"consider upgrading cable tier or splitting the branch"
+ )
+
+ return tree
+
+def compute_voltage_drop(
+ oriented_tree: nx.DiGraph,
+ nodes: dict,
+ dist_matrix: dict,
+ cable_tiers: dict, # (from, to) → CableTier, resultado de assign_tiers_to_nodes
+) -> dict:
+ """Returns {(from, to): vdrop_percent} for each edge."""
+ generator = next(n for n, node in nodes.items() if node.is_generator)
+
+ # Compute cumulative flow (watts) for each edge = sum of all descendant loads
+ cumulative_flow = {}
+ for node_i, node_j in nx.bfs_edges(oriented_tree, source=generator):
+ subtree_load = sum(
+ nodes[n].power_watts
+ for n in nx.descendants(oriented_tree, node_j) | {node_j}
+ if not nodes[n].is_generator
+ )
+ cumulative_flow[(node_i, node_j)] = subtree_load
+
+ # Compute voltage drop per edge
+ vdrop = {}
+ warnings = []
+ for (node_i, node_j), flow_w in cumulative_flow.items():
+ tier = cable_tiers.get((node_i, node_j))
+ if tier is None:
+ continue
+ d = dist_matrix[(node_i, node_j)]
+ resistance_total = (RHO_COPPER / tier.area_mm2) * d # ohms
+ current_a = flow_w / (V0 * PF)
+ vdrop_v = current_a * resistance_total
+ vdrop_pct = 100 * vdrop_v / V0
+
+ vdrop[(node_i, node_j)] = vdrop_pct
+
+ if vdrop_pct > VDROP_THRESHOLD_PERCENT:
+ warnings.append({
+ "edge": (node_i, node_j),
+ "vdrop_pct": round(vdrop_pct, 2),
+ "flow_w": round(flow_w),
+ "tier": tier.area_mm2,
+ "distance_m": round(d),
+ })
+ logger.warning(
+ f"[VDROP] {node_i}→{node_j}: {vdrop_pct:.1f}% "
+ f"(flow={flow_w:.0f}W, d={d:.0f}m, tier={tier.area_mm2}mm²)"
+ )
+
+ return vdrop, warnings
+
+def is_distro(name: str) -> bool:
+ return name.lower().startswith('distro')
+
+def precompute_hub(nodes, node_names, dist_matrix):
+ hub_score = {}
+ for n in node_names:
+ if nodes[n].is_generator:
+ continue
+ nearby_load = sum(
+ nodes[m].power_watts
+ for m in node_names
+ if m != n and not nodes[m].is_generator
+ and dist_matrix[(n, m)] <= RADIUS_M
+ )
+ hub_score[n] = nearby_load
+ return hub_score
+
+def oriented_tree_to_grid(
+ oriented_tree: nx.DiGraph,
+ dist_matrix: dict,
+ nodes: dict,
+ extra_cable_m: float = EXTRA_CABLE_LENGTH_M,
+ vdrop =None,
+) -> PowerGrid:
+
+ cables = []
+ for cid, (node_i, node_j) in enumerate(oriented_tree.edges()):
+ d = dist_matrix[(node_i, node_j)]
+ cable = Cable(
+ id=f"opt_{cid}",
+ from_node=node_i,
+ to_node=node_j,
+ length_m=d + extra_cable_m,
+ from_coords=(nodes[node_i].lon, nodes[node_i].lat),
+ to_coords=(nodes[node_j].lon, nodes[node_j].lat),
+ )
+ if vdrop:
+ cable.vdrop_pct = round(vdrop.get((node_i, node_j), 0), 2)
+ cable.vdrop_warning = cable.vdrop_pct > VDROP_THRESHOLD_PERCENT
+ cables.append(cable)
+ return cables
+
+# def assign_tiers_to_nodes(tree, nodes, dist_matrix=None):
+# tiers = nx.single_source_shortest_path_length(tree, list(tree.nodes)[0])
+# for node_name, tier in tiers.items():
+# node = nodes.get(node_name)
+# if node is None:
+# continue
+# try:
+# node.tier = tier
+# except AttributeError:
+# pass
+# return tiers
+
+def assign_tiers_to_nodes(
+ tree: nx.DiGraph,
+ nodes: dict,
+ dist_matrix: dict,
+) -> dict:
+ """
+ Assigns the minimum cable tier to each edge that satisfies:
+ - current capacity >= cumulative flow
+ - voltage drop <= threshold
+ Returns {(from, to): CableTier}
+ """
+ generator = next(n for n, node in nodes.items() if node.is_generator)
+ cable_tiers = {}
+
+ for node_i, node_j in nx.bfs_edges(tree, source=generator):
+ # Cumulative flow = sum of all descendant loads
+ subtree_nodes = nx.descendants(tree, node_j) | {node_j}
+ flow_w = sum(
+ nodes[n].power_watts
+ for n in subtree_nodes
+ if not nodes[n].is_generator
+ )
+ current_a = flow_w / (V0 * PF)
+ d = dist_matrix[(node_i, node_j)]
+
+ # Select minimum tier that satisfies both constraints
+ selected_tier = None
+ for tier in sorted(_CABLE_TYPES, key=lambda t: t.area_mm2): # smallest first
+ # Capacity check
+ if tier.max_current_a < current_a:
+ continue
+ # Voltage drop check
+ resistance_total = (RHO_COPPER / tier.area_mm2) * d
+ vdrop_pct = 100 * current_a * resistance_total / V0
+ if vdrop_pct > VDROP_THRESHOLD_PERCENT:
+ continue
+ selected_tier = tier
+ break
+
+ if selected_tier is None:
+ # No tier satisfies constraints — use largest available and warn
+ selected_tier = max(_CABLE_TYPES, key=lambda t: t.area_mm2)
+ logger.warning(
+ f"[TIER] No suitable tier for {node_i}→{node_j}: "
+ f"flow={flow_w:.0f}W, current={current_a:.1f}A, d={d:.0f}m — "
+ f"using largest tier ({selected_tier.area_mm2}mm²)"
+ )
+
+ cable_tiers[(node_i, node_j)] = selected_tier
+ logger.debug(
+ f"[TIER] {node_i}→{node_j}: {selected_tier.area_mm2}mm² "
+ f"({current_a:.1f}A, {d:.0f}m)"
+ )
+
+ return cable_tiers
+
+def define_distance_matrix(nodes, names, dist_matrix):
+ for a, b in itertools.permutations(names, 2):
+ na = nodes[a]
+ nb = nodes[b]
+ d = geodesic_distance_m(
+ na.lon,
+ na.lat,
+ nb.lon,
+ nb.lat,
+ )
+ dist_matrix[(a, b)] = d
diff --git a/src/nopywer/ortools_solverV1.py b/src/nopywer/ortools_solverV1.py
new file mode 100644
index 0000000..8bac198
--- /dev/null
+++ b/src/nopywer/ortools_solverV1.py
@@ -0,0 +1,377 @@
+import itertools
+import logging
+import os
+
+from ortools.sat.python import cp_model
+
+from nopywer import inventory
+from nopywer.analyze import _compute_distro_requirements
+from nopywer.constants import EXTRA_CABLE_LENGTH_M, PF, RHO_COPPER, V0, VDROP_THRESHOLD_PERCENT
+from nopywer.geometry import geodesic_distance_m
+from nopywer.models import _CABLE_TYPES, Cable, PowerGrid
+from nopywer.optimization_tools import assign_phases_from_grid, segments_cross
+from nopywer.optimize import _compute_power_flow
+
+logger = logging.getLogger(__name__)
+logging.basicConfig(
+ level=logging.DEBUG,
+ format="%(asctime)s %(name)-12s %(levelname)-8s %(message)s",
+ datefmt="%m-%d %H:%M",
+ filename="optimizer.log",
+ filemode="w",
+)
+console = logging.StreamHandler()
+console.setLevel(logging.INFO)
+formatter = logging.Formatter("%(name)-12s: %(levelname)-8s %(message)s")
+console.setFormatter(formatter)
+logging.getLogger().addHandler(console)
+
+
+MAX_POWER = 100000
+
+# NOPYWER_INPUT='tests/fixtures/input_nodes.geojson'
+# NOPYWER_OUTPUT='output.geojson'
+# NOPYWER_INVENTORY='orange_inventory.xlsx'
+
+INVENTORY = os.getenv("NOPYWER_INVENTORY", "orange_inventory.xlsx")
+
+# TODO: add max number of connections for each distro/node
+
+# basically, for any grid generated by the optim algo:
+# you need to compute the voltage drop, current in each cables etc.
+# So phases need to be assigned first. Then you check if the grid is valid
+# then, only for those feasible solutions, you try to optimize voltage drop, total length, etc
+
+
+def optimize_layout(
+ grid: PowerGrid,
+ extra_cable_m: float = EXTRA_CABLE_LENGTH_M,
+) -> PowerGrid:
+
+ ITERATE_ACCORDING_TO_INVENTORY = False
+ nodes = grid.nodes
+ node_names = list(nodes.keys())
+ generator = grid.generator.name
+
+ dist_matrix = {}
+ define_distance_matrix(nodes, node_names, dist_matrix)
+
+ model = cp_model.CpModel()
+
+ connection_variables = define_connection_variables(node_names, model)
+ flow = define_flow_variables(node_names, model)
+
+ depth = {n: model.NewIntVar(0, len(node_names), f"depth_{n}") for n in node_names}
+ model.Add(depth[generator] == 0)
+
+ tier_variables = {}
+ create_depth_constraint(node_names, model, connection_variables, depth)
+ add_cable_tier_variables(node_names, model, tier_variables, nodes)
+ tier_selection(node_names, model, connection_variables, tier_variables, nodes)
+ add_cable_capacity_constraints(node_names, model, flow, tier_variables, nodes)
+ add_flow_cable_exists_constraints(node_names, model, flow, tier_variables, nodes)
+ add_no_crossing_constraints(node_names, nodes, model, connection_variables)
+ add_nodes_proximity_constraints(node_names, nodes, model, connection_variables, dist_matrix)
+
+ for n in nodes:
+ if n == generator:
+ continue
+ model.Add(sum(connection_variables[(i, n)] for i in node_names if i != n) == 1)
+
+ add_power_balance_constraints(nodes, node_names, generator, model, flow)
+
+ generator_connections = [
+ connection_variables[(generator, j)] for j in node_names if j != generator
+ ]
+ direct_connections_count = model.NewIntVar(0, len(node_names), "direct_connections")
+ model.Add(direct_connections_count == sum(generator_connections))
+
+ VOLTAGE_SCALE = 100
+ RADIALITY_WEIGHT = 100000
+
+ cost_terms = []
+ cost_terms.append(direct_connections_count * RADIALITY_WEIGHT)
+
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+ if nodes[node_j].is_generator: # never towards generator
+ continue
+ d = dist_matrix[(node_i, node_j)]
+
+ # Select preferably smaller cable tiers
+ for cable_type, tier in enumerate(_CABLE_TYPES):
+ cost_terms.append(
+ int(d * tier.tier_cost * 100) * tier_variables[(node_i, node_j, cable_type)]
+ )
+
+ # Minimize voltage drop: flow * resistance <= Vdrop_threshold
+ resistance_per_m = RHO_COPPER / tier.area_mm2
+ rhs = int(VDROP_THRESHOLD_PERCENT / 100 * V0 * PF * VOLTAGE_SCALE)
+ model.Add(
+ flow[(node_i, node_j)] * int(resistance_per_m * d * VOLTAGE_SCALE) <= rhs
+ ).OnlyEnforceIf(tier_variables[(node_i, node_j, cable_type)])
+
+ # Penalize large direct connections from the gennie
+ if nodes[node_i].is_generator:
+ cost_terms.append(int(d * 20) * connection_variables[(node_i, node_j)])
+ else:
+ # Help minimize total length
+ cost_terms.append(int(d * d / 1000) * connection_variables[(node_i, node_j)])
+
+ model.Minimize(sum(cost_terms))
+ solver = cp_model.CpSolver()
+ solver.parameters.max_time_in_seconds = 120
+ solver.parameters.log_search_progress = False
+ solver.parameters.num_search_workers = 8
+ solver.parameters.cp_model_presolve = True
+ status = solver.Solve(model)
+
+ if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
+ print("No solution found. Retrying with relaxed constraints...")
+ raise RuntimeError("Optimization failed")
+
+ _compute_distro_requirements(grid)
+ grid = assign_nodes_and_cables(
+ grid, extra_cable_m, nodes, node_names, dist_matrix, connection_variables, solver
+ )
+
+ while status in (cp_model.OPTIMAL, cp_model.FEASIBLE):
+ if ITERATE_ACCORDING_TO_INVENTORY:
+ # analyze_grid(grid, verbose=True)
+ unmatched_cables = inventory.choose_cables(INVENTORY, grid.cables)
+ # inventory.choose_distros(INVENTORY, grid.nodes)
+ if unmatched_cables:
+ for unm in unmatched_cables:
+ nodes = [unm.from_node, unm.to_node]
+ print(f"\t {unm.plugs_and_sockets_a}{'A':.<4}")
+ print(f"\t {unm.length_m:.0f}m) {'-'.join(nodes)}")
+ print("Unmatched cables found in inventory, re-optimizing...")
+ ITERATE_ACCORDING_TO_INVENTORY = True
+
+ model.AddBoolOr(
+ [
+ connection_variables[(i, j)].Not()
+ if solver.Value(connection_variables[(i, j)]) == 1
+ else connection_variables[(i, j)]
+ for i in node_names
+ for j in node_names
+ if i != j
+ ]
+ )
+ status = solver.Solve(model)
+ else:
+ print("All cables matched with inventory. Optimization complete.")
+ ITERATE_ACCORDING_TO_INVENTORY = False
+
+ else:
+ break
+
+ total_cable_length_m = (
+ round(
+ sum(c.length_m for c in grid.cables.values()),
+ 1,
+ ),
+ )
+ num_cables = len(grid.cables)
+ logger.info(f"TOTAL DISTANCE: {total_cable_length_m}")
+ logger.info(f"TOTAL CABLES: {num_cables}")
+
+ return grid
+
+
+def add_flow_cable_exists_constraints(node_names, model, flow, tier_variables, nodes):
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+ if nodes[node_j].is_generator: # nunca hacia el generador
+ continue
+ model.Add(
+ flow[(node_i, node_j)]
+ <= sum(
+ tier.max_current_a * V0 * tier_variables[(node_i, node_j, t)]
+ for t, tier in enumerate(_CABLE_TYPES)
+ )
+ )
+
+
+def add_nodes_proximity_constraints(node_names, nodes, model, connection_variables, dist_matrix):
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i >= node_j:
+ continue
+ d_ij = dist_matrix[(node_i, node_j)]
+
+ for node_k in node_names:
+ if node_k == node_i or node_k == node_j:
+ continue
+ # Si k está más lejos de ambos que i y j entre sÃ
+ if dist_matrix[(node_k, node_i)] > d_ij and dist_matrix[(node_k, node_j)] > d_ij:
+ # No pueden ambos conectarse a k
+ model.Add(
+ connection_variables[(node_k, node_i)]
+ + connection_variables[(node_k, node_j)]
+ <= 1
+ )
+
+
+def assign_nodes_and_cables(
+ grid, extra_cable_m, nodes, node_names, dist_matrix, connection_variables, solver
+):
+ cid = 0
+ cables: list[Cable] = []
+ total_distance = []
+
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+
+ if solver.Value(connection_variables[(node_i, node_j)]):
+ length = dist_matrix[(node_i, node_j)] + extra_cable_m
+ total_distance.append(length)
+
+ cable = Cable(
+ id=f"opt_{cid}",
+ length_m=length + extra_cable_m,
+ from_node=node_i,
+ to_node=node_j,
+ from_coords=(nodes[node_i].lon, nodes[node_i].lat),
+ to_coords=(nodes[node_j].lon, nodes[node_j].lat),
+ )
+ cables.append(cable)
+ cid += 1
+
+ computed_cables = _compute_power_flow(cables, nodes)
+ node_phase, cable_phase, phase_loads = assign_phases_from_grid(nodes, computed_cables)
+ for n in node_names:
+ nodes[n].phase = node_phase.get(n, 0)
+ grid.nodes = nodes
+
+ for cable in computed_cables:
+ cable.phase = cable_phase.get((cable.from_node, cable.to_node), 0)
+
+ logger.info(f"[PHASE] phase loads: {phase_loads}")
+ grid.phase_loads = phase_loads
+ grid.cables = {cable.id: cable for cable in computed_cables}
+ return grid
+
+
+def add_no_crossing_constraints(node_names, nodes, model, connection_variables):
+ crossing_pairs = 0
+ for i, j, k, z in itertools.permutations(node_names, 4):
+ if i >= k: # prevents duplicates
+ continue
+ p1 = (nodes[i].lon, nodes[i].lat)
+ p2 = (nodes[j].lon, nodes[j].lat)
+ p3 = (nodes[k].lon, nodes[k].lat)
+ p4 = (nodes[z].lon, nodes[z].lat)
+
+ if segments_cross(p1, p2, p3, p4):
+ # if these two edges cross, they can't both be in the solution
+ model.Add(connection_variables[(i, j)] + connection_variables[(k, z)] <= 1)
+ crossing_pairs += 1
+ print(f"[CROSSING] pares de cruces prohibidos: {crossing_pairs}")
+
+
+def create_depth_constraint(node_names, model, connection_v, depth):
+ # depth -distance from generator - constraint to prevent cycles
+ N = len(node_names)
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+ model.Add(depth[node_j] >= depth[node_i] + 1).OnlyEnforceIf(
+ connection_v[(node_i, node_j)]
+ )
+ model.Add(depth[node_j] <= depth[node_i] + 1 + N * (1 - connection_v[(node_i, node_j)]))
+
+
+def add_cable_capacity_constraints(node_names, model, flow, tier_variable, nodes):
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+ if nodes[node_j].is_generator:
+ continue
+
+ for cable_type, tier in enumerate(_CABLE_TYPES):
+ model.Add(
+ flow[(node_i, node_j)] <= int(tier.max_current_a * V0 * PF)
+ ).OnlyEnforceIf(tier_variable[(node_i, node_j, cable_type)])
+
+
+def tier_selection(node_names, model, connection_v, tier_variable, nodes):
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+ if nodes[node_j].is_generator:
+ continue
+ model.Add(
+ sum(tier_variable[(node_i, node_j, t)] for t in range(len(_CABLE_TYPES)))
+ == connection_v[(node_i, node_j)]
+ )
+
+
+def define_flow_variables(node_names, model):
+ flow = {}
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i != node_j:
+ flow[(node_i, node_j)] = model.NewIntVar(
+ 0,
+ MAX_POWER,
+ f"flow_{node_i}_{node_j}",
+ )
+
+ return flow
+
+
+def define_connection_variables(node_names, model):
+ connection_v = {}
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i != node_j:
+ connection_v[(node_i, node_j)] = model.NewBoolVar(f"x_{node_i}_{node_j}")
+ return connection_v
+
+
+def add_power_balance_constraints(nodes, node_names, generator, model, flow):
+ total_load = int(sum(n.power_watts for n in nodes.values() if not n.is_generator))
+ for n in node_names:
+ incoming = [flow[(i, n)] for i in node_names if i != n]
+ outgoing = [flow[(n, j)] for j in node_names if j != n]
+ if n == generator:
+ model.Add(sum(outgoing) - sum(incoming) == total_load)
+ else:
+ model.Add(sum(incoming) - sum(outgoing) == int(nodes[n].power_watts))
+ print("Total load:", total_load)
+
+
+def add_cable_tier_variables(node_names, model, tier_variable, nodes):
+ for node_i in node_names:
+ for node_j in node_names:
+ if node_i == node_j:
+ continue
+ if nodes[node_j].is_generator: # never towards generator
+ continue
+ for cable_type in range(len(_CABLE_TYPES)):
+ tier_variable[(node_i, node_j, cable_type)] = model.NewBoolVar(
+ f"tier_{cable_type}_{node_i}_{node_j}"
+ )
+
+
+def define_distance_matrix(nodes, names, dist_matrix):
+ for a, b in itertools.permutations(names, 2):
+ na = nodes[a]
+ nb = nodes[b]
+ d = geodesic_distance_m(
+ na.lon,
+ na.lat,
+ nb.lon,
+ nb.lat,
+ )
+ dist_matrix[(a, b)] = d
diff --git a/telemetry data/telemetry visualizer.py b/telemetry data/telemetry visualizer.py
new file mode 100644
index 0000000..9f3b26f
--- /dev/null
+++ b/telemetry data/telemetry visualizer.py
@@ -0,0 +1,57 @@
+from pathlib import Path
+
+import pandas as pd
+import plotly.express as px
+
+
+def load_telemetry(file_path):
+ phases = {}
+ current_phase = None
+ rows = []
+
+ with open(file_path, encoding="utf-8-sig") as f:
+ for line in f:
+ line = line.strip()
+
+ if line.startswith("Phase"):
+ if current_phase and rows:
+ df_phase = pd.DataFrame(rows, columns=["Time", "Wh"])
+ phases[current_phase] = df_phase
+ rows = []
+ current_phase = line
+
+ elif line and not line.startswith("Time"):
+ parts = line.split(" , ")
+ if len(parts) == 2:
+ rows.append(parts)
+
+ if current_phase and rows:
+ df_phase = pd.DataFrame(rows, columns=["Time", "Wh"])
+ phases[current_phase] = df_phase
+ return phases
+
+
+def clean_phase_data(df):
+ df["Time"] = pd.to_datetime(df["Time"], dayfirst=True)
+ df["Wh"] = pd.to_numeric(df["Wh"])
+ return df
+
+
+DATA_DIR = Path("./telemetry data")
+files = list(DATA_DIR.glob("*.csv"))
+all_data = []
+
+for file in files:
+ phases = load_telemetry(file)
+
+ for phase_name, df in phases.items():
+ df = clean_phase_data(df)
+ df["phase"] = phase_name
+ df["source"] = file.name.split("_")[0]
+ all_data.append(df)
+
+df = pd.concat(all_data)
+
+fig = px.line(df, x="Time", y="Wh", color="phase", facet_row="source", title="Power telemetry")
+
+fig.show()
diff --git a/tests/fixtures/export.geojson b/tests/fixtures/export.geojson
new file mode 100644
index 0000000..f24d531
--- /dev/null
+++ b/tests/fixtures/export.geojson
@@ -0,0 +1,1417 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13953071992767785,
+ 41.6999752927288
+ ],
+ [
+ -0.13989663973096164,
+ 41.69911453243565
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_0",
+ "nodes": [
+ "distro9",
+ "distro8"
+ ],
+ "from": "distro9",
+ "to": "distro8",
+ "length_m": 110.4,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 30.6,
+ "cum_power_kw": 6.33,
+ "vdrop_volts": 21.66
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.14053148336696134,
+ 41.701600130185604
+ ],
+ [
+ -0.13988015097303447,
+ 41.701130634599245
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_1",
+ "nodes": [
+ "zaz",
+ "generator"
+ ],
+ "from": "zaz",
+ "to": "generator",
+ "length_m": 85.2,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 81.2,
+ "cum_power_kw": 16.8,
+ "vdrop_volts": 44.33
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.139532242707377,
+ 41.69998389335092
+ ],
+ [
+ -0.14007635726339934,
+ 41.69963441734
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_2",
+ "nodes": [
+ "distro9",
+ "curious creatures"
+ ],
+ "from": "distro9",
+ "to": "curious creatures",
+ "length_m": 69.7,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 24.2,
+ "cum_power_kw": 5.0,
+ "vdrop_volts": 25.9
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.14072105157337192,
+ 41.702002684355925
+ ],
+ [
+ -0.14052755675000794,
+ 41.70162945801931
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_3",
+ "nodes": [
+ "garden of joy",
+ "zaz"
+ ],
+ "from": "garden of joy",
+ "to": "zaz",
+ "length_m": 54.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 48.3,
+ "cum_power_kw": 10.0,
+ "vdrop_volts": 40.51
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13989743376584954,
+ 41.69911326307554
+ ],
+ [
+ -0.1394391591861685,
+ 41.6989932968759
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_4",
+ "nodes": [
+ "distro8",
+ "planet pulpo"
+ ],
+ "from": "distro8",
+ "to": "planet pulpo",
+ "length_m": 50.4,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 9.7,
+ "cum_power_kw": 2.0,
+ "vdrop_volts": 7.49
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13989662913899972,
+ 41.69911360709415
+ ],
+ [
+ -0.14028018307834664,
+ 41.698835653220975
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_5",
+ "nodes": [
+ "distro8",
+ "wonderever"
+ ],
+ "from": "distro8",
+ "to": "wonderever",
+ "length_m": 54.4,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 6.8,
+ "cum_power_kw": 1.4,
+ "vdrop_volts": 5.66
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13983961469861161,
+ 41.69878117723728
+ ],
+ [
+ -0.1398980040638073,
+ 41.69911389878753
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_6",
+ "nodes": [
+ "oasis playground",
+ "distro8"
+ ],
+ "from": "oasis playground",
+ "to": "distro8",
+ "length_m": 47.3,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 14.5,
+ "cum_power_kw": 3.0,
+ "vdrop_volts": 10.54
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13989674641519334,
+ 41.69911370103021
+ ],
+ [
+ -0.1403477956168545,
+ 41.69906542845324
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_7",
+ "nodes": [
+ "distro8",
+ "mirage"
+ ],
+ "from": "distro8",
+ "to": "mirage",
+ "length_m": 47.9,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 6.8,
+ "cum_power_kw": 1.4,
+ "vdrop_volts": 4.98
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13989090923520467,
+ 41.70058300170854
+ ],
+ [
+ -0.1403111131585792,
+ 41.70021466955112
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_8",
+ "nodes": [
+ "distro1",
+ "eyao"
+ ],
+ "from": "distro1",
+ "to": "eyao",
+ "length_m": 63.8,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 9.7,
+ "cum_power_kw": 2.0,
+ "vdrop_volts": 9.48
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13990617156460367,
+ 41.69912572222792
+ ],
+ [
+ -0.14031724994819922,
+ 41.69938775779702
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_9",
+ "nodes": [
+ "distro8",
+ "pdr of fiascostan"
+ ],
+ "from": "distro8",
+ "to": "pdr of fiascostan",
+ "length_m": 54.9,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 9.7,
+ "cum_power_kw": 2.0,
+ "vdrop_volts": 8.16
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13989129991538604,
+ 41.7005836394078
+ ],
+ [
+ -0.14032125920402552,
+ 41.70076539629761
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_10",
+ "nodes": [
+ "distro1",
+ "glowy owl"
+ ],
+ "from": "distro1",
+ "to": "glowy owl",
+ "length_m": 51.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 9.7,
+ "cum_power_kw": 2.0,
+ "vdrop_volts": 7.59
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1398907790084798,
+ 41.70058278914213
+ ],
+ [
+ -0.1404629446182906,
+ 41.70047922303174
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_11",
+ "nodes": [
+ "distro1",
+ "mini camp"
+ ],
+ "from": "distro1",
+ "to": "mini camp",
+ "length_m": 59.0,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 4.8,
+ "cum_power_kw": 1.0,
+ "vdrop_volts": 4.38
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1398839389103155,
+ 41.70113013731505
+ ],
+ [
+ -0.14061737924151105,
+ 41.70110305493381
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_12",
+ "nodes": [
+ "generator",
+ "barrio espace"
+ ],
+ "from": "generator",
+ "to": "barrio espace",
+ "length_m": 71.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 14.5,
+ "cum_power_kw": 3.0,
+ "vdrop_volts": 15.85
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.139874356320217,
+ 41.70012865495601
+ ],
+ [
+ -0.13951696030729716,
+ 41.70022112725018
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_13",
+ "nodes": [
+ "glitch",
+ "distro2"
+ ],
+ "from": "glitch",
+ "to": "distro2",
+ "length_m": 41.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 19.3,
+ "cum_power_kw": 4.0,
+ "vdrop_volts": 12.34
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1405228605103245,
+ 41.70162137103771
+ ],
+ [
+ -0.14105370474606616,
+ 41.70178357945931
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_14",
+ "nodes": [
+ "zaz",
+ "ran'dome"
+ ],
+ "from": "zaz",
+ "to": "ran'dome",
+ "length_m": 57.7,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 19.3,
+ "cum_power_kw": 4.0,
+ "vdrop_volts": 17.15
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1405247969713634,
+ 41.7016202411872
+ ],
+ [
+ -0.14099377105678132,
+ 41.701384425977956
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_15",
+ "nodes": [
+ "zaz",
+ "desert dessert"
+ ],
+ "from": "zaz",
+ "to": "desert dessert",
+ "length_m": 57.0,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 32.8,
+ "cum_power_kw": 6.8,
+ "vdrop_volts": 28.81
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13964289449621192,
+ 41.70127410398372
+ ],
+ [
+ -0.13945297743107815,
+ 41.70160010569251
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_16",
+ "nodes": [
+ "rdtfgh",
+ "barrio del sol"
+ ],
+ "from": "rdtfgh",
+ "to": "barrio del sol",
+ "length_m": 49.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 17.8,
+ "cum_power_kw": 3.68,
+ "vdrop_volts": 13.54
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.14001492449097064,
+ 41.69989479508128
+ ],
+ [
+ -0.13951296635310073,
+ 41.70022530910071
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_17",
+ "nodes": [
+ "belly town",
+ "distro2"
+ ],
+ "from": "belly town",
+ "to": "distro2",
+ "length_m": 65.6,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 14.5,
+ "cum_power_kw": 3.0,
+ "vdrop_volts": 14.62
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13989755914393132,
+ 41.69911388770567
+ ],
+ [
+ -0.13914562055063537,
+ 41.69927447838743
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_18",
+ "nodes": [
+ "distro8",
+ "olive odissey"
+ ],
+ "from": "distro8",
+ "to": "olive odissey",
+ "length_m": 75.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 9.7,
+ "cum_power_kw": 2.0,
+ "vdrop_volts": 11.16
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13990704758599418,
+ 41.69912641093495
+ ],
+ [
+ -0.14039843147426054,
+ 41.69928042564868
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_19",
+ "nodes": [
+ "distro8",
+ "jamhouse"
+ ],
+ "from": "distro8",
+ "to": "jamhouse",
+ "length_m": 54.3,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 14.5,
+ "cum_power_kw": 3.0,
+ "vdrop_volts": 12.1
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13990606235151692,
+ 41.69912544646274
+ ],
+ [
+ -0.13989660696530037,
+ 41.69911410712428
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_20",
+ "nodes": [
+ "distro8",
+ "distro8"
+ ],
+ "from": "distro8",
+ "to": "distro8",
+ "length_m": 11.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 0.0,
+ "cum_power_kw": 0.0,
+ "vdrop_volts": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13951464719975476,
+ 41.70023034587373
+ ],
+ [
+ -0.13989025810158784,
+ 41.70058193887645
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_21",
+ "nodes": [
+ "distro2",
+ "distro1"
+ ],
+ "from": "distro2",
+ "to": "distro1",
+ "length_m": 60.0,
+ "area_mm2": 16.0,
+ "plugs_and_sockets_a": 63.0,
+ "cable_type": "1P 63A \u2014 16.0mm\u00b2",
+ "current_a": 63.1,
+ "cum_power_kw": 13.07,
+ "vdrop_volts": 9.1
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1395212435711459,
+ 41.69999574982771
+ ],
+ [
+ -0.1395055790930972,
+ 41.700220130174756
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_22",
+ "nodes": [
+ "distro9",
+ "distro2"
+ ],
+ "from": "distro9",
+ "to": "distro2",
+ "length_m": 35.0,
+ "area_mm2": 16.0,
+ "plugs_and_sockets_a": 63.0,
+ "cable_type": "1P 63A \u2014 16.0mm\u00b2",
+ "current_a": 56.4,
+ "cum_power_kw": 11.67,
+ "vdrop_volts": 4.74
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1398907032137395,
+ 41.700583973416585
+ ],
+ [
+ -0.13987627447357368,
+ 41.70111958767342
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_23",
+ "nodes": [
+ "distro1",
+ "generator"
+ ],
+ "from": "distro1",
+ "to": "generator",
+ "length_m": 69.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 77.6,
+ "cum_power_kw": 16.07,
+ "vdrop_volts": 82.99
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13987287159693085,
+ 41.70112776842358
+ ],
+ [
+ -0.1396476485857442,
+ 41.70127259284582
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_24",
+ "nodes": [
+ "generator",
+ "rdtfgh"
+ ],
+ "from": "generator",
+ "to": "rdtfgh",
+ "length_m": 34.7,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 17.8,
+ "cum_power_kw": 3.68,
+ "vdrop_volts": 9.49
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14046027066882047,
+ 41.70048109809566
+ ]
+ },
+ "properties": {
+ "name": "mini camp",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 1000.0,
+ "voltage": 142.6,
+ "vdrop_percent": 38.0,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1403126048615206,
+ 41.70021165965277
+ ]
+ },
+ "properties": {
+ "name": "eyao",
+ "type": "load",
+ "power": 2000.0,
+ "cum_power": 2000.0,
+ "voltage": 137.5,
+ "vdrop_percent": 40.22,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13986458645846486,
+ 41.70012838821719
+ ]
+ },
+ "properties": {
+ "name": "glitch",
+ "type": "load",
+ "power": 4000.0,
+ "cum_power": 4000.0,
+ "voltage": 125.6,
+ "vdrop_percent": 45.39,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14001451226101178,
+ 41.69988172981668
+ ]
+ },
+ "properties": {
+ "name": "belly town",
+ "type": "load",
+ "power": 3000.0,
+ "cum_power": 3000.0,
+ "voltage": 123.3,
+ "vdrop_percent": 46.39,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14032067533151324,
+ 41.699387585580126
+ ]
+ },
+ "properties": {
+ "name": "pdr of fiascostan",
+ "type": "load",
+ "power": 2000.0,
+ "cum_power": 2000.0,
+ "voltage": 103.3,
+ "vdrop_percent": 55.09,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14037095139108816,
+ 41.69906229826065
+ ]
+ },
+ "properties": {
+ "name": "mirage",
+ "type": "load",
+ "power": 1400.0,
+ "cum_power": 1400.0,
+ "voltage": 106.5,
+ "vdrop_percent": 53.7,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1402769285712588,
+ 41.69884015144679
+ ]
+ },
+ "properties": {
+ "name": "wonderever",
+ "type": "load",
+ "power": 1400.0,
+ "cum_power": 1400.0,
+ "voltage": 105.8,
+ "vdrop_percent": 54.0,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13983096674250983,
+ 41.698769513641636
+ ]
+ },
+ "properties": {
+ "name": "oasis playground",
+ "type": "load",
+ "power": 3000.0,
+ "cum_power": 3000.0,
+ "voltage": 101.0,
+ "vdrop_percent": 56.09,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1394393620604287,
+ 41.69898872265349
+ ]
+ },
+ "properties": {
+ "name": "planet pulpo",
+ "type": "load",
+ "power": 2000.0,
+ "cum_power": 2000.0,
+ "voltage": 104.0,
+ "vdrop_percent": 54.78,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1400824723373084,
+ 41.699631722152795
+ ]
+ },
+ "properties": {
+ "name": "curious creatures",
+ "type": "load",
+ "power": 10000.0,
+ "cum_power": 10000.0,
+ "voltage": 107.3,
+ "vdrop_percent": 53.35,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1403046266028607,
+ 41.700765329482905
+ ]
+ },
+ "properties": {
+ "name": "glowy owl",
+ "type": "load",
+ "power": 2000.0,
+ "cum_power": 2000.0,
+ "voltage": 139.4,
+ "vdrop_percent": 39.39,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14061462479929102,
+ 41.701104168536816
+ ]
+ },
+ "properties": {
+ "name": "barrio espace",
+ "type": "load",
+ "power": 3000.0,
+ "cum_power": 3000.0,
+ "voltage": 214.2,
+ "vdrop_percent": 6.87,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14105640391758656,
+ 41.70179263095132
+ ]
+ },
+ "properties": {
+ "name": "ran'dome",
+ "type": "load",
+ "power": 4000.0,
+ "cum_power": 4000.0,
+ "voltage": 168.5,
+ "vdrop_percent": 26.74,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14100409056756252,
+ 41.70136886602274
+ ]
+ },
+ "properties": {
+ "name": "desert dessert",
+ "type": "load",
+ "power": 6800.0,
+ "cum_power": 6800.0,
+ "voltage": 156.9,
+ "vdrop_percent": 31.78,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1407273698750896,
+ 41.701997572463505
+ ]
+ },
+ "properties": {
+ "name": "garden of joy",
+ "type": "load",
+ "power": 30000.0,
+ "cum_power": 30000.0,
+ "voltage": 145.2,
+ "vdrop_percent": 36.87,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1394518880437417,
+ 41.701599503491636
+ ]
+ },
+ "properties": {
+ "name": "barrio del sol",
+ "type": "load",
+ "power": 3680.0,
+ "cum_power": 3680.0,
+ "voltage": 207.0,
+ "vdrop_percent": 10.0,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1391394682682699,
+ 41.69927405035179
+ ]
+ },
+ "properties": {
+ "name": "olive odissey",
+ "type": "load",
+ "power": 2000.0,
+ "cum_power": 2000.0,
+ "voltage": 100.3,
+ "vdrop_percent": 56.39,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13952647308927593,
+ 41.69998517673939
+ ]
+ },
+ "properties": {
+ "name": "distro9",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 26800.0,
+ "voltage": 133.2,
+ "vdrop_percent": 42.09,
+ "distro": {
+ "in": "3P 63.0A",
+ "out": {
+ "3P 32.0A": 1,
+ "1P 16.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13987842843560366,
+ 41.701128430139995
+ ]
+ },
+ "properties": {
+ "name": "generator",
+ "type": "generator",
+ "power": 0.0,
+ "cum_power": 88280.0,
+ "voltage": 230,
+ "vdrop_percent": 0.0,
+ "distro": {
+ "in": "3P 125A",
+ "out": {
+ "3P 32.0A": 1,
+ "1P 16.0A": 3
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1398906258410024,
+ 41.70058336835788
+ ]
+ },
+ "properties": {
+ "name": "distro1",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 38800.0,
+ "voltage": 147.0,
+ "vdrop_percent": 36.09,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {
+ "1P 16.0A": 3,
+ "3P 63.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14052384215401315,
+ 41.70162166285516
+ ]
+ },
+ "properties": {
+ "name": "distro-zaz",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 41800.0,
+ "voltage": 185.7,
+ "vdrop_percent": 19.26,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 3
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13964561238814718,
+ 41.70127284174622
+ ]
+ },
+ "properties": {
+ "name": "distro-rdtfgh",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 4680.0,
+ "voltage": 220.5,
+ "vdrop_percent": 4.13,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {
+ "1P 16.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13989622438430727,
+ 41.69911385446002
+ ]
+ },
+ "properties": {
+ "name": "distro8",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 15800.0,
+ "voltage": 111.5,
+ "vdrop_percent": 51.52,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 7
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13951008153297134,
+ 41.70022595079415
+ ]
+ },
+ "properties": {
+ "name": "distro2",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 33800.0,
+ "voltage": 137.9,
+ "vdrop_percent": 40.04,
+ "distro": {
+ "in": "3P 63.0A",
+ "out": {
+ "1P 16.0A": 2,
+ "3P 63.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1398945488962474,
+ 41.700573358856026
+ ]
+ },
+ "properties": {
+ "name": "distro",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 0.0,
+ "voltage": 0.0,
+ "vdrop_percent": 0.0,
+ "distro": {
+ "in": null,
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.14040007075315172,
+ 41.69928240812505
+ ]
+ },
+ "properties": {
+ "name": "jamhouse",
+ "type": "load",
+ "power": 3000.0,
+ "cum_power": 3000.0,
+ "voltage": 99.4,
+ "vdrop_percent": 56.78,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1399063474789402,
+ 41.69912586313198
+ ]
+ },
+ "properties": {
+ "name": "distro6",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 0.0,
+ "voltage": 0.0,
+ "vdrop_percent": 0.0,
+ "distro": {
+ "in": null,
+ "out": {}
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/export.geojson.png b/tests/fixtures/export.geojson.png
new file mode 100644
index 0000000..45c679d
Binary files /dev/null and b/tests/fixtures/export.geojson.png differ
diff --git a/tests/fixtures/input_nodes.geojson b/tests/fixtures/input_nodes.geojson
index 4485809..fd84795 100644
--- a/tests/fixtures/input_nodes.geojson
+++ b/tests/fixtures/input_nodes.geojson
@@ -191,9 +191,9 @@
]
},
"properties": {
- "name": "Why Not?",
- "power": 1840.0,
- "type": "load"
+ "name": "distro example",
+ "power": 0,
+ "type": "distro"
}
},
{
@@ -812,4 +812,4 @@
}
}
]
-}
+}
\ No newline at end of file
diff --git a/tests/fixtures/input_nodes_reduced.geojson b/tests/fixtures/input_nodes_reduced.geojson
new file mode 100644
index 0000000..bf68d3d
--- /dev/null
+++ b/tests/fixtures/input_nodes_reduced.geojson
@@ -0,0 +1,320 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.138464932641509,
+ 41.70066854632285
+ ]
+ },
+ "properties": {
+ "name": "MiddleOfNowhere",
+ "power": 8300.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137879652831478,
+ 41.7008596039798
+ ]
+ },
+ "properties": {
+ "name": "NoInfo/Ice",
+ "power": 2489.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137329960382992,
+ 41.70109095318956
+ ]
+ },
+ "properties": {
+ "name": "Werkhaus",
+ "power": 5282.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13665890443399,
+ 41.70131487330722
+ ]
+ },
+ "properties": {
+ "name": "Cantina (Cantina)",
+ "power": 4460.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.136354966884593,
+ 41.700651065766074
+ ]
+ },
+ "properties": {
+ "name": "toolhaus",
+ "power": 3340.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.136075607508836,
+ 41.70113479538608
+ ]
+ },
+ "properties": {
+ "name": "production",
+ "power": 11085.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13709553752102,
+ 41.70053910616153
+ ]
+ },
+ "properties": {
+ "name": "Kunsthaus",
+ "power": 3170.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137185016826846,
+ 41.70085650841904
+ ]
+ },
+ "properties": {
+ "name": "malfare",
+ "power": 10370.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137889717750607,
+ 41.70121609813784
+ ]
+ },
+ "properties": {
+ "name": "generator",
+ "power": 0,
+ "type": "generator"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137092621102908,
+ 41.70038656610599
+ ]
+ },
+ "properties": {
+ "name": "Swiss Camp",
+ "power": 1840.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137791038785941,
+ 41.70155383234283
+ ]
+ },
+ "properties": {
+ "name": "NIMBY",
+ "power": 7360.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137967862317445,
+ 41.70165498843007
+ ]
+ },
+ "properties": {
+ "name": "Jugaton",
+ "power": 3680.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137499598926543,
+ 41.701263307068864
+ ]
+ },
+ "properties": {
+ "name": "Why Not?",
+ "power": 1840.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.136850621282869,
+ 41.69966999243487
+ ]
+ },
+ "properties": {
+ "name": "It's A Grey Area",
+ "power": 1840.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137285930416049,
+ 41.70065526429824
+ ]
+ },
+ "properties": {
+ "name": "Ohana House",
+ "power": 60.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137001721971925,
+ 41.700133470839425
+ ]
+ },
+ "properties": {
+ "name": "Le Gratin",
+ "power": 1840.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137124644449325,
+ 41.700669066078476
+ ]
+ },
+ "properties": {
+ "name": "welfare (malfare)",
+ "power": 1730.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137228142727426,
+ 41.70075879293105
+ ]
+ },
+ "properties": {
+ "name": "guardshack (malfare)",
+ "power": 480.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137338011431298,
+ 41.700861693596636
+ ]
+ },
+ "properties": {
+ "name": "meeting point (malfare)",
+ "power": 250.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.136527711861485,
+ 41.70121465703139
+ ]
+ },
+ "properties": {
+ "name": "Fridge Truck",
+ "power": 1000.0,
+ "type": "load"
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13633563273172,
+ 41.701240803092176
+ ]
+ },
+ "properties": {
+ "name": "Spoonhaus",
+ "power": 3100.0,
+ "type": "load"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/nopywer_output_east_grid.geojson b/tests/fixtures/nopywer_output_east_grid.geojson
new file mode 100644
index 0000000..31f1cac
--- /dev/null
+++ b/tests/fixtures/nopywer_output_east_grid.geojson
@@ -0,0 +1,1729 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13719101266114425,
+ 41.69904766985218
+ ],
+ [
+ -0.137206218374929,
+ 41.69953266449073
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_0",
+ "nodes": [
+ "here & meow",
+ "distro6-1"
+ ],
+ "from": "here & meow",
+ "to": "distro6-1",
+ "length_m": 63.9,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 17.8,
+ "cum_power_kw": 3.68,
+ "vdrop_volts": 17.48
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ],
+ [
+ -0.1377138674373942,
+ 41.701476697407855
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_1",
+ "nodes": [
+ "distro3",
+ "dusty dreamers"
+ ],
+ "from": "distro3",
+ "to": "dusty dreamers",
+ "length_m": 70.4,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 4.8,
+ "cum_power_kw": 1.0,
+ "vdrop_volts": 5.23
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1383577949695892,
+ 41.70111101870848
+ ],
+ [
+ -0.13867822792326492,
+ 41.70159538737178
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_2",
+ "nodes": [
+ "distro5-1",
+ "camp cambio"
+ ],
+ "from": "distro5-1",
+ "to": "camp cambio",
+ "length_m": 70.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 62.8,
+ "cum_power_kw": 13.0,
+ "vdrop_volts": 67.73
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13826592688444675,
+ 41.701472011492505
+ ],
+ [
+ -0.1383577949695892,
+ 41.70111101870848
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_3",
+ "nodes": [
+ "jugaton",
+ "distro5-1"
+ ],
+ "from": "jugaton",
+ "to": "distro5-1",
+ "length_m": 50.8,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 4.8,
+ "cum_power_kw": 1.0,
+ "vdrop_volts": 3.77
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13747436010273575,
+ 41.699116578653026
+ ],
+ [
+ -0.137206218374929,
+ 41.69953266449073
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_4",
+ "nodes": [
+ "hyperflow",
+ "distro6-1"
+ ],
+ "from": "hyperflow",
+ "to": "distro6-1",
+ "length_m": 61.3,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 4.8,
+ "cum_power_kw": 1.0,
+ "vdrop_volts": 4.56
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13612322506708008,
+ 41.70063179851722
+ ],
+ [
+ -0.13620519471107007,
+ 41.70082143554413
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_5",
+ "nodes": [
+ "toolhaus",
+ "production"
+ ],
+ "from": "toolhaus",
+ "to": "production",
+ "length_m": 32.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 16.1,
+ "cum_power_kw": 3.34,
+ "vdrop_volts": 7.97
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13784153493839352,
+ 41.700393705460776
+ ],
+ [
+ -0.13777101617447376,
+ 41.70062032145378
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_6",
+ "nodes": [
+ "distro9",
+ "noinfo"
+ ],
+ "from": "distro9",
+ "to": "noinfo",
+ "length_m": 35.9,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 23.9,
+ "cum_power_kw": 4.94,
+ "vdrop_volts": 13.18
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ],
+ [
+ -0.13748051264301162,
+ 41.70142233907045
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_7",
+ "nodes": [
+ "distro3",
+ "nobarrio"
+ ],
+ "from": "distro3",
+ "to": "nobarrio",
+ "length_m": 71.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 26.7,
+ "cum_power_kw": 5.52,
+ "vdrop_volts": 29.34
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13792203842668818,
+ 41.701452483139114
+ ],
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_8",
+ "nodes": [
+ "mooncows",
+ "distro3"
+ ],
+ "from": "mooncows",
+ "to": "distro3",
+ "length_m": 67.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 14.5,
+ "cum_power_kw": 3.0,
+ "vdrop_volts": 14.96
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13735634636904792,
+ 41.70118150860041
+ ],
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_9",
+ "nodes": [
+ "pink poney paradise",
+ "distro3"
+ ],
+ "from": "pink poney paradise",
+ "to": "distro3",
+ "length_m": 58.7,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 4.8,
+ "cum_power_kw": 1.0,
+ "vdrop_volts": 4.36
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1374190711291166,
+ 41.70096791706415
+ ],
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_10",
+ "nodes": [
+ "le gratin",
+ "distro3"
+ ],
+ "from": "le gratin",
+ "to": "distro3",
+ "length_m": 45.7,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 14.5,
+ "cum_power_kw": 3.0,
+ "vdrop_volts": 10.19
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1368357235149388,
+ 41.700207145864105
+ ],
+ [
+ -0.13642674451628584,
+ 41.69994968066739
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_11",
+ "nodes": [
+ "distro4",
+ "peach please!"
+ ],
+ "from": "distro4",
+ "to": "peach please!",
+ "length_m": 54.5,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 3.6,
+ "cum_power_kw": 0.75,
+ "vdrop_volts": 3.04
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13678209537425187,
+ 41.70081371865842
+ ],
+ [
+ -0.13709073026884433,
+ 41.70089822126032
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_12",
+ "nodes": [
+ "distro2",
+ "welfare"
+ ],
+ "from": "distro2",
+ "to": "welfare",
+ "length_m": 37.4,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 19.3,
+ "cum_power_kw": 4.0,
+ "vdrop_volts": 11.12
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13678209537425187,
+ 41.70081371865842
+ ],
+ [
+ -0.1368415609243576,
+ 41.70105017775877
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_13",
+ "nodes": [
+ "distro2",
+ "barrio support"
+ ],
+ "from": "distro2",
+ "to": "barrio support",
+ "length_m": 36.7,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 17.4,
+ "cum_power_kw": 3.6,
+ "vdrop_volts": 9.82
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1369738592052911,
+ 41.700674795566215
+ ],
+ [
+ -0.13706825525949862,
+ 41.70054298992181
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_14",
+ "nodes": [
+ "werkhaus",
+ "kunsthaus"
+ ],
+ "from": "werkhaus",
+ "to": "kunsthaus",
+ "length_m": 26.6,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 22.8,
+ "cum_power_kw": 4.73,
+ "vdrop_volts": 9.35
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13784153493839352,
+ 41.700393705460776
+ ],
+ [
+ -0.13788338990168958,
+ 41.70020670287022
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_15",
+ "nodes": [
+ "distro9",
+ "artinstallation1"
+ ],
+ "from": "distro9",
+ "to": "artinstallation1",
+ "length_m": 31.1,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 4.8,
+ "cum_power_kw": 1.0,
+ "vdrop_volts": 2.31
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13686612235420045,
+ 41.700910652197706
+ ],
+ [
+ -0.13678209537425187,
+ 41.70081371865842
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_16",
+ "nodes": [
+ "malfare",
+ "distro2"
+ ],
+ "from": "malfare",
+ "to": "distro2",
+ "length_m": 22.8,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 33.8,
+ "cum_power_kw": 7.0,
+ "vdrop_volts": 11.86
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1368357235149388,
+ 41.700207145864105
+ ],
+ [
+ -0.13648507855198813,
+ 41.7002150517457
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_17",
+ "nodes": [
+ "distro4",
+ "ohana haus"
+ ],
+ "from": "distro4",
+ "to": "ohana haus",
+ "length_m": 39.2,
+ "area_mm2": 2.5,
+ "plugs_and_sockets_a": 16.0,
+ "cable_type": "1P 16A \u2014 2.5mm\u00b2",
+ "current_a": 0.3,
+ "cum_power_kw": 0.06,
+ "vdrop_volts": 0.17
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ],
+ [
+ -0.1383639100113639,
+ 41.70111117110253
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_18",
+ "nodes": [
+ "distro3",
+ "distro5-1"
+ ],
+ "from": "distro3",
+ "to": "distro5-1",
+ "length_m": 57.0,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 62.8,
+ "cum_power_kw": 13.0,
+ "vdrop_volts": 22.95
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13711182503054292,
+ 41.700427091148256
+ ],
+ [
+ -0.13729986190314222,
+ 41.69989841057792
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_19",
+ "nodes": [
+ "distro1",
+ "distro7"
+ ],
+ "from": "distro1",
+ "to": "distro7",
+ "length_m": 70.8,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 17.8,
+ "cum_power_kw": 3.68,
+ "vdrop_volts": 8.07
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13744814067689898,
+ 41.70067371329373
+ ],
+ [
+ -0.13784153493839352,
+ 41.700393705460776
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_20",
+ "nodes": [
+ "generator",
+ "distro9"
+ ],
+ "from": "generator",
+ "to": "distro9",
+ "length_m": 55.2,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 57.2,
+ "cum_power_kw": 11.84,
+ "vdrop_volts": 20.24
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13711247844818722,
+ 41.70042734787714
+ ],
+ [
+ -0.1368357235149388,
+ 41.700207145864105
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_21",
+ "nodes": [
+ "distro1",
+ "distro4"
+ ],
+ "from": "distro1",
+ "to": "distro4",
+ "length_m": 43.6,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 3.9,
+ "cum_power_kw": 0.81,
+ "vdrop_volts": 1.09
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1374400580203173,
+ 41.700680039793006
+ ],
+ [
+ -0.13678209537425187,
+ 41.70081371865842
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_22",
+ "nodes": [
+ "generator",
+ "distro2"
+ ],
+ "from": "generator",
+ "to": "distro2",
+ "length_m": 66.8,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 76.1,
+ "cum_power_kw": 15.74,
+ "vdrop_volts": 32.57
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13743594305981566,
+ 41.700675935977124
+ ],
+ [
+ -0.13697385920529187,
+ 41.70067479556621
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_23",
+ "nodes": [
+ "generator",
+ "werkhaus"
+ ],
+ "from": "generator",
+ "to": "werkhaus",
+ "length_m": 48.5,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 48.4,
+ "cum_power_kw": 10.01,
+ "vdrop_volts": 15.04
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13678209537425187,
+ 41.70081371865842
+ ],
+ [
+ -0.13644600159497342,
+ 41.70093881235571
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_24",
+ "nodes": [
+ "distro2",
+ "distro8"
+ ],
+ "from": "distro2",
+ "to": "distro8",
+ "length_m": 41.2,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 56.7,
+ "cum_power_kw": 11.75,
+ "vdrop_volts": 14.99
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.137206218374929,
+ 41.69953266449073
+ ],
+ [
+ -0.13729258524653887,
+ 41.69988995796666
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_25",
+ "nodes": [
+ "distro6-1",
+ "distro7"
+ ],
+ "from": "distro6-1",
+ "to": "distro7",
+ "length_m": 50.3,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 17.8,
+ "cum_power_kw": 3.68,
+ "vdrop_volts": 5.73
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13644600159497342,
+ 41.70093881235571
+ ],
+ [
+ -0.13620519471107007,
+ 41.70082143554413
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_26",
+ "nodes": [
+ "distro8",
+ "production"
+ ],
+ "from": "distro8",
+ "to": "production",
+ "length_m": 33.9,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 31.4,
+ "cum_power_kw": 6.5,
+ "vdrop_volts": 6.82
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13644600159497342,
+ 41.70093881235571
+ ],
+ [
+ -0.13641071955831735,
+ 41.70109480960767
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_27",
+ "nodes": [
+ "distro8",
+ "la cantina"
+ ],
+ "from": "distro8",
+ "to": "la cantina",
+ "length_m": 27.6,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 25.3,
+ "cum_power_kw": 5.25,
+ "vdrop_volts": 4.48
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.13784153493839352,
+ 41.700393705460776
+ ],
+ [
+ -0.13846142429518712,
+ 41.70047049608174
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_28",
+ "nodes": [
+ "distro9",
+ "mon"
+ ],
+ "from": "distro9",
+ "to": "mon",
+ "length_m": 62.3,
+ "area_mm2": 6.0,
+ "plugs_and_sockets_a": 32.0,
+ "cable_type": "1P 32A \u2014 6.0mm\u00b2",
+ "current_a": 33.3,
+ "cum_power_kw": 6.9,
+ "vdrop_volts": 13.31
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1374435252268257,
+ 41.70067412272905
+ ],
+ [
+ -0.13711247844818483,
+ 41.700427347877145
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_29",
+ "nodes": [
+ "generator",
+ "distro1"
+ ],
+ "from": "generator",
+ "to": "distro1",
+ "length_m": 48.9,
+ "area_mm2": 16.0,
+ "plugs_and_sockets_a": 63.0,
+ "cable_type": "1P 63A \u2014 16.0mm\u00b2",
+ "current_a": 21.4,
+ "cum_power_kw": 4.43,
+ "vdrop_volts": 2.52
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [
+ [
+ -0.1374471485318656,
+ 41.70067850485415
+ ],
+ [
+ -0.13784620827857663,
+ 41.70094177690906
+ ]
+ ]
+ },
+ "properties": {
+ "id": "cable_30",
+ "nodes": [
+ "generator",
+ "distro3"
+ ],
+ "from": "generator",
+ "to": "distro3",
+ "length_m": 54.3,
+ "area_mm2": 16.0,
+ "plugs_and_sockets_a": 63.0,
+ "cable_type": "1P 63A \u2014 16.0mm\u00b2",
+ "current_a": 67.6,
+ "cum_power_kw": 14.0,
+ "vdrop_volts": 8.83
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13777101617447376,
+ 41.70062032145378
+ ]
+ },
+ "properties": {
+ "name": "noinfo",
+ "type": "load",
+ "power": 4940.0,
+ "cum_power": 4940.0,
+ "voltage": 196.6,
+ "vdrop_percent": 14.52,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13846142429518712,
+ 41.70047049608174
+ ]
+ },
+ "properties": {
+ "name": "mon",
+ "type": "load",
+ "power": 13800.0,
+ "cum_power": 13800.0,
+ "voltage": 196.5,
+ "vdrop_percent": 14.57,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13686612235419846,
+ 41.700910652197706
+ ]
+ },
+ "properties": {
+ "name": "malfare",
+ "type": "load",
+ "power": 7000.0,
+ "cum_power": 7000.0,
+ "voltage": 185.5,
+ "vdrop_percent": 19.35,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13706825525949703,
+ 41.70054298992181
+ ]
+ },
+ "properties": {
+ "name": "kunsthaus",
+ "type": "load",
+ "power": 4730.0,
+ "cum_power": 4730.0,
+ "voltage": 205.6,
+ "vdrop_percent": 10.61,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1377138674373942,
+ 41.701476697407855
+ ]
+ },
+ "properties": {
+ "name": "dusty dreamers",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 1000.0,
+ "voltage": 216.0,
+ "vdrop_percent": 6.09,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13792203842668818,
+ 41.701452483139114
+ ]
+ },
+ "properties": {
+ "name": "mooncows",
+ "type": "load",
+ "power": 3000.0,
+ "cum_power": 3000.0,
+ "voltage": 206.2,
+ "vdrop_percent": 10.35,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1382660097964005,
+ 41.70147276635486
+ ]
+ },
+ "properties": {
+ "name": "jugaton",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 1000.0,
+ "voltage": 194.4,
+ "vdrop_percent": 15.48,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1386777546416652,
+ 41.70160605466492
+ ]
+ },
+ "properties": {
+ "name": "camp cambio",
+ "type": "load",
+ "power": 13000.0,
+ "cum_power": 13000.0,
+ "voltage": 130.5,
+ "vdrop_percent": 43.26,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13641071955831693,
+ 41.701094809607675
+ ]
+ },
+ "properties": {
+ "name": "la cantina",
+ "type": "load",
+ "power": 10490.0,
+ "cum_power": 10490.0,
+ "voltage": 177.9,
+ "vdrop_percent": 22.65,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13612322506708008,
+ 41.70063179851722
+ ]
+ },
+ "properties": {
+ "name": "toolhaus",
+ "type": "load",
+ "power": 3340.0,
+ "cum_power": 3340.0,
+ "voltage": 167.6,
+ "vdrop_percent": 27.13,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13620519471107007,
+ 41.70082143554413
+ ]
+ },
+ "properties": {
+ "name": "production",
+ "type": "load",
+ "power": 13000.0,
+ "cum_power": 16340.0,
+ "voltage": 175.6,
+ "vdrop_percent": 23.65,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13642674451628584,
+ 41.69994968066739
+ ]
+ },
+ "properties": {
+ "name": "peach please!",
+ "type": "load",
+ "power": 1500.0,
+ "cum_power": 1500.0,
+ "voltage": 223.4,
+ "vdrop_percent": 2.87,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13719101266114425,
+ 41.69904766985218
+ ]
+ },
+ "properties": {
+ "name": "here & meow",
+ "type": "load",
+ "power": 3680.0,
+ "cum_power": 3680.0,
+ "voltage": 196.2,
+ "vdrop_percent": 14.7,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13749597377242917,
+ 41.69910574225011
+ ]
+ },
+ "properties": {
+ "name": "hyperflow",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 1000.0,
+ "voltage": 209.1,
+ "vdrop_percent": 9.09,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13744135253153258,
+ 41.70067734372315
+ ]
+ },
+ "properties": {
+ "name": "generator",
+ "type": "generator",
+ "power": 0.0,
+ "cum_power": 104940.0,
+ "voltage": 230,
+ "vdrop_percent": 0.0,
+ "distro": {
+ "in": "3P 125A",
+ "out": {
+ "3P 32.0A": 3,
+ "3P 63.0A": 2
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13784153493839313,
+ 41.70039370546078
+ ]
+ },
+ "properties": {
+ "name": "distro9",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 19740.0,
+ "voltage": 209.8,
+ "vdrop_percent": 8.78,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 2,
+ "3P 32.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1368357235149388,
+ 41.700207145864105
+ ]
+ },
+ "properties": {
+ "name": "distro4",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 1560.0,
+ "voltage": 226.4,
+ "vdrop_percent": 1.57,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 2
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13711247844818483,
+ 41.700427347877145
+ ]
+ },
+ "properties": {
+ "name": "distro1",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 6240.0,
+ "voltage": 227.5,
+ "vdrop_percent": 1.09,
+ "distro": {
+ "in": "3P 63.0A",
+ "out": {
+ "3P 32.0A": 2
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1383577949695892,
+ 41.70111101870848
+ ]
+ },
+ "properties": {
+ "name": "distro5-1",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 14000.0,
+ "voltage": 198.2,
+ "vdrop_percent": 13.83,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 2
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13784620827857663,
+ 41.70094177690906
+ ]
+ },
+ "properties": {
+ "name": "distro3",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 27520.0,
+ "voltage": 221.2,
+ "vdrop_percent": 3.83,
+ "distro": {
+ "in": "3P 63.0A",
+ "out": {
+ "1P 16.0A": 5,
+ "3P 32.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13697385920529187,
+ 41.70067479556621
+ ]
+ },
+ "properties": {
+ "name": "werkhaus",
+ "type": "load",
+ "power": 5280.0,
+ "cum_power": 10010.0,
+ "voltage": 215.0,
+ "vdrop_percent": 6.52,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13748051264301162,
+ 41.70142233907045
+ ]
+ },
+ "properties": {
+ "name": "nobarrio",
+ "type": "load",
+ "power": 5520.0,
+ "cum_power": 5520.0,
+ "voltage": 191.9,
+ "vdrop_percent": 16.57,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13735634636904792,
+ 41.70118150860041
+ ]
+ },
+ "properties": {
+ "name": "pink poney paradise",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 1000.0,
+ "voltage": 216.8,
+ "vdrop_percent": 5.74,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1374190711291166,
+ 41.70096791706415
+ ]
+ },
+ "properties": {
+ "name": "le gratin",
+ "type": "load",
+ "power": 3000.0,
+ "cum_power": 3000.0,
+ "voltage": 211.0,
+ "vdrop_percent": 8.26,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13644600159497342,
+ 41.70093881235571
+ ]
+ },
+ "properties": {
+ "name": "distro8",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 26830.0,
+ "voltage": 182.4,
+ "vdrop_percent": 20.7,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "3P 32.0A": 2
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13729986190314222,
+ 41.69989841057792
+ ]
+ },
+ "properties": {
+ "name": "distro7",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 4680.0,
+ "voltage": 219.4,
+ "vdrop_percent": 4.61,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "3P 32.0A": 1
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.137206218374929,
+ 41.69953266449073
+ ]
+ },
+ "properties": {
+ "name": "distro6-1",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 4680.0,
+ "voltage": 213.7,
+ "vdrop_percent": 7.09,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 2
+ }
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13788338990168839,
+ 41.70020670287022
+ ]
+ },
+ "properties": {
+ "name": "artinstallation1",
+ "type": "load",
+ "power": 1000.0,
+ "cum_power": 1000.0,
+ "voltage": 207.5,
+ "vdrop_percent": 9.78,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13648507855198813,
+ 41.7002150517457
+ ]
+ },
+ "properties": {
+ "name": "ohana haus",
+ "type": "load",
+ "power": 60.0,
+ "cum_power": 60.0,
+ "voltage": 226.2,
+ "vdrop_percent": 1.65,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13709073026884314,
+ 41.70089822126032
+ ]
+ },
+ "properties": {
+ "name": "welfare",
+ "type": "load",
+ "power": 4000.0,
+ "cum_power": 4000.0,
+ "voltage": 186.3,
+ "vdrop_percent": 19.0,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.1368415609243572,
+ 41.70105017775878
+ ]
+ },
+ "properties": {
+ "name": "barrio support",
+ "type": "load",
+ "power": 3600.0,
+ "cum_power": 3600.0,
+ "voltage": 187.6,
+ "vdrop_percent": 18.43,
+ "distro": {
+ "in": "1P 16.0A",
+ "out": {}
+ }
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ -0.13678209537425187,
+ 41.70081371865842
+ ]
+ },
+ "properties": {
+ "name": "distro2",
+ "type": "load",
+ "power": 0.0,
+ "cum_power": 41430.0,
+ "voltage": 197.4,
+ "vdrop_percent": 14.17,
+ "distro": {
+ "in": "3P 32.0A",
+ "out": {
+ "1P 16.0A": 3,
+ "3P 32.0A": 1
+ }
+ }
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/power-nodes.geojson b/tests/fixtures/power-nodes.geojson
new file mode 100644
index 0000000..771f9dd
--- /dev/null
+++ b/tests/fixtures/power-nodes.geojson
@@ -0,0 +1,509 @@
+{
+ "type": "FeatureCollection",
+ "features": [
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012601724790615,
+ 41.69369570931962
+ ]
+ },
+ "properties": {
+ "name": "Smurfberry Square",
+ "power": 8300.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013187004600646,
+ 41.69388676697657
+ ]
+ },
+ "properties": {
+ "name": "Frostroot Pond",
+ "power": 2489.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013736697049132,
+ 41.69411811618633
+ ]
+ },
+ "properties": {
+ "name": "Windmill Workshop",
+ "power": 5282.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014407752998134,
+ 41.694342036303986
+ ]
+ },
+ "properties": {
+ "name": "Bluecap Cantina",
+ "power": 4460.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014711690547531,
+ 41.69367822876284
+ ]
+ },
+ "properties": {
+ "name": "Handy Smurf Shed",
+ "power": 3340.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014991049923288,
+ 41.69416195838285
+ ]
+ },
+ "properties": {
+ "name": "Sarsaparilla Depot",
+ "power": 11085.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013971119911104,
+ 41.693566269158296
+ ]
+ },
+ "properties": {
+ "name": "Painter's Nook",
+ "power": 3170.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013881640605278,
+ 41.693883671415804
+ ]
+ },
+ "properties": {
+ "name": "Meadowfare Crossing",
+ "power": 10370.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013176939681517,
+ 41.69424326113461
+ ]
+ },
+ "properties": {
+ "name": "Smurf Village Generator",
+ "power": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013974036329216,
+ 41.69341372910276
+ ]
+ },
+ "properties": {
+ "name": "Alpine Mushroom Camp",
+ "power": 1840.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013275618646183,
+ 41.6945809953396
+ ]
+ },
+ "properties": {
+ "name": "Lantern Courtyard",
+ "power": 7360.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013098795114679,
+ 41.69468215142684
+ ]
+ },
+ "properties": {
+ "name": "Jolly Barrel Yard",
+ "power": 3680.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013567058505581,
+ 41.69429047006563
+ ]
+ },
+ "properties": {
+ "name": "Whysmurf Way",
+ "power": 1840.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014216036149255,
+ 41.69269715543164
+ ]
+ },
+ "properties": {
+ "name": "Greyroot Terrace",
+ "power": 1840.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013780727016075,
+ 41.69368242729501
+ ]
+ },
+ "properties": {
+ "name": "Blossom Burrow",
+ "power": 60.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014064935460199,
+ 41.69316063383619
+ ]
+ },
+ "properties": {
+ "name": "Gratin Grove",
+ "power": 1840.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013942012982799,
+ 41.69369622907524
+ ]
+ },
+ "properties": {
+ "name": "Wellness Wagon",
+ "power": 1730.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013838514704698,
+ 41.693785955927815
+ ]
+ },
+ "properties": {
+ "name": "Guardhouse Gate",
+ "power": 480.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013728646000826,
+ 41.693888856593404
+ ]
+ },
+ "properties": {
+ "name": "Talking Oak Circle",
+ "power": 250.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014538945570639,
+ 41.69424182002816
+ ]
+ },
+ "properties": {
+ "name": "Coldberry Cellar",
+ "power": 1000.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014731024700404,
+ 41.694267966088944
+ ]
+ },
+ "properties": {
+ "name": "Spoon Sprout House",
+ "power": 3100.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014034816911164,
+ 41.69388206456499
+ ]
+ },
+ "properties": {
+ "name": "Geekstone Workshop",
+ "power": 2010.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013530550069384,
+ 41.6939986601976
+ ]
+ },
+ "properties": {
+ "name": "Gloria Garden",
+ "power": 200.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013177066263024,
+ 41.69396592012233
+ ]
+ },
+ "properties": {
+ "name": "Mirror Pond",
+ "power": 1000.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.01277661731886,
+ 41.693412531888825
+ ]
+ },
+ "properties": {
+ "name": "Veritas Square",
+ "power": 200.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012889967974597,
+ 41.693826743839494
+ ]
+ },
+ "properties": {
+ "name": "Tomorrow Post",
+ "power": 90.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013052557387299,
+ 41.69359542301004
+ ]
+ },
+ "properties": {
+ "name": "Moonask Ridge",
+ "power": 2800.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012435894206149,
+ 41.69411427729749
+ ]
+ },
+ "properties": {
+ "name": "Starcap Terrace",
+ "power": 450.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012272609024222,
+ 41.693585054948926
+ ]
+ },
+ "properties": {
+ "name": "Reflection Brook",
+ "power": 200.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.013614730234054,
+ 41.69303594213013
+ ]
+ },
+ "properties": {
+ "name": "Split Cottage Lane",
+ "power": 72.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.011865170446592,
+ 41.69345512319555
+ ]
+ },
+ "properties": {
+ "name": "Bouncecap Meadow",
+ "power": 2000.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012222788859019,
+ 41.693270366207
+ ]
+ },
+ "properties": {
+ "name": "Lighthouse Knoll",
+ "power": 300.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014415418961589,
+ 41.69427829070426
+ ]
+ },
+ "properties": {
+ "name": "Cratewheel Yard",
+ "power": 0.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012895850532078,
+ 41.69472316785931
+ ]
+ },
+ "properties": {
+ "name": "Rainbow Camp",
+ "power": 3680.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.014002382889214,
+ 41.69456574065345
+ ]
+ },
+ "properties": {
+ "name": "Soupkettle Corner",
+ "power": 400.0
+ }
+ },
+ {
+ "type": "Feature",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ 0.012336237078207,
+ 41.694843707438054
+ ]
+ },
+ "properties": {
+ "name": "Dreamdust Field",
+ "power": 3680.0
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/test_analyze.py b/tests/test_analyze.py
index c7c55f8..13abd9a 100644
--- a/tests/test_analyze.py
+++ b/tests/test_analyze.py
@@ -60,7 +60,7 @@ def test_analyze_raises_when_no_cables_are_present():
analyze(grid)
-def test_compute_voltage_drop_uses_phase_voltage_reference():
+def test_ltage_drop_uses_phase_voltage_reference():
generator = PowerNode(
name="generator",
lon=0.0,
diff --git a/uv.lock b/uv.lock
index e1bf313..eb3e983 100644
--- a/uv.lock
+++ b/uv.lock
@@ -14,6 +14,15 @@ conflicts = [[
{ package = "nopywer", extra = "pandapower" },
]]
+[[package]]
+name = "absl-py"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/64/c7/8de93764ad66968d19329a7e0c147a2bb3c7054c554d4a119111b8f9440f/absl_py-2.4.0.tar.gz", hash = "sha256:8c6af82722b35cf71e0f4d1d47dcaebfff286e27110a99fc359349b247dfb5d4", size = 116543, upload-time = "2026-01-28T10:17:05.322Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/18/a6/907a406bb7d359e6a63f99c313846d9eec4f7e6f7437809e03aa00fa3074/absl_py-2.4.0-py3-none-any.whl", hash = "sha256:88476fd881ca8aab94ffa78b7b6c632a782ab3ba1cd19c9bd423abc4fb4cd28d", size = 135750, upload-time = "2026-01-28T10:17:04.19Z" },
+]
+
[[package]]
name = "annotated-doc"
version = "0.0.4"
@@ -114,6 +123,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
+[[package]]
+name = "et-xmlfile"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" },
+]
+
[[package]]
name = "fastapi"
version = "0.135.1"
@@ -203,6 +221,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
+[[package]]
+name = "immutabledict"
+version = "4.3.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/e6/718471048fea0366c3e3d1df3acfd914ca66d571cdffcf6d37bbcd725708/immutabledict-4.3.1.tar.gz", hash = "sha256:f844a669106cfdc73f47b1a9da003782fb17dc955a54c80972e0d93d1c63c514", size = 7806, upload-time = "2026-02-15T10:32:34.668Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a3/ce/f9018bf69ae91b273b6391a095e7c93fa5e1617f25b6ba81ad4b20c9df10/immutabledict-4.3.1-py3-none-any.whl", hash = "sha256:c9facdc0ff30fdb8e35bd16532026cac472a549e182c94fa201b51b25e4bf7bf", size = 5000, upload-time = "2026-02-15T10:32:33.672Z" },
+]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -692,6 +719,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" },
]
+[[package]]
+name = "protobuf"
+version = "6.33.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" },
+ { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" },
+ { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" },
+]
+
[[package]]
name = "pydantic"
version = "2.12.5"