Skip to content

Fix several regressions in 'simplify()' - #166

Merged
p-snft merged 19 commits into
devfrom
fix/simplify_regressions
Jul 15, 2026
Merged

Fix several regressions in 'simplify()'#166
p-snft merged 19 commits into
devfrom
fix/simplify_regressions

Conversation

@jnettels

@jnettels jnettels commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Hi @p-snft, now that I have had an opportunity to review #157, these are the possible improvements and regressions that I found. I consider this a blocker that needs to be solved before I can continue with #164. I considered putting all the changes in there but I hope it is cleaner this way.

TLDR: I would like to assign the remaining work back to you.

Stuff already fixed

  • Suggested changes in simplify()
    • This may be worth a discussion, but I see no reason to keep the attribute path in the gdf returned by simplify(). It adds unnescessary size to the GeoDataFrame
    • Add a debug logger output about achieved reduction in line count
  • Regressions in simplify() compared to the old weld_segments():
    • CRS of GeoDataFrame lines is lost in the process
    • Index name of GeoDataFrame lines is lost
    • Line orientation needs to be redefined to match the new geometries, otherwise the flow direction defined later by dhnx becomes meaningless
    • Columns type and id_full of GeoDataFrame lines were just dropped (which is related to the next point)
    • Building connection lines (type = 'HL', 'GL') must not be merged with distribution lines ('DL')
      • Currently they are merged, which can change their direction. This will cause
        dhnx to fail, because it expects connection lines to always go towards producers/consumers.
        (see e.g. dhs_nodes.py line 170 "Pipes must not go from 'consumers'!")
      • For statistical / economical purposes it can be important to distinguish between distribution lines
        and building connection lines.
        "Luckily", it is the same problem I need to solve to enable support for existing pipes.
        For given columns (here type), pipes must not be merged if their values ('DL', 'GL', 'HL') differ.

Most of the above were quite simple fixes. Only the function to prevent certain pipes from merging was a little bit more involved. I hope my solution with retain_unique_values is acceptable.

Remaining issues

  • Decide whether to rename welding to simplify
  • Remove geometry_operations.drop_parallel_lines(), as it is superseded by simplify()
  • You try to identify and delete the longer detour when any two points have two paths between them. This should not be applied if along those paths any pipe segment is an existing=1 pipe (i.e. a pipe with a fixed capacity). We cannot know up front if the path containing the existing pipe has the required capacity to allow a feasible network. When working with existing pipes you sometimes have to deliberately create detours that the solver will use once the capacities of the existing pipes are exhausted.
    • Do you want me to create a minimum working example for such a case?
  • I found one example where simplify just does not seem to do what it is supposed to, i.e. of two alternatives a longer path is preserved. See the example below

Consider this input:
image

Commit 1e2b011 (last version with old 'welding', before merge of new simplify()) yields
image
This is fine, only the roundabout could potentially be simplified

The result of the current dev branch 465d067 and after including my fixes is the same:
image

I messed with the input geometry a bit, removing and redrawing that detour by hand, but the results were the same.

At this point I would like to assign this back to you, if possible. I have attached the python script and input data for reproducing the issue. I hope you have a better chance to fix it.

import matplotlib.pyplot as plt
import geopandas as gpd
import dhnx
import logging

logging.basicConfig(
    format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
    datefmt='%H:%M:%S')

logger = logging.getLogger(__name__)  # Create a logger for this module
logger.setLevel(level='DEBUG')  # Logger for this module
logging.getLogger('dhnx').setLevel(level='DEBUG')

# Load input
gdf_lines_streets = gpd.read_file("./input/streets_input.geojson")
gdf_poly_gen = gpd.read_file("./input/producers_polygon.geojson")
gdf_poly_houses = gpd.read_file("./input/consumers_polygon.geojson")

# Plot input
_, ax = plt.subplots(figsize=(20, 10), dpi=300)
gdf_poly_houses.to_crs("EPSG:4647").plot(ax=ax, color='green')
gdf_poly_gen.to_crs("EPSG:4647").plot(ax=ax, color='red')
gdf_lines_streets.to_crs("EPSG:4647").plot(ax=ax, color='blue')
plt.title('Input geometry')
plt.show()

# Process the geometry
reset_index = True
tn_input = dhnx.gistools.connect_points.process_geometry(
    lines=gdf_lines_streets,
    producers=gdf_poly_gen.copy(),
    consumers=gdf_poly_houses.copy(),
    method="boundary",
    reset_index=reset_index,
)

# Plot output after processing the geometry
_, ax = plt.subplots(figsize=(20, 10), dpi=300)
tn_input['consumers'].plot(ax=ax, color='green')
tn_input['producers'].plot(ax=ax, color='red')
tn_input['pipes'].plot(ax=ax, color='blue')
tn_input['forks'].plot(ax=ax, color='grey')
plt.title('Geometry after pre-processing')
plt.show()

print("Tests:")
print(tn_input['pipes'].crs is not None)
print([c in tn_input['pipes'].columns for c in ['type', 'id_full']])
if reset_index:
    print(tn_input['pipes'].index.name == 'id')

# A test for line geometry orientation matching 'from_node' and 'to_node'
# would be possible, but is a little bit more complicated

I hope the download works:
test_dhnx_simplify_regressions.zip

@jnettels
jnettels requested a review from p-snft July 8, 2026 11:51
@jnettels jnettels mentioned this pull request Jul 8, 2026
6 tasks
@p-snft

p-snft commented Jul 8, 2026

Copy link
Copy Markdown
Member

Hi @jnettels, sorry for messing this up. I probably rely to much on working tests, and was just content after my (green field) test case worked out well. I'll look into that immediately.

I think, handling existing pipes (without investment) might be tackled the following way, you already partly implemented:

  1. They may not be removed. This includes they may not be aggegated with other (non existing) pipes.
  2. They get zero length or weight when searching for detours.

PS: A minimum working example for a case with existing pipes would be great. I tend to use it as an integration test if it's present.

PPS: I extracted the problem you mentioned above from the dataset: roundtrip.zip

PPPS: The problem occurs in the _remove_useless_forks step: The useless forks in the detour are removed and overwrite the shorter path. I will fix it tomorrow, when I can think straight again. (It's late already.)

@jnettels

jnettels commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

I think that solved one of the two remaining issues, nice!

Now the "keeping existing pipes" To Do remains, will you tackle that, too?

New test data:
streets_input_existing.zip

In the previous example input, I set two pipes as existing:
image
These existing pipes need to survive gistools.connect_points.process_geometry().

This is the expected optimization result: The diagonal one in the middle has a large capacity, but just enough for all buildings connected directly. This forces usage of the outer path. There, the shorter detour is existing, but with a tiny capacity. Therefore the longer detour is needed for extra capacity
image

To get this result, some of the changes in #164 are required.

One last(?) thing we should do in here is drop the following in connect_points.py and remove drop_parallel_lines() completely.

# Keep only the shortest of all lines connecting the same two points
lines = go.drop_parallel_lines(lines)

Your simplify() should provide the same functionality, and drop_parallel_lines() also does not respect existing pipes.

@p-snft

p-snft commented Jul 9, 2026

Copy link
Copy Markdown
Member

Thanks for the example. Reading through it made me reconsider my above suggestion: Treating existing pipes to have zero weight when searching for detours is only true if the existing capacity is sufficient. However, I do not see why existing pipes should be forced to stay in the optimisation. If it is cheaper to replace an existing pipe by a new one, why should you build a round-trip?

So, I see the following cases:

  • Branch with existing pipe is shorter. This does not need any changes in simplify(). (I assume that replacing a pipe is an option.)
  • Branch with existing pipe is longer. Now, there are two sub-cases:
    • Pipes needed to connect the existing pipe are longer than the direct connection. This means the existing pipe are clearly useless and the longer that can be removed.
    • Pipes needed to connect the existing pipe are shorter than the direct connection. This means that the existing pipe might be used, depending on its capacity. We have to keep both branches.

Your suggestion, however is to always keep both branches if there is something existing on either side. This will delay the decision to a later stage and would be more flexible. I can live with that approach but would not force users to have existing pipes untouched in the optimisation.

@jnettels

jnettels commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Hmm... interesting... to your points:

Branch with existing pipe is shorter. This does not need any changes in simplify(). (I assume that replacing a pipe is an option.)

That assumption is the critical part. When you run the new example without the longer (non-existing) detour, you get the following result:

11:40:32 pyomo.core   WARNING  Loading a SolverResults object with a warning status into model.name="Model";
  - termination condition: infeasible
  - message from solver: Model was proven to be infeasible.

RuntimeError: The solver did not return an optimal solution. Instead the optimization ended with
       - status: warning
       - termination condition: infeasible

This is true, because there simply is now way to provide capacity to the downstream consumers. This is the worst-case scenario from a "user experience" point of view, because

  • the program effectively just crashes
  • even if you catch that exception, you have no way of knowing where the bottleneck in your network is

I would much rather have a solvable network. The user always has to check the results and then they will find the parallel paths. Then they have to make a decision of e.g. replacing the pipe in the input data.

Branch with existing pipe is longer

For both cases you describe, the existing pipe may have a very large capacity (large DN). And the new detour might only need to be a small DN to achieve the total required capacity. There could always be cases where you would not declare the existing pipe "useless", if the investment for a complete replacement is much larger.
To some degree the solver should be able to answer that (though I am not really sure). In any case, the user should be expected to update the input data if they are not satisfied with the results, instead of us making decisions for them, where we actually cannot foresee all the consequences.

I can live with that approach but would not force users to have existing pipes untouched in the optimisation.

I must admit that I am not sure what you mean here. :-)

Renaming back and forth is unnecessary.
@p-snft

p-snft commented Jul 9, 2026

Copy link
Copy Markdown
Member

You are right with your statement about usefulness of existing pipes. Additionally, not using an existing pipe might also be a good idea because it has too much capacity (and thus high losses). This, however, seems to be impossible right now. Now, let us have a look at the infeasibility from above:

image

What I would expect here, is that pipe (a) is decommissioned and replaced by a new, and bigger pipe. (The opposite of „existing pipes untouched“ as I called it above). In this special case, the system remains feasible by the coincidence that there is a possible detour (b). However, we should not rely on that.

But this is something to be addressed in the optimisation model. The solution on pre-processing level is just what you mentioned above: Keep both branches, let the optimiser decide.

@jnettels

jnettels commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Ok, then we agree.

These "infeasible" errors were actually very annoying for me when I worked with a larger network where I had to manually check which consumers caused it. Now I realize that it should be possible to automatically create duplicates of the existing network segments and assign them a separate hp_type with much higher fixed costs. That could yield a result where the solver chooses them only if absolutely necessary. Then you could automatically determine where these existing and new pipes overlap and replace both with one pipe with their combined capacity, plus an additional attribute that flags a "replacement".
The downside would be a longer runtime for the solver in cases where no replacements are necessary... but one could create a system where this workflow is used only in case an infeasible network is detected.

...well, that is a topic for some other time^^

This includes a refactor of _remove_useless_forks to allow reusing part
of its logic.
@p-snft

p-snft commented Jul 9, 2026

Copy link
Copy Markdown
Member

I realised that the change to keep unique values can imply an issue: Now, there is a reason to have two edges connecting the same two nodes, which is currently not supported.

@jnettels

jnettels commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

I could hardly remember, but yes, that must have been the reason why I implemented go.drop_parallel_lines() in ce70d71.

However, here is what I found with our current test cases:

  • For streets_input.geojson (the one without existing pipes)
  • For streets_input_existing.geojson
    • In Features/update existing pipes #164:
      • When go.drop_parallel_lines() is disabled and welding=False
        • I get the expected output with both detour paths kept and flow divided among them (as shown earlier)
      • When go.drop_parallel_lines() is disabled and welding=True
        • The in-between nodes of the detour are removed and the AssertionError from below is expected, but actually another AttributeError: 'NoneType' object has no attribute 'maximum' is raised instead. But I assume I can ignore that when simplify replaces welding
    • In Fix several regressions in 'simplify()' #166
      • When go.drop_parallel_lines() is disabled and welding=True
        • The detour is still removed, keeping only the path with limited capacity --> infeasible network
        • So far I was not able to implement a solution that keeps both paths intact if one of them contains pipes with existing=1
  • For a new case streets_input_detour_merged.geojson where I redrew the detour as a single MultiLine (I guess?)
    • Download: streets_input_detour_merged.zip
    • When go.drop_parallel_lines() is disabled and welding=False
      • The network solves just fine, because there are still new nodes in between
      • If I also comment out lines = go.split_multilinestr_to_linestr(lines) in connect_points.py, I get the expected AssertionError: There is more than one pipe that connects ['forks-22 to forks-2'], because now no node is inserted in between

So, what is the consequence?

  • I think simplify() must keep detours if one contains an existing pipe
    • While simplify() tries to remove all in-between nodes normally, in this special case at least one node must remain on one of the paths.
      • Bonus points: Introduce a savety-node on one of the paths if there is no remaining node.
        • I would assume that this can only be the case for identical straight lines on top of each other

Do you think you can do that?

p-snft added 2 commits July 10, 2026 13:30
In the process, I refactored some of the logic functions to be
easier to understund. for example, `_have_unique_values` was True
if there are different values. The new name inverts the logic to
`_attribute_values_equal` and should be easier to understand.
(Also, the separate NaN treatment in `_all_values_equal` was useless,
as NaN != any value.)
@p-snft

p-snft commented Jul 10, 2026

Copy link
Copy Markdown
Member

Now, both subroutines of simplify, _drop_detours and _remove_useless_forks will not drop any edge if there are properties to be kept or existing pipelines. (It is still not fully tested, but I suggest removing the simulation part before adding the tests to the CI pipeline. Those tests always fail in my environment and I don't see the functionality fixed.)

@p-snft
p-snft marked this pull request as ready for review July 10, 2026 11:41
@jnettels

jnettels commented Jul 10, 2026

Copy link
Copy Markdown
Contributor Author

Hmmm.. we really need to step up those tests, yes.
Is there an elegant way to get cbc working in the automated test environments, to be able to test a complete workflow? This example has proven to cover many use cases. (Though that should be a different PR.) In another repo of mine I install cbc within a GitHub Workflow and run a script directly as a kind of test. But that feels less elegant then the pytest setup.


The "existing" detour now seems to be handled as required, nice!

However, I found a new regression. The previous commit da0e64c was fine with streets_input.geojson.
image

Now we get ValueError: The consumer id 22 has no connection to the grid! from network.optimize_investment(invest_options=invest_opt, **settings).
image
That seems to be a lie, because the connection seems kind of fine. Attributes from_node, to_node and type='HL' are fine.
Almost! "to" and "from" are reversed: "to_node" must be "consumers-22", but it is "forks-210".

simplify() has now manipulated the building connection line, which is something it was not supposed to do. This happens for all building connections that are endings - they get merged until the first split in the path.
Basically, keep_unique_values has not been respected here.

Sorry :-)


PS A note to a previous comment:

Additionally, not using an existing pipe might also be a good idea because it has too much capacity (and thus high losses). This, however, seems to be impossible right now.

Actually, in #164 one of my changes is storing the actual flow in the results. For existing pipes, a user can now compare capacity and flow. This allows them to directly see how much of the existing capacity is actually needed and identify pipe segments that are e.g. severely under-utilized.


And yes, the simulation part is completely unsupported, to my knowledge. Plus pandapipes works well enough for what I would assume the simulation should have become. I have a bad feeling about deleting the code... but the tests should be disabled, I agree

@p-snft

p-snft commented Jul 13, 2026

Copy link
Copy Markdown
Member

Is there an elegant way to get cbc working in the automated test environments, to be able to test a complete workflow?

We do that in solph. But even if the tests are not ran automatically, you should really define (failing) tests. I ran all the example code you shared above with both datasets for street input data and they finished through just fine. Also, I do not get why

  1. in your example ("forks-210", "forks-219") and ("consumers-22", "forks-219") get merged if only one of them is a building connection, and
  2. you have an undirected graph between the forks (naturally) but require one distinct direction between some other nodes. (The docstring of check_input tells that "consumer -> fork" is forbidden in favour of strict "fork -> consumer", but for whatever reason direction does not matter for "fork <-> producer".)

@jnettels

Copy link
Copy Markdown
Contributor Author

Ok, several things to discuss in parallel :-)


you should really define (failing) tests

Agree! Once we are done and have finalized the "expected result", I would suggest adding the following to test_gistools.py. This is much faster than a complete solver test including network.optimize_investment(invest_options=invest_opt, **settings) and this could cover everything we have discussed here.

def test_process_geometry():
    """Test ``process_geometry()`` function with a simple example.

    It includes pipes with ``existing=1``, due to which an otherwise
    deleted detour must be kept.
    """
    base_dir = os.path.join(os.path.dirname(__file__), "_files/process_geometry")
    gdf_lines = gpd.read_file(os.path.join(base_dir, "in/lines_input_existing.geojson"))
    gdf_prod = gpd.read_file(os.path.join(base_dir, "in/producers_polygon.geojson"))
    gdf_cons = gpd.read_file(os.path.join(base_dir, "in/consumers_polygon.geojson"))

    tn_input = cp.process_geometry(
        lines=gdf_lines,
        producers=gdf_prod,
        consumers=gdf_cons,
        method="boundary",
        reset_index=True,
        welding=True,
    )

    assert tn_input['pipes'].crs is not None
    assert [c in tn_input['pipes'].columns for c in ['type', 'id_full']]
    assert tn_input['pipes'].index.name == 'id'

    # Update expected result
    # tn_input['pipes'].to_file(os.path.join(base_dir, "out/pipes.geojson"))

    # Load expected result
    gdf_pipes_test = gpd.read_file(os.path.join(base_dir, "out/pipes.geojson")).set_index('id')

    assert gdf_pipes_test.equals(tn_input['pipes'])

  1. you have an undirected graph between the forks (naturally) but require one distinct direction between some other nodes. (The docstring of check_input tells that "consumer -> fork" is forbidden in favour of strict "fork -> consumer", but for whatever reason direction does not matter for "fork <-> producer".)

You are right, that is actually suspicious. I quickly did the following two changes:

  • Disable the initial test in check_inputs()
  • Allow construction of a "reverse" house connection line (just like with producers) in dhs_nodes.py
diff --git a/src/dhnx/optimization/dhs_nodes.py b/src/dhnx/optimization/dhs_nodes.py
index 898fa58..670ac81 100644
--- a/src/dhnx/optimization/dhs_nodes.py
+++ b/src/dhnx/optimization/dhs_nodes.py
@@ -156,9 +156,15 @@ def add_nodes_dhs(opti_network, gd, nodes, busd):
                 )
 
             elif q["from_node"].split("-")[0] == "consumers":
-                raise ValueError(
-                    "Pipes must not go from 'consumers'!"
-                    " Existing heatpipe id {}".format(p)
+                start = q["to_node"]
+                end = q["from_node"]
+                b_in = busd[(d_labels["l_1"], d_labels["l_2"], "bus", start)]
+                b_out = busd[("consumers", d_labels["l_2"], "bus", end)]
+
+                d_labels["l_4"] = start + "-" + end
+
+                nodes = ac.add_heatpipes(
+                    pipe_data, d_labels, False, q["length"], b_in, b_out, nodes
                 )
 
             elif q["to_node"].split("-")[0] == "producers":
diff --git a/src/dhnx/optimization/optimization_models.py b/src/dhnx/optimization/optimization_models.py
index 6336508..8fe05f6 100644
--- a/src/dhnx/optimization/optimization_models.py
+++ b/src/dhnx/optimization/optimization_models.py
@@ -208,7 +208,7 @@ class OemofInvestOptimizationModel(InvestOptimizationModel):
                             cons_id, p
                         )
                     )
-
+        """
         pipe_to_cons_ids = list(
             self.thermal_network.components["pipes"]["to_node"].values
         )
@@ -224,7 +224,7 @@ class OemofInvestOptimizationModel(InvestOptimizationModel):
                     "The consumer id {} has no connection the the"
                     "grid!".format(id)
                 )
-
+        """
         # Check 3
         # check if all components of network are connected
         self.thermal_network.nx_graph = self.thermal_network.to_nx_graph()

For our simple test case here, our network solved correctly. We would have to ask Johannes why he implemented that restriction in the first place... but I do not see why the solver would care.
Instead of disabling the test in check_inputs() I would test if either end of the line points to the consumers... but apart from that I would be fine changing that.

There is a big HOWEVER, though, which leads to

Also, I do not get why in your example ("forks-210", "forks-219") and ("consumers-22", "forks-219") get merged if only one of them is a building connection

The fact that the network can now be solved does not mean the result from simplify() is completely fine.
This is the result I currently get in our branch here, plus the changes above:
image
In some cases a "DL" goes completely to the consumer, some "HL" go to what previously was the first fork in the road. This messes up any statistics a user would create about length of distribution lines are vs. length of house connection lines.

Are you saying that you cannot reproduce the issue?
If it helps, this is the complete code that gets me the previous image. Note that it now uses streets_input.geojson (without existing pipes), because this branch will have problems without #164

import os
import io
import pandas as pd
import matplotlib.pyplot as plt
import geopandas as gpd
import dhnx
import logging
import dhnx

logging.basicConfig(
    format='%(asctime)s %(name)-12s %(levelname)-8s %(message)s',
    datefmt='%H:%M:%S')

logger = logging.getLogger(__name__)  # Create a logger for this module
logger.setLevel(level='DEBUG')  # Logger for this module
logging.getLogger('dhnx').setLevel(level='DEBUG')


def get_default_dhnx_invest_options():
    """Generate a dictionary with default investments options for DHNx."""
    consumers_bus = """
label_2,active,excess,shortage,shortage costs,excess costs
heat,1,0,0,999999,9999
"""
    consumers_demand = """
label_2,active,nominal_capacity
heat,1,1
"""
    producers_bus = """
,label_2,active,excess,shortage,shortage costs,excess costs
1,heat,1,0,0,9999,9999
"""
    producers_source = """
label_2,active
heat,1
"""
    network = """
label_3,nonconvex,l_factor,l_factor_fix,cap_min,cap_max,capex_pipes,fix_costs
pipe-generic,1,1.5812551e-07,0.0221164,1,519307.085403,0.02908927,948.69105059
"""

    invest_options = dict(
        consumers=dict(
            bus=pd.read_csv(io.StringIO(consumers_bus)),
            demand=pd.read_csv(io.StringIO(consumers_demand)),
            ),
        producers=dict(
            bus=pd.read_csv(io.StringIO(producers_bus)),
            source=pd.read_csv(io.StringIO(producers_source)),
            ),
        network=dict(
            pipes=pd.read_csv(io.StringIO(network))
            ),
        )
    return invest_options


# Load input
gdf_lines_streets = gpd.read_file("./input/streets_input.geojson")
# gdf_lines_streets = gpd.read_file("./input/streets_input_existing.geojson")
# gdf_lines_streets = gpd.read_file("./input/streets_input_detour_merged.geojson")
gdf_poly_gen = gpd.read_file("./input/producers_polygon.geojson")
gdf_poly_houses = gpd.read_file("./input/consumers_polygon.geojson")

# Plot input
_, ax = plt.subplots(figsize=(20, 10), dpi=300)
gdf_poly_houses.to_crs("EPSG:4647").plot(ax=ax, color='green')
gdf_poly_gen.to_crs("EPSG:4647").plot(ax=ax, color='red')
gdf_lines_streets.to_crs("EPSG:4647").plot(ax=ax, color='blue')
plt.title('Input geometry')
plt.show()

gdf_lines_streets = gdf_lines_streets.replace({'hp_type': {'pipe-typ-A': 'pipe-generic'}})
gdf_lines_streets = gdf_lines_streets.fillna({'existing': 0})
gdf_lines_streets = gdf_lines_streets.replace({None: float('nan')})

if 'existing' in gdf_lines_streets.columns:
    _, ax = plt.subplots(figsize=(20, 10), dpi=300)
    gdf_lines_streets.plot(ax=ax, column='existing', legend_kwds={'label': 'existing'}, legend=True)
    plt.title('Status "existing" of input pipes')
    plt.show()

# Process the geometry
reset_index = True
tn_input = dhnx.gistools.connect_points.process_geometry(
    lines=gdf_lines_streets,
    producers=gdf_poly_gen.copy(),
    consumers=gdf_poly_houses.copy(),
    method="boundary",
    reset_index=reset_index,
    # welding=False,
)

# Plot output after processing the geometry
_, ax = plt.subplots(figsize=(20, 10), dpi=300)
tn_input['consumers'].plot(ax=ax, color='green')
tn_input['producers'].plot(ax=ax, color='red')
tn_input['pipes'].plot(ax=ax, color='blue')
tn_input['forks'].plot(ax=ax, color='grey')
plt.title('Geometry after pre-processing')
plt.show()

# breakpoint()

print("Tests:")
print(tn_input['pipes'].crs is not None)
print([c in tn_input['pipes'].columns for c in ['type', 'id_full']])
if reset_index:
    print(tn_input['pipes'].index.name == 'id')

# Optionally export the geodataframes and load it into qgis
# for checking the results of the geometry processing
save_path = './output'
if save_path is not None:
    if not os.path.exists(os.path.dirname(save_path)):
        os.makedirs(os.path.dirname(save_path))
    for filename, gdf in tn_input.items():
        try:
            gdf.to_file(os.path.join(save_path, filename)+'.geojson')
            gdf.to_file(os.path.join(save_path, filename)+'.gpkg')
        except Exception as e:
            print(gdf)
            breakpoint()
            logger.exception(e)


# initialize a ThermalNetwork
network = dhnx.network.ThermalNetwork()

# add the pipes, forks, consumer, and producers to the ThermalNetwork
for k, v in tn_input.items():
    network.components[k] = v

# check if ThermalNetwork is consistent
network.is_consistent()

# load the specification of the oemof-solph components
invest_opt = get_default_dhnx_invest_options()

settings = dict(
    # solver='cbc',
    solver='gurobi',
    # return_existing=True,  # Requires PR # 164
    )

# Perform the investment optimisation
network.optimize_investment(invest_options=invest_opt, **settings)

results_edges = network.results.optimization['components']['pipes']
gdf_pipes = network.components['pipes'].copy()
cols_drop = [c for c in results_edges.columns if c in gdf_pipes]
gdf_pipes = gdf_pipes.drop(columns=cols_drop)  # Drop duplicate columns
gdf_pipes = gdf_pipes.join(results_edges, rsuffix='_results')
gdf_pipes = gdf_pipes[gdf_pipes['capacity'] > 0]  # Keep only DN>0 in output

# Plot output after processing the geometry
_, ax = plt.subplots(figsize=(20, 10), dpi=300)
gdf_poly_houses.to_crs("EPSG:4647").plot(ax=ax, color='green')
gdf_poly_gen.to_crs("EPSG:4647").plot(ax=ax, color='red')
gdf_pipes.to_crs("EPSG:4647").plot(ax=ax, column='capacity')
plt.title('Network result')
plt.show()

gdf_pipes.to_file('./output/pipes_result.gpkg')

_, ax = plt.subplots(figsize=(20, 10), dpi=300)
gdf_pipes.to_crs("EPSG:4647").plot(ax=ax, column='type', legend=True, cmap='viridis')
gdf_poly_houses.to_crs("EPSG:4647").plot(ax=ax, color='green')
gdf_poly_gen.to_crs("EPSG:4647").plot(ax=ax, color='red')
plt.title('Line type (distribution/generator/house)')
plt.show()

Comment thread src/dhnx/gistools/geometry_operations.py Fixed
p-snft added 2 commits July 13, 2026 12:29
It allows construction of a "reverse" house connection lines,
and was provided in
#166 (comment)
There were two problems when inverting "_all_values_equal":
First, the logic in _remove_useless_forks was notinverted.
This is now more transparent without continue statement.
Second, series of all NaN were not merged because NaN != NaN.
@github-actions

github-actions Bot commented Jul 13, 2026

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  src/dhnx/gistools
  connect_points.py
  geometry_operations.py 566
  src/dhnx/optimization
  add_components.py 464-469, 532-537
  dhs_nodes.py 158-170
  optimization_models.py 213-223, 866
Project Total  

This report was generated by python-coverage-comment-action

@p-snft

p-snft commented Jul 13, 2026

Copy link
Copy Markdown
Member

Turns out, what I considered to be refactoring actually broke _remove_useless_forks. Now, with the tests active, I directly saw that. (I know, I could have run the tests locally, but as the simulation ones failed anyway, I skipped that. Too bad.)

@jnettels: This should be done, now. Just one thing might still need discussion:

Remove geometry_operations.drop_parallel_lines(), as it is superseded by simplify()

This is true, but simplification is not mandatory. However, the old functionality was also broken (in the sense of the original simplify()) as it just drops the shortest connection no matter what attributes the edges have. thus, I decided to better fail than having unexpected results.

This is not needed anymore because simplify() already includes this step:
It uses a networkx Graph, which ony allows one edge between two nodes.
@jnettels

jnettels commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Awesome! Thanks for getting all the tests running!


I decided to better fail than having unexpected results

Agreed


I hope I do not mess up this branch by also pushing to it, but I will risk it:

  1. For 6f6643a it was not my intention to completely remove the connection checks for the buildings in check_input(). Since it seemed like this branch was done from your side, I took the liberty to commit a change that restores that check, while now being compatible with reverse house connection lines.
  2. There was a geopandas deprecation warning that deserved a fix
  3. I found another configuration of our test case that produces an error in your code. It is this new detour I drew:
image

In 236505f I added my updated test case as a new test for process_geometry(), which is now (intentionally) failing.

src/dhnx/gistools/geometry_operations.py:701: in _remove_useless_forks
      nx.set_edge_attributes(graph, edge_attrs)
E   ValueError: too many values to unpack (expected 2)

Maybe this is the solution:
nx.set_edge_attributes(graph, {neighbors: edge_attrs})

Once we get an expected result, we need to comment out
tn_input["pipes"].to_file(file_pipes)
in the new test function and commit that file. If we ever need to update the test case, we can reuse that function to generate the new expected result. I hope that makes sense.


How do you feel about renaming the process_geometry() argument welding to simplify? It now does more than just merge line segments together, so the meaning has changed. And it just sounds better, I think.

@jnettels

Copy link
Copy Markdown
Contributor Author
diff --git a/src/dhnx/gistools/geometry_operations.py b/src/dhnx/gistools/geometry_operations.py
index bf89b66..7e0567d 100644
--- a/src/dhnx/gistools/geometry_operations.py
+++ b/src/dhnx/gistools/geometry_operations.py
@@ -698,7 +698,7 @@ def _remove_useless_forks(
                             # direct edge already exists but is longer, modify
                             edge_attrs["length"] = edge_length
                             edge_attrs["path"] = path
-                            nx.set_edge_attributes(graph, edge_attrs)
+                            nx.set_edge_attributes(graph, {neighbors: edge_attrs})
 
                     graph.remove_node(node)
                     graph_was_updated = True

Indeed this seems to be the only change required to fix the error and get an expected output.
But now I do not know if you are working on other changes yourself. I will wait an hour and then push my solution.

@jnettels

jnettels commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Hmm... the actual error in _remove_useless_forks() is fixed.

But my tests fail when comparing with the expected pipes.geojson. Locally I can run tox and everything passes.
I have noticed that the column order seems to change with each file save tn_input["pipes"].to_file(file_pipes). But I thought I fixed that by fitting the column order before comparing...

@p-snft

p-snft commented Jul 15, 2026

Copy link
Copy Markdown
Member

Are you aware that assert NaN == NaN will fail?

@jnettels

jnettels commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Good point. But equals() does work with NaN. Only eq() could not be used for such a test.

In [8]: df1 = pd.DataFrame({"a": [1, 2, float('nan')]})

In [9]: df1.equals(df1)
Out[9]: True

In [10]: df1.eq(df1).all().all()
Out[10]: np.False_

The failing columns were length and geometry... so it was just about losing precision when saving to file. I still do not get why it worked locally.
Anyway, I am done with my part now!


The only remaining question from me would be:
How do you feel about renaming the process_geometry() argument welding to simplify? It now does more than just merge line segments together, so the meaning has changed. And it just sounds better, I think.
You may tell me to rename it, or update and merge yourself.

@p-snft

p-snft commented Jul 15, 2026

Copy link
Copy Markdown
Member

Only doctest link check fails because of traffic limitations. I will merge.

@p-snft p-snft closed this Jul 15, 2026
@p-snft
p-snft merged commit d781eac into dev Jul 15, 2026
8 of 9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants