Skip to content

Added terrain refinement option in tagging #1633

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 30 commits into
base: main
Choose a base branch
from

Conversation

ews-ffarella
Copy link

@ews-ffarella ews-ffarella commented Jun 2, 2025

Summary

This PR introduces polygon-based terrain refinement for AMR tagging, enabling users to specify arbitrary 2D polygonal regions (with optional holes) for mesh refinement above terrain. The new feature at least compiles on GPU (thanks @WeiqunZhang) and supports multiple refinement levels and flexible region definitions via the input file.

  • Polygonal Terrain Refinement
    The new Polygon class allows users to define arbitrary 2D polygons (with holes) for refinement regions.

    • Input syntax:
      • poly_exterior for the outer ring
      • poly_num_holes and poly_hole_N for holes
    • Example:
      tagging.myrefine.poly_exterior = 0 0  0 10  10 10  10 0  0 0
      tagging.myrefine.poly_num_holes = 1
      tagging.myrefine.poly_hole_0 = 2 2  2 8  8 8  8 2  2 2
      
  • Flexible Tagging
    The new TerrainRefinement class supports:

    • Polygon-based region selection (with or without holes)
    • Optional bounding box restriction (box_lo, box_hi)
    • User-defined refinement level
    • User defined vertical distance above the terrain

Of course, using Geos would be a lot better, but this would be an overkill!

tagging.labels = terrain_lvl1 terrain_lvl2 terrain_lvl2_whole_terrain terrain_lvl3

tagging.terrain_lvl1.type = TerrainRefinement
tagging.terrain_lvl1.vertical_distance = 200
tagging.terrain_lvl1.level = 1
tagging.terrain_lvl1.poly_exterior = 898 461 892 422 883 384 870 ...
tagging.terrain_lvl1.poly_num_holes = 1
tagging.terrain_lvl1.poly_hole_0 = 699 520 696 539 691 558 ...

tagging.terrain_lvl2.type = TerrainRefinement
tagging.terrain_lvl2.vertical_distance = 100
tagging.terrain_lvl2.level = 2
tagging.terrain_lvl2.poly_exterior = 700 500 699 480 696 461 691 ...

# This will apply to the whole terrain
tagging.terrain_lvl2_whole_terrain.type = TerrainRefinement
tagging.terrain_lvl2_whole_terrain.vertical_distance = 50
tagging.terrain_lvl2_whole_terrain.level = 2

tagging.terrain_lvl3.type = TerrainRefinement
tagging.terrain_lvl3.vertical_distance = 10
tagging.terrain_lvl3.level = 3
tagging.terrain_lvl3.poly_exterior = 699 520 696 539 691 ..

See example picture:

The black area is the terrain mask

image

The advantages of this method are:

  • Very complex geometries can be refined
    • Areas with large elevation gradients
    • Forest areas
  • It works even in the flat area for which the terrain black is 0
  • No need to think too hard about geometry
  • The user does not need to analyze the terrain in a preprocessing stage to get the right box_lo, box_hi, etc as this is the case with GeometryRefinement.

Future idea(s)

  • Remove the reliance on terrain_height. In flat terrain, but with forest, one could just use the z-coordinate as a proxy.
  • Maybe actually use the Polygon class to allow for easier forest input (defining forest using vector data is a common workflow i think ...)

Pull request type

Please check the type of change introduced:

  • Bugfix
  • Feature
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no api changes)
  • Build related changes
  • Documentation content changes
  • Other (please describe):

Checklist

The following is included:

  • new unit-test(s): see unit_tests/utilities/test_polygon.cpp
  • new regression test(s): see test/test_files/terrain_refinement_polygon_amr/terrain_refinement_polygon_amr.inp
  • documentation for new capability: see class documentation in Polygon.H and TerrainRefinement.H

This PR was tested by running:

  • the unit tests
    • on GPU
    • on CPU
  • the regression tests
    • on GPU
    • on CPU

See CI summary on my repo.

Additional background

Often, one has a collection of interesting objects (measurements, turbines, planning area) for which high resolution is needed.

Refining these using geometric entities like boxes and cylinders is very messy and inaccurate (requires a lot of pre-processing effort to be sure not to refine crazy large area). This PR aims at providing an easy and flexible way of refining the domain.

Example levels:

  • Levell 1: whole terrain up to 500m
  • Level 2: buffer 6 km around all the turbines, up to 300m
  • Level 3: buffer 2km, up to 150m
  • ...

@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 2, 2025

With a polygon as a boundary, one can have rather good control!

tagging.terrain_lvl2.type = TerrainRefinement
tagging.terrain_lvl2.vertical_distance = 40
tagging.terrain_lvl2.level = 2
tagging.terrain_lvl2.poly_outer= 4 -500 -500  -500 500  500 500 500 -500

image

Missing

  • Check for polygon orientation
  • Define inner rings (might be an over-kill)

@ews-ffarella
Copy link
Author

The implementation is ugly, and my C++ skills are very rusty. One can do a lot better (polygon class, DRY principle), but here is a complete implementation with inner rings.

image

@ews-ffarella ews-ffarella marked this pull request as ready for review June 2, 2025 15:34
@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 2, 2025

Note to maintainers

I know this is very rough and not according to your standards. I haven't programmed in C++ in 15 years 🫨.
I think it would actually take a couple of hours to implement a nice polygon class for someone familiar with the code.
But I really think that this feature would make settings up simulations with terrain so much easier!

It also solves some annoying problems referenced in #1629 (like not being able to capture a flat surface (no blank)). For the user, it is also very intuitive.

@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 2, 2025

Update with real example

grafik

Python code

import numpy as np
from shapely.geometry import Polygon, Point
import IPython.display


def get_amr_wind_polygon(geom: Polygon, precision: int = 0) -> tuple[str, list[str]]:
    assert isinstance(geom, Polygon)
    assert geom.geom_type == "Polygon"
    outer_coords = np.asarray(geom.exterior.coords)[:, :2].round(precision)
    if precision == 0:
        outer_coords = outer_coords.astype("int")
    poly_outer = " ".join(map(str, outer_coords.flatten()))
    poly_inners = []
    if len(geom.interiors):
        for ring_geom in geom.interiors:
            ring_coords = np.asarray(ring_geom.coords)[:, :2].round(precision)
            if precision == 0:
                ring_coords = ring_coords.astype("int")
            poly_inners.append(
                " ".join(map(str, ring_coords.flatten()))
            )
    return poly_outer, poly_inners


level = 1
geom = Point(500, 500).buffer(400)
if 1:
    geom = geom.difference(Point(500, 500).buffer(200))
IPython.display.display(geom)
poly_outer, poly_inners = get_amr_wind_polygon(geom)
print(f"tagging.terrain_lvl{level}.type = TerrainRefinement")
print(f"tagging.terrain_lvl{level}.vertical_distance = 100")
print(f"tagging.terrain_lvl{level}.level = {level}")
print(f"tagging.terrain_lvl{level}.poly_exterior = {poly_outer}")
if poly_inners:
    print(f"tagging.terrain_lvl{level}.poly_num_holes = {len(poly_inners)}")
    for i, l in enumerate(poly_inners):
        print(f"tagging.terrain_lvl{level}.poly_hole_{i} = {l}")

level = 2
geom = Point(500, 500).buffer(200)
IPython.display.display(geom)

poly_outer, poly_inners = get_amr_wind_polygon(geom)
print(f"tagging.terrain_lvl{level}.type = TerrainRefinement")
print(f"tagging.terrain_lvl{level}.vertical_distance = 40")
print(f"tagging.terrain_lvl{level}.level = {level}")
print(f"tagging.terrain_lvl{level}.poly_exterior = {poly_outer}")
if poly_inners:
    print(f"tagging.terrain_lvl{level}.poly_num_holes = {len(poly_inners)}")
    for i, l in enumerate(poly_inners):
        print(f"tagging.terrain_lvl{level}.poly_hole_{i} = {l}")

@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 2, 2025

Basically, one needs to assert after mesh creation the refinement level at the location of some points. I am so sorry that I cannot do that. This is probably trivial...

Copy link
Author

@ews-ffarella ews-ffarella Jun 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the same file as used for the terrain drag test (taken from test/test_files/terrain_box.).

@hgopalan
Copy link
Contributor

hgopalan commented Jun 2, 2025

Basically, one needs to assert after mesh creation the refinement leval at the location of some points. I am so sorry that I cannot do that. This is probaly trivial...

Can you provide more details on why is this assertion necessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this is ugly as ***. This was a quick proof of concept, taken from internet. I had used this mehod before in Openfoam.

As we probably won't have super complex polygons, I think this is fast enough ...

AMReX probably already has a class for this, but I haven't found it.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically, this should just generate a mesh. I have cranked the grid_eff to 1 for testing purpose. But I have to say that for my test case in complex terrain (see #1630), I kinda feel that this is needed. Also taken from test/test_files/terrain_box.

@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 2, 2025

Can you provide more details on why is this assertion necessary?

To create a unit test I mean. The generated mesh should have 2 refinement levels. I don't know how to find the nearest cell of a point in a mesh, test for the maximum refinement level, or actually write unit testing in cpp. This is why I said that for a maintainer, this is probably rather trivial.
But I can give the coordinates of some test points, and the expected refinement level.

@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 2, 2025

It fails on GPU. I would like to change this to draft, as I don't want to trigger costly CI.
But TBH, I don't think I will have the skills to have it run on GPU. I actually never used lambdas in C++.
Sorry about that.

@ews-ffarella ews-ffarella marked this pull request as draft June 2, 2025 17:21
@WeiqunZhang
Copy link
Contributor

WeiqunZhang@545e71f

This is my attempt to fix the GPU compilation. Note that I have not tested it and it may not even compile. But it gives you the idea of how to port it to GPU. Please feel free to use it in whatever way you prefer.

@ews-ffarella
Copy link
Author

WeiqunZhang@545e71f

This is my attempt to fix the GPU compilation. Note that I have not tested it and it may not even compile. But it gives you the idea of how to port it to GPU. Please feel free to use it in whatever way you prefer.

Thank you so much! I have to admit, storing a list of lists for the inner rings was very stupid.

@ews-ffarella
Copy link
Author

ews-ffarella commented Jun 3, 2025

Polygon tests

I had CoPilot generate the test-suite in python, to better see the process.
If you don't mind, I add this for reference

# Polygon test visualization and logic using matplotlib and shapely

import matplotlib.pyplot as plt
from shapely.geometry import Polygon, Point, LinearRing
from shapely.errors import TopologicalError

def bounding_box_contains(poly, pt):
    minx, miny, maxx, maxy = poly.bounds
    x, y = pt
    return (minx <= x <= maxx) and (miny <= y <= maxy)

def plot_polygon(ax, poly, holes=None, color="lightblue", edgecolor="blue", alpha=0.3):
    if holes is None:
        holes = []
    x, y = poly.exterior.xy
    ax.fill(x, y, color=color, alpha=alpha, edgecolor=edgecolor, linewidth=2)
    for hole in holes:
        hx, hy = hole.xy
        ax.fill(hx, hy, color="white", alpha=1, edgecolor="red", linewidth=2)


def plot_points(ax, points, results, labels=None):
    for i, (pt, res) in enumerate(zip(points, results)):
        color = "green" if res else "red"
        ax.plot(pt[0], pt[1], "o", color=color, markersize=10)
        if labels:
            ax.text(pt[0], pt[1], f"{labels[i]}", fontsize=9, ha="left")


def run_test(title, poly, test_points, expected, holes=None, show_bbox=False):
    fig, ax = plt.subplots()
    ax.set_title(title)
    plot_polygon(ax, poly, holes)
    results = [poly.contains(Point(p)) for p in test_points]
    plot_points(ax, test_points, results, labels=[str(i) for i in range(len(test_points))])
    for i, (pt, exp, res) in enumerate(zip(test_points, expected, results)):
        print(f"Test point {pt}: expected {exp}, got {res}")
        assert res == exp, f"Test failed for point {pt}: expected {exp}, got {res}"
    if show_bbox:
        minx, miny, maxx, maxy = poly.bounds
        ax.plot([minx, maxx, maxx, minx, minx], [miny, miny, maxy, maxy, miny], "k--", lw=2)
    ax.set_aspect("equal")
    plt.show()


# 1. SimpleSquare
poly1 = Polygon([(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)])
run_test("SimpleSquare", poly1, [(5, 5), (0, 5), (-1, 5)], [True, False, False])

# 2. PolygonWithHole
outer = [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)]
hole = [(3, 3), (3, 7), (7, 7), (7, 3), (3, 3)]
poly2 = Polygon(outer, [hole])
run_test(
    "PolygonWithHole",
    poly2,
    [(2, 2), (5, 5), (10, 5), (3, 5), (-1, 5)],
    [True, False, False, False, False],
    holes=[LinearRing(hole)],
)

# 3. ComplexPolygonSelfIntersecting (bowtie)
bowtie = [(0, 0), (5, 10), (10, 0), (0, 10), (10, 10), (0, 0)]
try:
    poly3 = Polygon(bowtie)
    # Shapely may throw TopologicalError for self-intersecting polygons
    run_test("ComplexPolygonSelfIntersecting", poly3, [(5, 5)], [False])
except TopologicalError as e:
    print("Self-intersecting polygon could not be constructed:", e)

# 4. DegenerateCases: single point and line
try:
    poly4 = Polygon([(1, 1)])
    run_test("DegenerateCases: Single Point", poly4, [(1, 1)], [False])
except ValueError:
    print("DegenerateCases: Single Point - Polygon not valid, contains always False")


try:
    poly5 = Polygon([(0, 0), (1, 1)])
    run_test("DegenerateCases: Line", poly5, [(0.5, 0.5)], [False])
except ValueError:
    print("DegenerateCases: Line - Polygon not valid, contains always False")

# 5. BoundingBox
poly6 = Polygon([(-2,3), (4,5), (1,-1), (-2,3)])
test_points = [(0,0), (10,10)]
expected = [
    bounding_box_contains(poly6, (0,0)),
    bounding_box_contains(poly6, (10,10))
]
def run_bbox_test(title, poly, test_points, expected):
    fig, ax = plt.subplots()
    ax.set_title(title)
    plot_polygon(ax, poly)
    results = [bounding_box_contains(poly, p) for p in test_points]
    plot_points(ax, test_points, results, labels=[str(i) for i in range(len(test_points))])
    for i, (pt, exp, res) in enumerate(zip(test_points, expected, results)):
        print(f"Test point {pt}: expected {exp}, got {res}")
        assert res == exp, f"Test failed for point {pt}: expected {exp}, got {res}"
    minx, miny, maxx, maxy = poly.bounds
    ax.plot([minx, maxx, maxx, minx, minx], [miny, miny, maxy, maxy, miny], 'k--', lw=2)
    ax.set_aspect('equal')
    plt.show()

run_bbox_test("BoundingBox", poly6, test_points, expected)

# 6. MultipleHoles
outer = [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)]
hole1 = [(2, 2), (2, 4), (4, 4), (4, 2), (2, 2)]
hole2 = [(6, 6), (6, 8), (8, 8), (8, 6), (6, 6)]
poly7 = Polygon(outer, [hole1, hole2])
run_test(
    "MultipleHoles",
    poly7,
    [(5, 5), (3, 3), (7, 7)],
    [True, False, False],
    holes=[LinearRing(hole1), LinearRing(hole2)],
)

image
image

@hgopalan
Copy link
Contributor

hgopalan commented Jun 3, 2025

Thank you for putting up the PR to close the feature request.

@ews-ffarella
Copy link
Author

Thank you for putting up the PR to close the feature request.

Thanks. I have to say that CoPilot helped me a lot to get back into CPP programming. The OpenFoam library makes it almost too easy because every possible container type and parsing is implemented!
I still wonder about the drawbacks of having grid_eff to 1 instead of 0.7. but for complex geometries, there are problems at the border of subgrids sometimes. The mesh is refined let s say 2 cells above the terrain, but not at the wall. Not sure if the is the case with your implementation with AMRterrain

@ews-ffarella
Copy link
Author

Bugs

  • Using the static method in ParallelFor negates the whole point of computing the bounding box for the polygon, maybe it can be passed in the lambda---

@ews-ffarella
Copy link
Author

Results on my project's domain

image
image

I just dont know how to remove these. Hopefully, the happen far away enough from my ROI.
I think it has to do the subgrids.

image

image

image

image

@mbkuhn
Copy link
Contributor

mbkuhn commented Jun 3, 2025

For those errors that you're seeing, what is the vertical distance specified for the levels that are having the problem? And what is the dz for those levels?


tagging.terrain_lvl1.type = TerrainRefinement
tagging.terrain_lvl1.vertical_distance = 200
tagging.terrain_lvl1.level = 1
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm noticing that the first level in the tagging is 1. This argument specifies which level the tagging should be applied to, not which level will be created by the tagging. It's confusing. But the tagging of level = 0 will determine where the cells of level = 1 show up. This might be affecting the other case that you are having challenges with.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I know it is confusing. In my mind, it is the final target grid level, not the tagging process. I actually wonder if I should round up the vertical distance by the current grid dz when tagging the cells. but somehow, I don't think this is what causes the problems. These cells are close tobthe terrain, and if you see the cells around, they are clearly refined all the way up.
Could it be the ngrow paremeter that I ignore?

Copy link
Author

@ews-ffarella ews-ffarella Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will post tomorrow my inp file for the complex case. The refinements are not the same as in the example.

Thank you so much for the assistance!

Would you like me to run a case with the standard grid efficiency of 70%, to show the differences?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have changed the implementation to be more inline with the rest of the code.
Level now referes to the grid level the grid refinement should apply to.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mbkuhn , with my updates, this disappeared.

Changes:

  • Use the same convention as in the rest of the code for level
  • Round up the vertical distance to the nearest multiple of the current grid size

I will try to get rid of the annoying mis-refinements on some edges, but i am afraid there is just no way with AMReX meshing. I am posting pictures and setup here:

@ews-ffarella
Copy link
Author

For those errors that you're seeing, what is the vertical distance specified for the levels that are having the problem? And what is the dz for those levels?

On top of my head, I recall running with 5 levels, the finest being 10m, and an aspect ratio of 4. I will need to check my terrain refinement level, but I think that the 2 big patches that fail are level 2. level 1 is 600m high, level 1 300m across the whole domain. 3 4 and 5 are just around the wind farm.

@ews-ffarella
Copy link
Author

I wonder if I should make sure that the rank actually contains the polygon, like it is done if the forest implementation.

At creation time, the polygon bbox is computed. It is only in the tagging process that the bbox is lost (someone with skills could probably easily fix that, and add a bbox check)

https://github.com/ews-ffarella/amr-wind/blob/main/amr-wind/physics/ForestDrag.cpp#L118

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.

4 participants