diff --git a/.gitignore b/.gitignore index 31a73b938a..a395748b55 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,11 @@ *.pyc -*~ -*.py~ -*.swp *.py# +*.msh +*.geo +*.exe +*.swp +*.py~ +*.un~ +*.pos +*.vtu +*.vrml diff --git a/src/porepy/fracs/__init__.py b/src/porepy/fracs/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/porepy/fracs/examples/__init__.py b/src/porepy/fracs/examples/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/porepy/fracs/examples/cart_grids.py b/src/porepy/fracs/examples/cart_grids.py new file mode 100644 index 0000000000..7ceabdfb48 --- /dev/null +++ b/src/porepy/fracs/examples/cart_grids.py @@ -0,0 +1,171 @@ +""" +Various method for creating grids for relatively simple fracture networks. + +The module doubles as a test framework (though not unittest), and will report +on any problems if ran as a main method. + +""" +import sys +import getopt +import numpy as np +import matplotlib.pyplot as plt +import time +import traceback +import logging +from inspect import isfunction, getmembers + +from gridding.fractured import meshing +from viz import plot_grid + + +def x_intersection_2d(**kwargs): + f_1 = np.array([[2, 6], [5, 5]]) + f_2 = np.array([[4, 4], [2, 7]]) + f = [f_1, f_2] + + gb = meshing.cart_grid(f, [10, 10], physdims=[10, 10]) + if kwargs.get('return_expected', False): + expected = [0, 1, 2, 1] + return gb, expected + else: + return gb + + +def T_intersection_2d(**kwargs): + f_1 = np.array([[2, 6], [5, 5]]) + f_2 = np.array([[4, 4], [2, 5]]) + f = [f_1, f_2] + + gb = meshing.cart_grid(f, [10, 10], physdims=[10, 10]) + if kwargs.get('return_expected', False): + expected = [0, 1, 2, 1] + return gb, expected + else: + return gb + + +def L_intersection_2d(**kwargs): + f_1 = np.array([[2, 6], [5, 5]]) + f_2 = np.array([[6, 6], [2, 5]]) + f = [f_1, f_2] + + gb = meshing.cart_grid(f, [10, 10], physdims=[10, 10]) + if kwargs.get('return_expected', False): + expected = [0, 1, 2, 1] + return gb, expected + else: + return gb + + +def x_intersection_3d(**kwargs): + f_1 = np.array([[2, 5, 5, 2], [2, 2, 5, 5], [5, 5, 5, 5]]) + f_2 = np.array([[2, 2, 5, 5], [5, 5, 5, 5], [2, 5, 5, 2]]) + f = [f_1, f_2] + gb = meshing.cart_grid(f, np.array([10, 10, 10])) + if kwargs.get('return_expected', False): + expected = [1, 2, 1, 0] + return gb, expected + else: + return gb + + +def several_intersections_3d(**kwargs): + f_1 = np.array([[2, 5, 5, 2], [2, 2, 5, 5], [5, 5, 5, 5]]) + f_2 = np.array([[2, 2, 5, 5], [5, 5, 5, 5], [2, 5, 5, 2]]) + f_3 = np.array([[4, 4, 4, 4], [1, 1, 8, 8], [1, 8, 8, 1]]) + f_4 = np.array([[3, 3, 6, 6], [3, 3, 3, 3], [3, 7, 7, 3]]) + f = [f_1, f_2, f_3, f_4] + gb = meshing.cart_grid(f, np.array([8, 8, 8])) + if kwargs.get('return_expected', False): + expected = [1, 4, 9, 2] + return gb, expected + else: + return gb + + +if __name__ == '__main__': + + # If invoked as main, run all tests + try: + opts, args = getopt.getopt(sys.argv[1:], 'v:', ['verbose=', + 'compute_geometry=']) + except getopt.GetoptError as err: + print(err) + sys.exit(2) + + gmsh_path = None + verbose = 0 + compute_geometry = True + # process options + for o, a in opts: + if o == '--gmsh_path': + gmsh_path = a + elif o in ('-v', '--verbose'): + verbose = int(a) + elif o == '--compute_geometry': + compute_geometry = True + + return_expected = 1 + success_counter = 0 + failure_counter = 0 + + time_tot = time.time() + functions_list = [o for o in getmembers( + sys.modules[__name__]) if isfunction(o[1])] + for f in functions_list: + func = f + if func[0] == 'isfunction': + continue + elif func[0] == 'getmembers': + continue + if verbose > 0: + print('Running ' + func[0]) + + time_loc = time.time() + try: + gb, expected = func[1](gmsh_path=gmsh_path, + verbose=verbose, + gmsh_verbose=0, + return_expected=True) + + # Check that the bucket has the expected number of grids in each + # dimension. + for dim, exp_l in zip(range(3, -1, -1), expected): + g_loc = gb.grids_of_dimension(dim) + assert len(g_loc) == exp_l + + except Exception as exp: + print('\n') + print(' ************** FAILURE **********') + print('Example ' + func[0] + ' failed') + print(exp) + logging.error(traceback.format_exc()) + failure_counter += 1 + continue + + if compute_geometry: + try: + for g, _ in gb: + if g.dim == 3: + g.compute_geometry() + else: + g.compute_geometry(is_embedded=True) + except Exception as exp: + print('************ FAILURE **********') + print('Geometry computation failed for grid ' + str(g)) + print(exp) + logging.error(traceback.format_exc()) + failure_counter += 1 + continue + + # If we have made it this far, this is a success + success_counter += 1 + ################################# + # Summary + # + print('\n') + print(' --- ') + print('Ran in total ' + str(success_counter + failure_counter) + ' tests,' + + ' out of which ' + str(failure_counter) + ' failed.') + print('Total elapsed time is ' + str(time.time() - time_tot) + ' seconds') + print('\n') diff --git a/src/porepy/fracs/examples/simple_networks.py b/src/porepy/fracs/examples/simple_networks.py new file mode 100644 index 0000000000..c39c616231 --- /dev/null +++ b/src/porepy/fracs/examples/simple_networks.py @@ -0,0 +1,291 @@ +""" +Various method for creating grids for relatively simple fracture networks. + +The module doubles as a test framework (though not unittest), and will report +on any problems if ran as a main method. + +""" +import sys +import getopt +import numpy as np +import matplotlib.pyplot as plt +import time +import traceback +import logging +from inspect import isfunction, getmembers + +from gridding.fractured import meshing +from viz import plot_grid + + +def single_isolated_fracture(**kwargs): + """ + A single fracture completely immersed in a boundary grid. + """ + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_1], domain, **kwargs) + + if kwargs.get('return_expected', False): + expected = [1, 1, 0, 0] + return grids, expected + else: + return grids + + +def two_intersecting_fractures(**kwargs): + """ + Two fractures intersecting along a line. + + The example also sets different characteristic mesh lengths on the boundary + and at the fractures. + + """ + + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + f_2 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1], [-.7, -.7, .8, .8]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + + mesh_size = {'mode': 'constant', 'value': 0.5, 'bound_value': 1} + kwargs['mesh_size'] = mesh_size + + grids = meshing.simplex_grid([f_1, f_2], domain, **kwargs) + + if kwargs.get('return_expected', False): + return grids, [1, 2, 1, 0] + else: + return grids + + +def three_intersecting_fractures(**kwargs): + """ + Three fractures intersecting, with intersecting intersections (point) + """ + + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + f_2 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1], [-.7, -.7, .8, .8]]) + f_3 = np.array([[-1, 1, 1, -1], [-1, -1, 1, 1], [0, 0, 0, 0]]) + + # Add some parameters for grid size + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + mesh_size = {'mode': 'constant', 'value': 0.5, 'bound_value': 1} + + kwargs['mesh_size'] = mesh_size + grids = meshing.simplex_grid([f_1, f_2, f_3], domain, **kwargs) + + if kwargs.get('return_expected', False): + return grids, [1, 3, 6, 1] + else: + return grids + + +def one_fracture_intersected_by_two(**kwargs): + """ + One fracture, intersected by two other (but no point intersections) + """ + + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + f_2 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1], [-.7, -.7, .8, .8]]) + f_3 = f_2 + np.array([0.5, 0, 0]).reshape((-1, 1)) + + # Add some parameters for grid size + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + mesh_size = {'mode': 'constant', 'value': 0.5, 'bound_value': 1} + + kwargs['mesh_size'] = mesh_size + grids = meshing.simplex_grid([f_1, f_2, f_3], domain, **kwargs) + + if kwargs.get('return_expected', False): + return grids, [1, 3, 2, 0] + else: + return grids + + +def split_into_octants(**kwargs): + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + f_2 = np.array([[-1, -1, 1, 1], [-1, 1, 1, -1], [0, 0, 0, 0]]) + f_3 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1], [-1, -1, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_1, f_2, f_3], domain, **kwargs) + + if kwargs.get('return_expected', False): + return grids, [1, 3, 6, 1] + else: + return grids + return grids + + +def three_fractures_sharing_line_same_segment(**kwargs): + """ + Three fractures that all share an intersection line. This can be considered + as three intersection lines that coincide. + """ + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + f_2 = np.array([[-1, 1, 1, -1], [-1, 1, 1, -1], [-1, -1, 1, 1]]) + f_3 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1], [-1, -1, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_1, f_2, f_3], domain, **kwargs) + + if kwargs.get('return_expected', False): + return grids, [1, 3, 1, 0] + else: + return grids + + +def three_fractures_split_segments(**kwargs): + """ + Three fractures that all intersect along the same line, but with the + intersection between two of them forming an extension of the intersection + of all three. + """ + f_1 = np.array([[-1, 1, 1, -1], [0, 0, 0, 0], [-1, -1, 1, 1]]) + f_2 = np.array([[-1, 1, 1, -1], [-1, 1, 1, -1], [-.5, -.5, .5, .5]]) + f_3 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1], [-1, -1, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_1, f_2, f_3], domain, **kwargs) + if kwargs.get('return_expected', False): + return grids, [1, 3, 3, 2] + else: + return grids + + +def two_fractures_L_intersection(**kwargs): + """ + Two fractures sharing a segment in an L-intersection. + """ + f_1 = np.array([[0, 1, 1, 0], [0, 0, 1, 1], [0, 0, 0, 0]]) + f_2 = np.array([[0, 0, 0, 0], [0, 1, 1, 0], [0, 0, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_1, f_2], domain, **kwargs) + if kwargs.get('return_expected', False): + return grids, [1, 2, 1, 0] + else: + return grids + + +def two_fractures_L_intersection_part_of_segment(**kwargs): + """ + Two fractures sharing what is a full segment for one, an part of a segment + for the other. + """ + f_1 = np.array([[0, 1, 1, 0], [0, 0, 1, 1], [0, 0, 0, 0]]) + f_2 = np.array([[0, 0, 0, 0], [0.3, 0.7, 0.7, 0.3], [0, 0, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_1, f_2], domain, **kwargs) + if kwargs.get('return_expected', False): + return grids, [1, 2, 1, 0] + else: + return grids + + +def two_fractures_L_intersection_one_displaced(**kwargs): + """ + Two fractures sharing what is a part of segments for both. + """ + f_1 = np.array([[0, 1, 1, 0], [0, 0, 1, 1], [0, 0, 0, 0]]) + f_2 = np.array([[0, 0, 0, 0], [0.5, 1.5, 1.5, 0.5], [0, 0, 1, 1]]) + domain = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': + 2} + grids = meshing.simplex_grid([f_2, f_1], domain, **kwargs) + if kwargs.get('return_expected', False): + return grids, [1, 2, 1, 0] + else: + return grids + + +if __name__ == '__main__': + + # If invoked as main, run all tests + try: + opts, args = getopt.getopt(sys.argv[1:], 'v:', ['gmsh_path=', + 'verbose=', + 'compute_geometry=']) + except getopt.GetoptError as err: + print(err) + sys.exit(2) + + gmsh_path = None + verbose = 0 + compute_geometry = True + # process options + for o, a in opts: + if o == '--gmsh_path': + gmsh_path = a + elif o in ('-v', '--verbose'): + verbose = int(a) + elif o == '--compute_geometry': + compute_geometry = True + + return_expected = 1 + success_counter = 0 + failure_counter = 0 + + time_tot = time.time() + functions_list = [o for o in getmembers( + sys.modules[__name__]) if isfunction(o[1])] + for f in functions_list: + func = f + if func[0] == 'isfunction': + continue + elif func[0] == 'getmembers': + continue + if verbose > 0: + print('Running ' + func[0]) + + time_loc = time.time() + try: + gb, expected = func[1](gmsh_path=gmsh_path, + verbose=verbose, + gmsh_verbose=0, + return_expected=True) + + # Check that the bucket has the expected number of grids in each + # dimension. + for dim, exp_l in zip(range(3, -1, -1), expected): + g_loc = gb.grids_of_dimension(dim) + assert len(g_loc) == exp_l + + except Exception as exp: + print('\n') + print(' ************** FAILURE **********') + print('Example ' + func[0] + ' failed') + print(exp) + logging.error(traceback.format_exc()) + failure_counter += 1 + continue + + if compute_geometry: + try: + for g, _ in gb: + if g.dim == 3: + g.compute_geometry() + else: + g.compute_geometry(is_embedded=True) + except Exception as exp: + print('************ FAILURE **********') + print('Geometry computation failed for grid ' + str(g)) + print(exp) + logging.error(traceback.format_exc()) + failure_counter += 1 + continue + + # If we have made it this far, this is a success + success_counter += 1 + ################################# + # Summary + # + print('\n') + print(' --- ') + print('Ran in total ' + str(success_counter + failure_counter) + ' tests,' + + ' out of which ' + str(failure_counter) + ' failed.') + print('Total elapsed time is ' + str(time.time() - time_tot) + ' seconds') + print('\n') diff --git a/src/porepy/fracs/examples/soultz_grid.py b/src/porepy/fracs/examples/soultz_grid.py new file mode 100644 index 0000000000..1be7506126 --- /dev/null +++ b/src/porepy/fracs/examples/soultz_grid.py @@ -0,0 +1,233 @@ +""" +Create a grid based on the Soultz data set. +""" +import numpy as np + +from gridding.fractured.fractures import Fracture, EllipticFracture, FractureNetwork +from gridding.fractured import meshing, simplex, split_grid + + +def create_grid(gmsh_path): + """ + Create a grid bucket containing grids from gmsh. + + NOTE: The line setting 'path_to_gmsh' *must* be modified for this to work. + + Parameters concerning mesh size, domain size etc. may also be changed, see + below. + + Parameters: + gmsh_path (str): Path to a gmsh executable. + + Returns: + grid_bucket: A grid_bucket containing the full hierarchy of grids. + + """ + num_fracs = 39 + + # If the + # Don't change the path, or move the file + data = _soultz_data() + + # Data format of the data file is (frac_num, fracture center_xyz, major + # axis, minor axis, dip direction, dip angle) + centers = data[:, 1:4] + major_axis = data[:, 4] + minor_axis = data[:, 5] + + dip_direction = data[:, 6] / 180 * np.pi + dip_angle = data[:, 7] / 180 * np.pi + + # We will define the fractures as elliptic fractures. For this we need + # strike angle, rather than dip direction. + strike_angle = dip_direction + np.pi/2 + + # Modifications of the fracture definition: + # These are carried out to ease the gridding; without these, we will end up + # with gridding polygons that have very close points. The result may be + + # Minor axis angle. This is specified as zero (the fractures are + # interpreted as circles), but we rotate them in an attempt to avoid close + # points in the fracture specification. + # Also note that since the fractures are defined as circles, any + # orientation of the approximating polygon is equally correct + major_axis_angle = np.zeros(num_fracs) + major_axis_angle[14] = 5 * np.pi / 180 + major_axis_angle[26] = 5 * np.pi / 180 + major_axis_angle[24] = 5 * np.pi / 180 + major_axis_angle[32] = 5 * np.pi / 180 + + # Also modify some centers. This may potentially have some impact on the + # properties of the fracture network, but they been selected as to not + # modify the fracture network properties. + centers[24, 2] += 30 + centers[8, 2] -= 10 + centers[19, 2] -= 20 + centers[3, 2] += 30 + + # Create a set of fractures + frac_list = [] + for fi in range(data.shape[0]): + frac_new = EllipticFracture(centers[fi], major_axis[fi], + minor_axis[fi], major_axis_angle[fi], + strike_angle[fi], dip_angle[fi], + num_points=16) + frac_list.append(frac_new) + + # Create the network, dump to vtu + network = FractureNetwork(frac_list, verbose=1, tol=1e-4) + network.to_vtk('soultz_fractures_full.vtu') + + # Impose domain boundaries. These are set large enough to not be in + # conflict with the network. + # This may be changed if desirable. + domain = {'xmin':-4000, 'xmax': 4000, 'ymin':-3000, 'ymax': 3000, 'zmin':0, + 'zmax':8000} + network.impose_external_boundary(domain) + + # Find intersections, and split these + network.find_intersections() + network.split_intersections() + + # This may be changed, if desirable. + mesh_size = {'mode':'constant', 'value': 150, 'bound_value':500} + opts = {'mesh_size': mesh_size, 'gmsh_path': gmsh_path, 'file_name': + 'soultz_fracs'} + # Since we have a ready network (and may want to take this file into + # jupyter and study the network before gridding), we abuse the workflow + # slightly by calling simplex_tetrahedral directly, rather than to go the + # way through meshing.simplex_grid (the latter is, for now, restricted to + # specifying the grid by individual fractures, rather than networks). + grids = simplex.tetrahedral_grid(network=network, **opts) + + # Convert the grids into a bucket + meshing.tag_faces(grids) + gb = meshing.assemble_in_bucket(grids) + gb.compute_geometry() + + split_grid.split_fractures(gb) + return gb + + +def _soultz_data(): + """ + Hard coded data that describes the fracture network. + """ + data = np.array([[ 1.00000000e+00, -4.26646154e+02, 1.52215385e+02, + 1.01200000e+03, 3.00000000e+02, 3.00000000e+02, + 1.30000000e+02, 7.90000000e+01], + [ 2.00000000e+00, -4.22353846e+02, 1.50784615e+02, + 1.19800000e+03, 6.00000000e+02, 6.00000000e+02, + 2.47000000e+02, 7.40000000e+01], + [ 3.00000000e+00, -3.81022727e+02, 1.77284091e+02, + 1.64300000e+03, 4.00000000e+02, 4.00000000e+02, + 7.60000000e+01, 5.80000000e+01], + [ 4.00000000e+00, -3.20113636e+02, 2.19920455e+02, + 2.17900000e+03, 6.00000000e+02, 6.00000000e+02, + 2.78000000e+02, 5.30000000e+01], + [ 5.00000000e+00, -4.35000000e+01, 1.74000000e+01, + 1.01500000e+03, 3.00000000e+02, 3.00000000e+02, + 2.70000000e+02, 4.50000000e+01], + [ 6.00000000e+00, -5.22857143e+01, 2.09142857e+01, + 1.22000000e+03, 6.00000000e+02, 6.00000000e+02, + 2.47000000e+02, 7.40000000e+01], + [ 7.00000000e+00, -7.80000000e+01, 3.12000000e+01, + 1.82000000e+03, 6.00000000e+02, 6.00000000e+02, + 2.70000000e+01, 4.70000000e+01], + [ 8.00000000e+00, -1.20562500e+02, 4.81312500e+01, + 2.81500000e+03, 4.00000000e+02, 4.00000000e+02, + 2.30000000e+02, 7.00000000e+01], + [ 9.00000000e+00, -1.35862500e+02, 5.17012500e+01, + 3.22300000e+03, 3.00000000e+02, 3.00000000e+02, + 6.00000000e+01, 7.50000000e+01], + [ 1.00000000e+01, -1.45950000e+02, 5.40550000e+01, + 3.49200000e+03, 3.00000000e+02, 3.00000000e+02, + 2.57000000e+02, 6.30000000e+01], + [ 1.10000000e+01, -1.22550000e+02, 4.85950000e+01, + 2.86800000e+03, 3.00000000e+02, 3.00000000e+02, + 2.90000000e+02, 7.00000000e+01], + [ 1.20000000e+01, -4.96440625e+02, 1.03075000e+02, + 2.12300000e+03, 6.00000000e+02, 6.00000000e+02, + 6.50000000e+01, 7.00000000e+01], + [ 1.30000000e+01, -5.20600000e+02, 1.31800000e+02, + 3.24200000e+03, 3.00000000e+02, 3.00000000e+02, + 8.20000000e+01, 6.90000000e+01], + [ 1.40000000e+01, -5.22100000e+02, 1.36300000e+02, + 3.34700000e+03, 3.00000000e+02, 3.00000000e+02, + 2.31000000e+02, 8.40000000e+01], + [ 1.50000000e+01, -5.24485714e+02, 1.43457143e+02, + 3.51400000e+03, 3.00000000e+02, 3.00000000e+02, + 3.13000000e+02, 5.70000000e+01], + [ 1.60000000e+01, -5.30000000e+02, 1.60000000e+02, + 3.90000000e+03, 4.00000000e+02, 4.00000000e+02, + 2.34000000e+02, 6.40000000e+01], + [ 1.70000000e+01, -3.76521739e+02, -4.26086957e+01, + 4.76000000e+03, 4.00000000e+02, 4.00000000e+02, + 2.50000000e+02, 6.50000000e+01], + [ 1.80000000e+01, -3.52028985e+02, -7.18115942e+01, + 4.89000000e+03, 3.00000000e+02, 3.00000000e+02, + 2.50000000e+02, 6.50000000e+01], + [ 1.90000000e+01, -3.20000000e+02, -1.10000000e+02, + 5.06000000e+03, 4.00000000e+02, 4.00000000e+02, + 2.50000000e+02, 6.50000000e+01], + [ 2.00000000e+01, -5.26853741e+02, 4.29659864e+01, + 1.57900000e+03, 3.00000000e+02, 3.00000000e+02, + 6.90000000e+01, 7.80000000e+01], + [ 2.10000000e+01, -5.27840136e+02, 4.45442177e+01, + 1.63700000e+03, 3.00000000e+02, 3.00000000e+02, + 4.60000000e+01, 6.80000000e+01], + [ 2.20000000e+01, -5.30952381e+02, 4.95238095e+01, + 1.82000000e+03, 3.00000000e+02, 3.00000000e+02, + 4.60000000e+01, 6.40000000e+01], + [ 2.30000000e+01, -5.34727891e+02, 5.55646258e+01, + 2.04200000e+03, 3.00000000e+02, 3.00000000e+02, + 7.20000000e+01, 6.50000000e+01], + [ 2.40000000e+01, -5.34795918e+02, 5.56734694e+01, + 2.04600000e+03, 3.00000000e+02, 3.00000000e+02, + 2.43000000e+02, 6.90000000e+01], + [ 2.50000000e+01, -5.35578231e+02, 5.69251701e+01, + 2.09200000e+03, 3.00000000e+02, 3.00000000e+02, + 9.10000000e+01, 7.60000000e+01], + [ 2.60000000e+01, -5.58720930e+02, 8.08023256e+01, + 2.97000000e+03, 4.00000000e+02, 4.00000000e+02, + 7.70000000e+01, 8.20000000e+01], + [ 2.70000000e+01, -6.46220930e+02, 8.88523256e+01, + 3.27100000e+03, 4.00000000e+02, 4.00000000e+02, + 3.45000000e+02, 8.50000000e+01], + [ 2.80000000e+01, -8.99343750e+02, 1.12031250e+02, + 4.08900000e+03, 3.00000000e+02, 3.00000000e+02, + 2.53000000e+02, 6.20000000e+01], + [ 2.90000000e+01, -9.41176471e+02, 1.14323529e+02, + 4.77500000e+03, 3.00000000e+03, 3.00000000e+03, + 2.34000000e+02, 7.10000000e+01], + [ 3.00000000e+01, -5.00000000e+02, 6.08117647e+01, + 1.72300000e+03, 3.00000000e+02, 3.00000000e+02, + 2.16000000e+02, 6.90000000e+01], + [ 3.10000000e+01, -5.00000000e+02, 6.35647059e+01, + 1.80100000e+03, 3.00000000e+02, 3.00000000e+02, + 2.60000000e+01, 8.00000000e+01], + [ 3.20000000e+01, -6.44324324e+02, 1.11648649e+02, + 2.81700000e+03, 3.00000000e+02, 3.00000000e+02, + 2.42000000e+02, 8.60000000e+01], + [ 3.30000000e+01, -1.25135135e+03, 2.02702703e+02, + 3.94000000e+03, 3.00000000e+02, 3.00000000e+02, + 2.50000000e+02, 6.80000000e+01], + [ 3.40000000e+01, -1.47891892e+03, 2.36837838e+02, + 4.36100000e+03, 4.00000000e+02, 4.00000000e+02, + 2.80000000e+02, 7.70000000e+01], + [ 3.50000000e+01, -1.54400000e+03, 2.58857143e+02, + 4.62000000e+03, 6.00000000e+02, 1.20000000e+03, + 2.85000000e+02, 7.80000000e+01], + [ 3.60000000e+01, -1.56240000e+03, 2.66742857e+02, + 4.71200000e+03, 4.00000000e+02, 4.00000000e+02, + 2.12000000e+02, 5.00000000e+01], + [ 3.70000000e+01, -1.61460000e+03, 2.89114286e+02, + 4.97300000e+03, 3.00000000e+02, 3.00000000e+02, + 2.76000000e+02, 8.10000000e+01], + [ 3.80000000e+01, -1.62240000e+03, 2.92457143e+02, + 5.01200000e+03, 3.00000000e+02, 3.00000000e+02, + 2.57000000e+02, 8.50000000e+01], + [ 3.90000000e+01, -1.64000000e+03, 3.00000000e+02, + 5.10000000e+03, 3.00000000e+02, 3.00000000e+02, + 2.55000000e+02, 6.90000000e+01]]) + return data diff --git a/src/porepy/fracs/frac_viz_utils.py b/src/porepy/fracs/frac_viz_utils.py new file mode 100644 index 0000000000..88811f8cdb --- /dev/null +++ b/src/porepy/fracs/frac_viz_utils.py @@ -0,0 +1,50 @@ +# Various utility functions for gridding + +import numpy as np +import matplotlib.pyplot as plt + +def plot_fractures(d, p, c, colortag=None): + """ + Plot fractures as lines in a domain + + d: domain size in the form of a dictionary + p - points + c - connection between fractures + colortag - indicate that fractures should have different colors + """ + + # Assign a color to each tag. We define these by RBG-values (simplest option in pyplot). + # For the moment, some RBG values are hard coded, do something more intelligent if necessary. + if colortag is None: + tagmap = np.zeros(c.shape[1], dtype='int') + col = [(0, 0, 0)]; + else: + utag, tagmap = np.unique(colortag, return_inverse=True) + ntag = utag.size + if ntag <= 3: + col = [(1, 0, 0), (0, 1, 0), (0, 0, 1)] + elif ntag < 6: + col = [(1, 0, 0), (0, 1, 0), (0, 0, 1), + (1, 1, 0), (1, 0, 1), (0, 0, 1)] + else: + raise NotImplementedError('Have not thought of more than six colors') + + + plt.figure() + # Simple for-loop to draw one fracture after another. Not fancy, but it serves its purpose. + for i in range(c.shape[1]): + plt.plot([p[0, c[0, i]], p[0, c[1, i]]], [p[1, c[0, i]], p[1, c[1, i]]], 'o-',color=col[tagmap[i]]) + plt.axis([d['xmin'], d['xmax'], d['ymin'], d['ymax']]) + plt.show() + +def remove_nodes(g, rem): + """ + Remove nodes from grid. + g - a valid grid definition + rem - a ndarray of indecies of nodes to be removed + """ + all_rows = np.arange(g.face_nodes.shape[0]) + rows_to_keep = np.where(np.logical_not(np.in1d(all_rows, rem)))[0] + g.face_nodes = g.face_nodes[rows_to_keep,:] + g.nodes = g.nodes[:,rows_to_keep] + return g diff --git a/src/porepy/fracs/fractures.py b/src/porepy/fracs/fractures.py new file mode 100644 index 0000000000..7a571131f1 --- /dev/null +++ b/src/porepy/fracs/fractures.py @@ -0,0 +1,2099 @@ +""" +A module for representation and manipulations of fractures and fracture sets. + +The model relies heavily on functions in the computational geometry library. + +""" +# Import of 'standard' external packages +import numpy as np +from collections import namedtuple +import matplotlib.pyplot as plt +from mpl_toolkits.mplot3d import Axes3D +import sympy +import warnings +import time + +# Imports of external packages that may not be present at the system. The +# module will work without any of these, but with limited functionalbility. +try: + import triangle +except ImportError: + warnings.warn('The triangle module is not available. Gridding of fracture' + ' networks will not work') +try: + import vtk + import vtk.util.numpy_support as vtk_np +except ImportError: + warnings.warn('VTK module is not available. Export of fracture network to\ + vtk will not work.') + +# Import of internally developed packages. +from compgeom import basics as cg +from compgeom import sort_points +from utils import setmembership +from core.grids import simplex +from gridding.gmsh.gmsh_interface import GmshWriter +from gridding.constants import GmshConstants +from gridding.fractured.utils import determine_mesh_size + + +class Fracture(object): + + def __init__(self, points, index=None): + self.p = points + # Ensure the points are ccw + self.points_2_ccw() + self.compute_centroid() + self.normal = cg.compute_normal(points)[:, None] + + self.orig_p = self.p.copy() + + self.index = index +# self.rot = cg.project_plane_matrix(p) + + def set_index(self, i): + self.index = i + + def __eq__(self, other): + return self.index == other.index + + def points(self): + """ + Iterator over the vexrtexes of the bounding polygon + + Yields: + np.array (3 x 1): polygon vertexes + + """ + for i in range(self.p.shape[1]): + yield self.p[:, i].reshape((-1, 1)) + + def segments(self): + """ + Iterator over the segments of the bounding polygon. + + Yields: + np.array (3 x 2): polygon segment + """ + + sz = self.p.shape[1] + for i in range(sz): + yield self.p[:, np.array([i, i + 1]) % sz] + + def points_2_ccw(self): + """ + Ensure that the points are sorted in a counter-clockwise order. + + Implementation note: + For now, the ordering of nodes in based on a simple angle argument. + This will not be robust for general point clouds, but we expect the + fractures to be regularly shaped in this sense. In particular, we + will be safe if the cell is convex. + + Returns: + np.array (int): The indices corresponding to the sorting. + + """ + # First rotate coordinates to the plane + points_2d = self.plane_coordinates() + # Center around the 2d origin + points_2d -= np.mean(points_2d, axis=1).reshape((-1, 1)) + + theta = np.arctan2(points_2d[1], points_2d[0]) + sort_ind = np.argsort(theta) + + self.p = self.p[:, sort_ind] + + return sort_ind + + def add_points(self, p, check_convexity=True): + """ + Add a point to the polygon, and enforce ccw sorting. + + Parameters: + p (np.ndarray, 3xn): Points to add + check_convexity (boolean, optional): Verify that the polygon is + convex. Defaults to true + + Return: + boolean, true if the resulting polygon is convex. + + """ + self.p = np.hstack((self.p, p)) + self.points_2_ccw() + + return self.check_convexity() + + def plane_coordinates(self): + """ + Represent the vertex coordinates in its natural 2d plane. + + The plane does not necessarily have the third coordinate as zero (no + translation to the origin is made) + + Returns: + np.array (2xn): The 2d coordinates of the vertexes. + + """ + rotation = cg.project_plane_matrix(self.p) + points_2d = rotation.dot(self.p) + + return points_2d[:2] + + def check_convexity(self): + """ + Check if the polygon is convex. + + Todo: If a hanging node is inserted at a segment, this may slightly + violate convexity due to rounding errors. It should be possible to + write an algorithm that accounts for this. First idea: Projcet + point onto line between points before and after, if the projection + is less than a tolerance, it is okay. + + Returns: + boolean, true if the polygon is convex. + + """ + return self.as_sp_polygon().is_convex() + + def compute_centroid(self): + """ + Compute, and redefine, center of the fracture in the form of the + centroid. + + The method assumes the polygon is convex. + + """ + # Rotate to 2d coordinates + rot = cg.project_plane_matrix(self.p) + p = rot.dot(self.p) + z = p[2, 0] + p = p[:2] + + # Vectors from the first point to all other points. Subsequent pairs of + # these will span triangles which, assuming convexity, will cover the + # polygon. + v = p[:, 1:] - p[:, 0].reshape((-1, 1)) + # The cell center of the triangles spanned by the subsequent vectors + cc = (p[:, 0].reshape((-1, 1)) + p[:, 1:-1] + p[:, 2:])/3 + # Area of triangles + area = 0.5 * np.abs(v[0, :-1] * v[1, 1:] - v[1, :-1] * v[0, 1:]) + + # The center is found as the area weighted center + center = np.sum(cc * area, axis=1) / np.sum(area) + + # Project back again. + self.center = rot.transpose().dot(np.append(center, z)) + + def as_sp_polygon(self): + sp = [sympy.geometry.Point(self.p[:, i]) + for i in range(self.p.shape[1])] + return sympy.geometry.Polygon(*sp) + + def _sympy_2_np(p): + # Convert sympy points to numpy format. If more than one point, these + # should be sent as a list + if isinstance(p, list): + return np.array(list([i.args for i in p]), dtype='float').transpose() + else: + return np.array(list(p.args), dtype='float').reshape((-1, 1)) + + def intersects(self, other, tol): + """ + Find intersections between self and another polygon. + + The function checks for both intersection between the interior of one + polygon with the boundary of the other, and pure boundary + intersections. Intersection types supported so far are + X (full intersection, possibly involving boundaries) + L (Two fractures intersect at a boundary) + + Not yet supported is T (one fracture ends with a segment, or possibly a + point in the plane of another). Patterns involving more than two + fractures need to be delt with by someone else (see FractureNetwork) + + + """ + + # Find intersections between self and other. Note that the algorithms + # for intersections are not fully reflexive in terms of argument + # order, so we need to do two tests. + + # Array for intersections with the interior of one polygon (as opposed + # to full boundary intersection, below) + int_points = np.empty((3, 0)) + + # Keep track of whether the intersection points are on the boundary of + # the polygons. + on_boundary_self = [] + on_boundary_other = [] + + #### + # First compare max/min coordinates. If the bounding boxes of the + # fractures do not intersect, there is nothing to do. + min_self = self.p.min(axis=1) + max_self = self.p.max(axis=1) + min_other = other.p.min(axis=1) + max_other = other.p.max(axis=1) + + if np.any(max_self < min_other) or np.any(min_self > max_other): + return int_points, on_boundary_self, on_boundary_other + + ##### + # Next screening: To intersect, both fractures must have vertexes + # either on both sides of each others plane, or close to the plane (T, + # L, Y)-type intersections. + + # Vectors from centers of the fractures to the vertexes of the other + # fratures. + s_2_o = other.p - self.center.reshape((-1, 1)) + o_2_s = self.p - other.center.reshape((-1, 1)) + + # Take the dot product of distance and normal vectors. Different signs + # for different vertexes signifies fractures that potentially cross. + other_from_self = np.sum(s_2_o * self.normal, axis=0) + self_from_other = np.sum(o_2_s * other.normal, axis=0) + + # To avoid ruling out vertexes that lie on the plane of another + # fracture, we introduce a threshold for almost-zero values. + # The scaling factor is somewhat arbitrary here, and probably + # safeguards too much, but better safe than sorry. False positives will + # be corrected by the more accurate, but costly, computations below. + scaled_tol = tol * max(1, max(np.max(np.abs(s_2_o)), + np.max(np.abs(o_2_s)))) + + # We can terminate only if no vertexes are close to the plane of the + # other fractures. + if (np.min(np.abs(other_from_self)) > scaled_tol and \ + np.min(np.abs(self_from_other)) > scaled_tol): + # If one of the fractures has all of its points on a single side of + # the other, there can be no intersections. + if (np.all(np.sign(other_from_self) == 1) or \ + np.all(np.sign(other_from_self) == -1) or \ + np.all(np.sign(self_from_other) == 1) or \ + np.all(np.sign(self_from_other) == -1)): + return int_points, on_boundary_self, on_boundary_other + + #### + # Check for intersection between interior of one polygon with + # segment of the other. + + # Compute intersections, with both polygons as first argument + isect_self_other = cg.polygon_segment_intersect(self.p, other.p, tol=tol) + isect_other_self = cg.polygon_segment_intersect(other.p, self.p, tol=tol) + + # Process data + if isect_self_other is not None: + int_points = np.hstack((int_points, isect_self_other)) + + # An intersection between self and other (in that order) is defined + # as being between interior of self and boundary of other. See + # polygon_segment_intersect for details. + on_boundary_self += [ + False for i in range(isect_self_other.shape[1])] + on_boundary_other += [ + True for i in range(isect_self_other.shape[1])] + + if isect_other_self is not None: + int_points = np.hstack((int_points, isect_other_self)) + + # Interior of other intersected by boundary of self + on_boundary_self += [ + True for i in range(isect_other_self.shape[1])] + on_boundary_other += [False for i in + range(isect_other_self.shape[1])] + + if int_points.shape[1] > 1: + int_points, *rest \ + = setmembership.unique_columns_tol(int_points, tol=tol) + # There should be at most two of these points + assert int_points.shape[1] <= 2 + + # There at at the most two intersection points between the fractures + # (assuming convexity). If two interior points are found, we can simply + # cut it short here. + if int_points.shape[1] == 2: + return int_points, on_boundary_self, on_boundary_other + + #### + # Next, check for intersections between the polygon boundaries + bound_sect_self_other = cg.polygon_boundaries_intersect(self.p, + other.p, + tol=tol) + bound_sect_other_self = cg.polygon_boundaries_intersect(other.p, + self.p, + tol=tol) + + # Short cut: If no boundary intersections, we return the interior + # points + if len(bound_sect_self_other) == 0 and len(bound_sect_other_self) == 0: + # None of the intersection points lay on the boundary + return int_points, on_boundary_self, on_boundary_other + + # Else, we have boundary intersection, and need to process them + bound_pt_self, \ + self_segment, \ + self_non_vertex, \ + self_cuts_through = self._process_segment_isect( + bound_sect_self_other, self.p, tol) + + bound_pt_other, \ + other_segment, \ + other_non_vertex, \ + other_cuts_through = self._process_segment_isect( + bound_sect_other_self, other.p, tol) + + # Run some sanity checks + + # Convex polygons can intersect each other in at most two points (and a + # line inbetween) + if int_points.shape[1] > 1: + assert bound_pt_self.shape[1] == 0 and bound_pt_other.shape[1] == 0 + elif int_points.shape[1] == 1: + # There should be exactly one unique boundary point. + bp = np.hstack((bound_pt_self, bound_pt_other)) + u_bound_pt, *rest = setmembership.unique_columns_tol(bp, tol=tol) + assert u_bound_pt.shape[1] == 1 + + # If a segment runs through the polygon, there should be no interior points. + # This may be sensitive to tolerances, should be a useful test to gauge + # that. + if self_cuts_through or other_cuts_through: + assert int_points.shape[1] == 0 + + # Storage for intersection points located on the boundary + bound_pt = [] + + # Cover the case of a segment - essentially an L-intersection + if self_segment: + assert other_segment # This should be reflexive + assert bound_pt_self.shape[1] == 2 + assert np.allclose(bound_pt_self, bound_pt_other) + on_boundary_self == [True, True] + on_boundary_other = [True, True] + return bound_pt_self, on_boundary_self, on_boundary_other + + # Case of cutting + if self_cuts_through or other_cuts_through: + # Do not expect this yet, corresponds to one kind of a a + # T-intersection (vertexes embedded in plane, but not fully cutting + # is another possibility). + raise NotImplemented() + + # By now, there should be a single member of bound_pt + assert bound_pt_self.shape[1] == 1 or bound_pt_self.shape[1] == 2 + assert bound_pt_other.shape[1] == 1 or bound_pt_other.shape[1] == 2 + bound_pt = np.hstack((int_points, bound_pt_self)) + on_boundary_self += [False for i in range(bound_pt_self.shape[1])] + on_boundary_other += [True for i in range(bound_pt_self.shape[1])] + + return bound_pt, on_boundary_self, on_boundary_other + + def _process_segment_isect(self, isect_bound, poly, tol): + """ + Helper function to interpret result from polygon_boundaries_intersect + + Classify an intersection between segments, from the perspective of a + polygon, as: + i) Sharing a segment + ii) Sharing part of a segment + ii) Sharing a point, which coincides with a polygon vertex + iii) Sharing a point that splits a segment of the polygn + + Parameters: + isect_bound: intersections + poly: First (not second!) polygon sent into intersection finder + + """ + bound_pt = np.empty((3, 0)) + + # Boolean indication of a shared segment + has_segment = False + # Boolean indication of an intersection in a vertex + non_vertex = [] + # Counter for how many times each segment is found + num_occ = np.zeros(poly.shape[1]) + + # Loop over all intersections, process information + for bi in isect_bound: + + # Index of the intersecting segments + num_occ[bi[0]] += 1 + + # Coordinates of the intersections + ip = bi[2] + + # No intersections should have more than two poitns + assert ip.shape[1] < 3 + + ip_unique, *rest = setmembership.unique_columns_tol(ip, tol=tol) + if ip_unique.shape[1] == 2: + # The polygons share a segment, or a + bound_pt = np.hstack((bound_pt, ip_unique)) + has_segment = True + # No need to deal with non_vertex here, there should be no more + # intersections (convex, non-overlapping polygons). + elif ip_unique.shape[1] == 1: + # Either a vertex or single intersection point. + poly_ext, *rest = setmembership.unique_columns_tol( + np.hstack((self.p, ip_unique)), tol=tol) + if poly_ext.shape[1] == self.p.shape[1]: + # This is a vertex, we skip it + pass + else: + # New point contact + bound_pt = np.hstack((bound_pt, ip_unique)) + non_vertex.append(True) + else: + # This should never happen for convex polygons + raise ValueError( + 'Should not have more than two intersection points') + + # Now, if any segment has been found more than once, it cuts two + # segments of the other polygon. + cuts_two = np.any(num_occ > 1) + + # Return a unique version of bound_pt + # No need to uniquify unless there is more than one point. + if bound_pt.shape[1] > 1: + bound_pt, *rest = setmembership.unique_columns_tol(bound_pt, tol=tol) + + return bound_pt, has_segment, non_vertex, cuts_two + + + def impose_boundary(self, box, tol): + """ + Impose a boundary on a fracture, defined by a bounding box. + + If the fracture extends outside the box, it will be truncated, and new + points are inserted on the intersection with the boundary. It is + assumed that the points defining the fracture defines a convex set (in + its natural plane) to begin with. + + The attribute self.p will be changed if intersections are found. The + original vertexes can still be recovered from self.orig_p. + + The box is specified by its extension in Cartesian coordinates. + + Parameters: + box (dicitionary): The bounding box, specified by keywords xmin, + xmax, ymin, ymax, zmin, zmax. + tol (double): Tolerance, defines when two points are considered + equal. + + """ + + # Maximal extent of the fracture + min_coord = self.p.min(axis=1) + max_coord = self.p.max(axis=1) + + # Coordinates of the bounding box + x0_box = box['xmin'] + x1_box = box['xmax'] + y0_box = box['ymin'] + y1_box = box['ymax'] + z0_box = box['zmin'] + z1_box = box['zmax'] + + # Gather the box coordinates in an array + box_array = np.array([[x0_box, x1_box], + [y0_box, y1_box], + [z0_box, z1_box]]) + + # We need to be a bit careful if the fracture extends outside the + # bounding box on two (non-oposite) sides. In addition to the insertion + # of points along the segments defined by self.p, this will also + # require the insertion of points at the meeting of the two sides. To + # capture this, we first look for the intersection between the fracture + # and planes that extends further than the plane, and then later + # move the intersection points to lay at the real bounding box + x0 = np.minimum(x0_box, min_coord[0] - 10 * tol) + x1 = np.maximum(x1_box, max_coord[0] + 10 * tol) + y0 = np.minimum(y0_box, min_coord[1] - 10 * tol) + y1 = np.maximum(y1_box, max_coord[1] + 10 * tol) + z0 = np.minimum(z0_box, min_coord[2] - 10 * tol) + z1 = np.maximum(z1_box, max_coord[2] + 10 * tol) + + def outside_box(p, bound_i): + # Helper function to test if points are outside the bounding box + p = cg.snap_to_grid(p, tol=tol) + # snap_to_grid will impose a grid of size self.tol, thus points + # that are more than half that distance away from the boundary + # are deemed outside. + # To reduce if-else on the boundary index, we compute all bounds, + # and then return the relevant one, as specified by bound_i + west_of = p[0] < x0_box - tol / 2 + east_of = p[0] > x1_box + tol / 2 + south_of = p[1] < y0_box - tol / 2 + north_of = p[1] > y1_box + tol / 2 + beneath = p[2] < z0_box - tol / 2 + above = p[2] > z1_box + tol / 2 + outside = np.vstack((west_of, east_of, south_of, north_of, + beneath, above)) + return outside[bound_i] + + + # Represent the planes of the bounding box as fractures, to allow + # us to use the fracture intersection methods. + # For each plane, we keep the fixed coordinate to the value specified + # by the box, and extend the two others to cover segments that run + # through the extension of the plane only. + west = Fracture(np.array([[x0_box, x0_box, x0_box, x0_box], + [y0, y1, y1, y0], [z0, z0, z1, z1]])) + east = Fracture(np.array([[x1_box, x1_box, x1_box, x1_box], + [y0, y1, y1, y0], + [z0, z0, z1, z1]])) + south = Fracture(np.array([[x0, x1, x1, x0], + [y0_box, y0_box, y0_box, y0_box], + [z0, z0, z1, z1]])) + north = Fracture(np.array([[x0, x1, x1, x0], + [y1_box, y1_box, y1_box, y1_box], + [z0, z0, z1, z1]])) + bottom = Fracture(np.array([[x0, x1, x1, x0], [y0, y0, y1, y1], + [z0_box, z0_box, z0_box, z0_box]])) + top = Fracture(np.array([[x0, x1, x1, x0], [y0, y0, y1, y1], + [z1_box, z1_box, z1_box, z1_box]])) + # Collect in a list to allow iteration + bound_planes = [west, east, south, north, bottom, top] + + # Loop over all boundary sides and look for intersections + for bi, bf in enumerate(bound_planes): + # Dimensions that are not fixed by bf + active_dims = np.ones(3, dtype=np.bool) + active_dims[np.floor(bi/2).astype('int')] = 0 + # Convert to indices + active_dims = np.squeeze(np.argwhere(active_dims)) + + # Find intersection points + isect, _, _ = self.intersects(bf, tol) + num_isect = isect.shape[1] + if len(isect) > 0 and num_isect > 0: + num_pts_orig = self.p.shape[1] + # Add extra points at the end of the point list. + self.p, *rest \ + = setmembership.unique_columns_tol(np.hstack((self.p, + isect))) + num_isect = self.p.shape[1] - num_pts_orig + if num_isect == 0: + continue + + # Sort fracture points in a ccw order. + # This must be done before sliding the intersection points + # (below), since the latter may break convexity of the + # fracture, thus violate the (current) implementation of + # points_2_ccw() + sort_ind = self.points_2_ccw() + + # The next step is to decide whether and how to move the + # intersection points. + + # If all intersection points are outside, and on the same side + # of the box for all of the active dimensions, convexity of the + # polygon implies that the whole fracture is outside the + # bounding box. We signify this by setting self.p empty. + if np.any(np.all(isect[active_dims] < \ + box_array[active_dims, 0], axis=1), axis=0): + self.p = np.empty((3, 0)) + # No need to consider other boundary planes + break + if np.any(np.all(isect[active_dims] > \ + box_array[active_dims, 1], axis=1), axis=0): + self.p = np.empty((3, 0)) + # No need to consider other boundary planes + break + + # The intersection points will lay on the bounding box on one + # of the dimensions (that of bf, specified by xyz_01_box), but + # may be outside the box for the other sides (xyz_01). We thus + # need to move the intersection points in the plane of bf and + # the fracture plane. + # The relevant tangent vector is perpendicular to both the + # fracture and bf + normal_self = cg.compute_normal(self.p) + normal_bf = cg.compute_normal(bf.p) + tangent = np.cross(normal_self, normal_bf) + # Unit vector + tangent *= 1./ np.linalg.norm(tangent) + + isect_ind = np.argwhere(sort_ind >= num_pts_orig).ravel('F') + p_isect = self.p[:, isect_ind] + + # Loop over all intersection points and active dimensions. If + # the point is outside the bounding box, move it. + for pi in range(num_isect): + for dim, other_dim in zip(active_dims, active_dims[::-1]): + # Test against lower boundary + lower_diff = p_isect[dim, pi] - box_array[dim, 0] + # Modify coordinates if necessary. This dimension is + # simply set to the boundary, while the other should + # slide along the tangent vector + if lower_diff < 0: + p_isect[dim, pi] = box_array[dim, 0] + # We know lower_diff != 0, no division by zero. + # We may need some tolerances, though. + t = tangent[dim] / lower_diff + p_isect[other_dim, pi] += t * tangent[other_dim] + + # Same treatment of the upper boundary + upper_diff = p_isect[dim, pi] - box_array[dim, 1] + # Modify coordinates if necessary. This dimension is + # simply set to the boundary, while the other should + # slide along the tangent vector + if upper_diff > 0: + p_isect[dim, pi] = box_array[dim, 1] + t = tangent[dim] / upper_diff + p_isect[other_dim, pi] -= t * tangent[other_dim] + + # Finally, identify points that are outside the face bf + inside = np.logical_not(outside_box(self.p, bi)) + # Dump points that are outside. + self.p = self.p[:, inside] + self.p, *rest = setmembership.unique_columns_tol(self.p, + tol=tol) + # We have modified the fractures, so re-calculate the centroid + self.compute_centroid() + else: + # No points exists, but the whole point set can still be + # outside the relevant boundary face. + outside = outside_box(self.p, bi) + if np.all(outside): + self.p = np.empty((3, 0)) + # There should be at least three points in a fracture. + if self.p.shape[1] < 3: + break + + + def segments(self): + return self.as_sp_polygon().sides + + def __repr__(self): + return str(self.as_sp_polygon()) + + def __str__(self): + s = 'Points: \n' + s += str(self.p) + '\n' + s += 'Center: ' + str(self.center) + return s + + def plot_frame(self, ax=None): + + if ax is None: + fig = plt.figure() + ax = fig.gca(projection='3d') + x = np.append(self.p[0], self.p[0, 0]) + y = np.append(self.p[1], self.p[1, 0]) + z = np.append(self.p[2], self.p[2, 0]) + ax.plot(x, y, z) + return ax + +#-------------------------------------------------------------------- + + +class EllipticFracture(Fracture): + """ + Subclass of Fractures, representing an elliptic fracture. + + """ + + def __init__(self, center, major_axis, minor_axis, major_axis_angle, + strike_angle, dip_angle, num_points=16): + """ + Initialize an elliptic shaped fracture, approximated by a polygon. + + + The rotation of the plane is calculated using three angles. First, the + rotation of the major axis from the x-axis. Next, the fracture is + inclined by specifying the strike angle (which gives the rotation + axis) measured from the x-axis, and the dip angle. All angles are + measured in radians. + + Parameters: + center (np.ndarray, size 3x1): Center coordinates of fracture. + major_axis (double): Length of major axis (radius-like, not + diameter). + minor_axis (double): Length of minor axis. There are no checks on + whether the minor axis is less or equal the major. + major_axis_angle (double, radians): Rotation of the major axis from + the x-axis. Measured before strike-dip rotation, see above. + strike_angle (double, radians): Line of rotation for the dip. + Given as angle from the x-direction. + dip_angle (double, radians): Dip angle, i.e. rotation around the + strike direction. + num_points (int, optional): Number of points used to approximate + the ellipsis. Defaults to 16. + + Example: + Fracture centered at [0, 1, 0], with a ratio of lengths of 2, + rotation in xy-plane of 45 degrees, and an incline of 30 degrees + rotated around the x-axis. + >>> frac = EllipticFracture(np.array([0, 1, 0]), 10, 5, np.pi/4, 0, + np.pi/6) + + """ + self.center = center + + # First, populate polygon in the xy-plane + angs = np.linspace(0, 2 * np.pi, num_points + 1, endpoint=True)[:-1] + x = major_axis * np.cos(angs) + y = minor_axis * np.sin(angs) + z = np.zeros_like(angs) + ref_pts = np.vstack((x, y, z)) + + # Rotate reference points so that the major axis has the right + # orientation + major_axis_rot = cg.rot(major_axis_angle, [0, 0, 1]) + rot_ref_pts = major_axis_rot.dot(ref_pts) + + # Then the dip + # Rotation matrix of the strike angle + strike_rot = cg.rot(strike_angle, np.array([0, 0, 1])) + # Compute strike direction + strike_dir = strike_rot.dot(np.array([1, 0, 0])) + dip_rot = cg.rot(dip_angle, strike_dir) + + dip_pts = dip_rot.dot(rot_ref_pts) + + # Set the points, and store them in a backup. + self.p = center[:, np.newaxis] + dip_pts + self.orig_p = self.p.copy() + + # Compute normal vector + self.normal = cg.compute_normal(self.p)[:, None] + + + def segments(self): + return utils.sort_point_lines() + + +#------------------------------------------------------------------------- + + +class Intersection(object): + + def __init__(self, first, second, coord): + self.first = first + self.second = second + self.coord = coord + + def __repr__(self): + s = 'Intersection between fractures ' + str(self.first.index) + ' and ' + \ + str(self.second.index) + '\n' + s += 'Intersection points: \n' + for i in range(self.coord.shape[1]): + s += '(' + str(self.coord[0, i]) + ', ' + str(self.coord[1, i]) + ', ' + \ + str(self.coord[2, i]) + ') \n' + return s + + def get_other_fracture(self, i): + if self.first == i: + return self.second + else: + return self.first + +#---------------------------------------------------------------------------- + + +class FractureNetwork(object): + + def __init__(self, fractures=None, verbose=0, tol=1e-4): + + if fractures is None: + self._fractures = fractures + else: + self._fractures = fractures + + for i, f in enumerate(self._fractures): + f.set_index(i) + + self.intersections = [] + + self.has_checked_intersections = False + self.tol = tol + self.verbose = verbose + + def add(self, f): + # Careful here, if we remove one fracture and then add, we'll have + # identical indices. + f.set_index(len(self._fractures)) + self._fractures.append(f) + + def __getitem__(self, position): + return self._fractures[position] + + def get_normal(self, frac): + return self._fractures[frac].normal + + def get_center(self, frac): + return self._fractures[frac].center + + def get_intersections(self, frac): + """ + Get all intersections in the plane of a given fracture. + """ + if frac is None: + frac = np.arange(len(self._fractures)) + if isinstance(frac, np.ndarray): + frac = list(frac) + elif isinstance(frac, int): + frac = [frac] + isect = [] + for fi in frac: + f = self[fi] + isect_loc = [] + for i in self.intersections: + if i.first.index == f.index or i.second.index == f.index: + isect_loc.append(i) + isect.append(isect_loc) + return isect + + def find_intersections(self, use_orig_points=False): + """ + Find intersections between fractures in terms of coordinates. + + The intersections are stored in the attribute self.Intersections. + + Handling of the intersections (splitting into non-intersecting + polygons, paving the way for gridding) is taken care of by the function + split_intersections(). + + Note that find_intersections() should be invoked after external + boundaries are imposed. If the reverse order is applied, intersections + outside the domain may be identified, with unknown consequences for the + reliability of the methods. If intersections outside the bounding box + are of interest, these can be found by setting the parameter + use_orig_points to True. + + Parameters: + use_orig_points (boolean, optional): Whether to use the original + fracture description in the search for intersections. Defaults + to False. If True, all fractures will have their attribute p + reset to their original value. + + """ + self.has_checked_intersections = True + if self.verbose > 0: + print('Find intersections between fractures') + start_time = time.time() + + # If desired, use the original points in the fracture intersection. + # This will reset the field self._fractures.p, and thus revoke + # modifications due to boundaries etc. + if use_orig_points: + for f in self._fractures: + f.p = f.orig_p + + for i, first in enumerate(self._fractures): + for j in range(i + 1, len(self._fractures)): + second = self._fractures[j] + isect, bound_first, bound_second = first.intersects(second, + self.tol) + if len(isect) > 0: + self.intersections.append(Intersection(first, second, + isect)) + + if self.verbose > 0: + print('Done. Elapsed time ' + str(time.time() - start_time)) + if self.verbose > 1: + self.intersection_info() + + def intersection_info(self, frac_num=None): + # Number of fractures with some intersection + num_intersecting_fracs = 0 + # Number of intersections in total + num_intersections = 0 + + if frac_num is None: + frac_num = np.arange(len(self._fractures)) + for f in frac_num: + isects = [] + for i in self.intersections: + if i.first.index == f and i.coord.shape[1] > 0: + isects.append(i.second.index) + elif i.second.index == f and i.coord.shape[1] > 0: + isects.append(i.first.index) + if len(isects) > 0: + num_intersecting_fracs += 1 + num_intersections += len(isects) + + if self.verbose > 1: + print(' Fracture ' + str(f) + ' intersects with'\ + ' fractuer(s) ' + str(isects)) + # Print aggregate numbers. Note that all intersections are counted + # twice (from first and second), thus divide by two. + print('In total ' + str(num_intersecting_fracs) + ' fractures ' + + 'intersect in ' + str(int(num_intersections/2)) \ + + ' intersections') + + + def split_intersections(self): + """ + Based on the fracture network, and their known intersections, decompose + the fractures into non-intersecting sub-polygons. These can + subsequently be exported to gmsh. + + The method will add an atribute decomposition to self. + + """ + + if self.verbose > 0: + start_time = time.time() + print('Split fracture intersections') + + # First, collate all points and edges used to describe fracture + # boundaries and intersections. + all_p, edges,\ + edges_2_frac, is_boundary_edge = self._point_and_edge_lists() + + if self.verbose > 1: + self._verify_fractures_in_plane(all_p, edges, edges_2_frac) + + # By now, all segments in the grid are defined by a unique set of + # points and edges. The next task is to identify intersecting edges, + # and split them. + all_p, edges,\ + edges_2_frac, is_boundary_edge = \ + self._remove_edge_intersections(all_p, edges, edges_2_frac, + is_boundary_edge) + + if self.verbose > 1: + self._verify_fractures_in_plane(all_p, edges, edges_2_frac) + + # With all edges being non-intersecting, the next step is to split the + # fractures into polygons formed of boundary edges, intersection edges + # and auxiliary edges that connect the boundary and intersections. + all_p, \ + edges,\ + is_boundary_edge,\ + poly_segments,\ + poly_2_frac = self._split_into_polygons(all_p, edges, edges_2_frac, + is_boundary_edge) + + # Store the full decomposition. + self.decomposition = {'points': all_p, + 'edges': edges.astype('int'), + 'is_bound': is_boundary_edge, + 'polygons': poly_segments, + 'polygon_frac': poly_2_frac} + + if self.verbose > 0: + print('Fracture splitting done. Elapsed time ' + str(time.time() - + start_time)) + + def _point_and_edge_lists(self): + """ + Obtain lists of all points and connections necessary to describe + fractures and their intersections. + + Returns: + np.ndarray, 3xn: Unique coordinates of all points used to describe + the fracture polygons, and their intersections. + + np.ndarray, 2xn_edge: Connections between points, formed either + by a fracture boundary, or a fracture intersection. + list: For each edge, index of all fractures that point to the + edge. + np.ndarray of bool (size=num_edges): A flag telling whether the + edge is on the boundary of a fracture. + + """ + if self.verbose > 1: + print(' Create lists of points and edges') + start_time = time.time() + + # Field for all points in the fracture description + all_p = np.empty((3, 0)) + # All edges, either as fracture boundary, or fracture intersection + edges = np.empty((2, 0)) + # For each edge, a list of all fractures pointing to the edge. + edges_2_frac = [] + + # Field to know if an edge is on the boundary of a fracture. + # Not sure what to do with a T-type intersection here + is_boundary_edge = [] + + # First loop over all fractures. All edges are assumed to be new; we + # will deal with coinciding points later. + for fi, frac in enumerate(self._fractures): + num_p = all_p.shape[1] + num_p_loc = frac.orig_p.shape[1] + all_p = np.hstack((all_p, frac.orig_p)) + + loc_e = num_p + np.vstack((np.arange(num_p_loc), + (np.arange(num_p_loc) + 1) % num_p_loc)) + edges = np.hstack((edges, loc_e)) + for i in range(num_p_loc): + edges_2_frac.append([fi]) + is_boundary_edge.append(True) + + # Next, loop over all intersections, and define new points and edges + for i in self.intersections: + # Only add information if the intersection exists, that is, it has + # a coordinate. + if i.coord.size > 0: + num_p = all_p.shape[1] + all_p = np.hstack((all_p, i.coord)) + + edges = np.hstack( + (edges, num_p + np.arange(2).reshape((-1, 1)))) + edges_2_frac.append([i.first.index, i.second.index]) + is_boundary_edge.append(False) + + # Ensure that edges are integers + edges = edges.astype('int') + + if self.verbose > 1: + print(' Done creating lists. Elapsed time ' + str(time.time() - + start_time)) + if self.verbose > 1: + print(' Uniquify next') + + return self._uniquify_points_and_edges(all_p, edges, edges_2_frac, + is_boundary_edge) + + def _uniquify_points_and_edges(self, all_p, edges, edges_2_frac, + is_boundary_edge): + # Snap the points to an underlying Cartesian grid. This is the basis + # for declearing two points equal + # NOTE: We need to account for dimensions in the tolerance; + + if self.verbose > 1: + start_time = time.time() + print(' Uniquify points and edges. Starting with:') + print(' ' + str(all_p.shape[1]) + ' points, ' +\ + str(edges.shape[1]) + ' edges') + + all_p = cg.snap_to_grid(all_p, tol=self.tol) + + # We now need to find points that occur in multiple places + p_unique, unique_ind_p, \ + all_2_unique_p = setmembership.unique_columns_tol(all_p, tol= + self.tol * + np.sqrt(3)) + + # Update edges to work with unique points + edges = all_2_unique_p[edges] + + # Look for edges that share both nodes. These will be identical, and + # will form either a L/Y-type intersection (shared boundary segment), + # or a three fractures meeting in a line. + # Do a sort of edges before looking for duplicates. + e_unique, unique_ind_e, all_2_unique_e = \ + setmembership.unique_columns_tol(np.sort(edges, axis=0)) + + # Update the edges_2_frac map to refer to the new edges + edges_2_frac_new = e_unique.shape[1] * [np.empty(0)] + for old_i, new_i in enumerate(all_2_unique_e): + edges_2_frac_new[new_i] =\ + np.unique(np.hstack((edges_2_frac_new[new_i], + edges_2_frac[old_i]))) + edges_2_frac = edges_2_frac_new + + # Represent edges by unique values + edges = e_unique + + # The uniquification of points may lead to edges with identical start + # and endpoint. Find and remove these. + point_edges = np.where(np.squeeze(np.diff(edges, axis=0)) == 0)[0] + edges = np.delete(edges, point_edges, axis=1) + unique_ind_e = np.delete(unique_ind_e, point_edges) + for ri in point_edges[::-1]: + del edges_2_frac[ri] + + # Update boundary information + is_boundary_edge = [is_boundary_edge[i] + for i in unique_ind_e] # Hope this is right + + # Ensure that the edge to fracture map to a list of numpy arrays. + # Use unique so that the same edge only refers to an edge once. + edges_2_frac = [np.unique(np.array(edges_2_frac[i])) for i in + range(len(edges_2_frac))] + + # Sanity check, the fractures should still be defined by points in a + # plane. + self._verify_fractures_in_plane(p_unique, edges, edges_2_frac) + + if self.verbose > 1: + print(' Uniquify complete:') + print(' ' + str(p_unique.shape[1]) + ' points, ' +\ + str(edges.shape[1]) + ' edges') + print(' Elapsed time ' + str(time.time() - start_time)) + + return p_unique, edges, edges_2_frac, is_boundary_edge + + def _remove_edge_intersections(self, all_p, edges, edges_2_frac, + is_boundary_edge): + """ + Remove crossings from the set of fracture intersections. + + Intersecting intersections (yes) are split, and new points are + introduced. + + Parameters: + all_p (np.ndarray, 3xn): Coordinates of all points used to describe + the fracture polygons, and their intersections. Should be + unique. + edges (np.ndarray, 2xn): Connections between points, formed either + by a fracture boundary, or a fracture intersection. + edges_2_frac (list): For each edge, index of all fractures that + point to the edge. + is_boundary_edge (np.ndarray of bool, size=num_edges): A flag + telling whether the edge is on the boundary of a fracture. + + Returns: + The same fields, but updated so that all edges are + non-intersecting. + + """ + if self.verbose > 0: + print('Remove edge intersections') + start_time = time.time() + + # The algorithm loops over all fractures, pulls out edges associated + # with the fracture, project to the local 2D plane, and look for + # intersections there (direct search in 3D may also work, but this was + # a simple option). When intersections are found, the global lists of + # points and edges are updated. + for fi, frac in enumerate(self._fractures): + + if self.verbose > 1: + print(' Remove intersections from fracture ' + str(fi)) + + # Identify the edges associated with this fracture + # It would have been more convenient to use an inverse + # relationship frac_2_edge here, but that would have made the + # update for new edges (towards the end of this loop) more + # cumbersome. + edges_loc_ind = [] + for ei, e in enumerate(edges_2_frac): + if np.any(e == fi): + edges_loc_ind.append(ei) + + edges_loc = np.vstack((edges[:, edges_loc_ind], + np.array(edges_loc_ind))) + p_ind_loc = np.unique(edges_loc[:2]) + p_loc = all_p[:, p_ind_loc] + + p_2d, edges_2d, p_loc_c, rot = self._points_2_plane(p_loc, + edges_loc, + p_ind_loc) + + # Add a tag to trace the edges during splitting + edges_2d[2] = edges_loc[2] + + # Obtain new points and edges, so that no edges on this fracture + # are intersecting. + p_new, edges_new = cg.remove_edge_crossings(p_2d, edges_2d, + tol=self.tol, + verbose=self.verbose) + + # Then, patch things up by converting new points to 3D, + + # From the design of the functions in cg, we know that new points + # are attached to the end of the array. A more robust alternative + # is to find unique points on a combined array of p_loc and p_new. + p_add = p_new[:, p_ind_loc.size:] + num_p_add = p_add.shape[1] + + # Add third coordinate, and map back to 3D + p_add = np.vstack((p_add, np.zeros(num_p_add))) + + # Inverse of rotation matrix is the transpose, add cloud center + # correction + p_add_3d = rot.transpose().dot(p_add) + p_loc_c + + # The new points will be added at the end of the global point array + # (if not, we would need to renumber all global edges). + # Global index of added points + ind_p_add = all_p.shape[1] + np.arange(num_p_add) + # Global index of local points (new and added) + p_ind_exp = np.hstack((p_ind_loc, ind_p_add)) + + # Add the new points towards the end of the list. + all_p = np.hstack((all_p, p_add_3d)) + + # The ordering of the global edge list bears no significance. We + # therefore plan to delete all edges (new and old), and add new + # ones. + + # First add new edges. + # All local edges in terms of global point indices + edges_new_glob = p_ind_exp[edges_new[:2]] + edges = np.hstack((edges, edges_new_glob)) + + # Global indices of the local edges + edges_loc_ind = np.unique(edges_loc_ind) + + # Append fields for edge-fracture map and boundary tags + for ei in range(edges_new.shape[1]): + # Find the global edge index. For most edges, this will be + # correctly identified by edges_new[2], which tracks the + # original edges under splitting. However, in cases of + # overlapping segments, in which case the index of the one edge + # may completely override the index of the other (this is + # caused by the implementation of remove_edge_crossings). + # We therefore compare the new edge to the old ones (before + # splitting). If found, use the old information; if not, use + # index as tracked by splitting. + is_old, old_loc_ind =\ + setmembership.ismember_rows(edges_new_glob[:, ei] + .reshape((-1, 1)), + edges[:2, edges_loc_ind]) + if is_old[0]: + glob_ei = edges_loc_ind[old_loc_ind[0]] + else: + glob_ei = edges_new[2, ei] + # Update edge_2_frac and boundary information. + edges_2_frac.append(edges_2_frac[glob_ei]) + is_boundary_edge.append(is_boundary_edge[glob_ei]) + + # Finally, purge the old edges + edges = np.delete(edges, edges_loc_ind, axis=1) + + # We cannot delete more than one list element at a time. Delete by + # index in decreasing order, so that we do not disturb the index + # map. + edges_loc_ind.sort() + for ei in edges_loc_ind[::-1]: + del edges_2_frac[ei] + del is_boundary_edge[ei] + # And we are done with this fracture. On to the next one. + + if self.verbose > 0: + print('Done with intersection removal. Elapsed time ' + + str(time.time() - start_time)) + + self._verify_fractures_in_plane(all_p, edges, edges_2_frac) + + return self._uniquify_points_and_edges(all_p, edges, edges_2_frac, + is_boundary_edge) + + # To + # define the auxiliary edges, we create a triangulation of the + # fractures. We then grow polygons forming part of the fracture in a + # way that ensures that no polygon lies on both sides of an + # intersection edge. + + def _split_into_polygons(self, all_p, edges, edges_2_frac, is_boundary_edge): + """ + Split the fracture surfaces into non-intersecting polygons. + + Starting from a description of the fracture polygons and their + intersection lines (which should be non-intersecting), the fractures + are split into sub-polygons which they may share a boundary, but no + sub-polygon intersect the surface of another. This step is necessary + for the fracture network to be ammenable to gmsh. + + The sub-polygons are defined by drawing auxiliary lines between + fracture points. This expands the global set of edges, but not the + points. The auxiliary lines will be sent to gmsh as constraints on the + gridding algorithm. + + TODO: The restriction that the auxiliary lines can only be drawn + between existing lines can create unfortunate constraints in the + gridding, in terms of sharp angles etc. This can be alleviated by + adding additional points to the global description. However, if a point + is added to an intersection edge, this will require an update of all + fractures sharing that intersection. Not sure what the right thing to + do here is. + + Parameters: + all_p (np.ndarray, 3xn): Coordinates of all points used to describe + the fracture polygons, and their intersections. Should be + unique. + edges (np.ndarray, 2xn): Connections between points, formed either + by a fracture boundary, or a fracture intersection. + edges_2_frac (list): For each edge, index of all fractures that + point to the edge. + is_boundary_edge (np.ndarray of bool, size=num_edges): A flag + telling whether the edge is on the boundary of a fracture. + + Returns: + np.ndarray (3xn_pt): Coordinates of all points used to describe the + fractures. + np.ndarray (3xn_edges): Connections between points, formed by + fracture boundaries, fracture intersections, or auxiliary lines + needed to form sub-polygons. The auxiliary lines will be added + towards the end of the array. + np.ndarray (boolean, 3xn_edges_orig): Flag telling if an edge is on + the boundary of a fracture. NOTE: The list is *not* expanded as + auxiliary lines are added; these are known to not be on the + boundary. The legth of this output, relative to the second, can + thus be used to flag auxiliary lines. + list of np.ndarray: Each item contains a sub-polygon, specified by + the global indices of its vertices. + list of int: For each sub-polygon, index of the fracture it forms a + part of. + + Note that for the moment, the first and third outputs are not + modified compared to respective input. This will change if the + splitting is allowed to introduce new additional points. + + """ + + if self.verbose > 0: + print('Split fractures into polygon') + start_time = time.time() + + # For each polygon, list of segments that make up the polygon + poly_segments = [] + # Which fracture is the polygon part of + poly_2_frac = [] + # Extra edges formed by connecting boundary and intersection segments + artificial_edges = [] + + for fi, frac in enumerate(self._fractures): + + if self.verbose > 1: + print(' Split fracture no ' + str(fi)) + + # Identify the edges associated with this fracture + # It would have been more convenient to use an inverse + # relationship frac_2_edge here, but that would have made the + # update for new edges (towards the end of this loop) more + # cumbersome. + edges_loc_ind = [] + for ei, e in enumerate(edges_2_frac): + if np.any(e == fi): + edges_loc_ind.append(ei) + + edges_loc = np.vstack((edges[:, edges_loc_ind], + np.array(edges_loc_ind))) + p_ind_loc = np.unique(edges_loc[:2]) + p_loc = all_p[:, p_ind_loc] + + p_2d, edges_2d, p_loc_c, rot = self._points_2_plane(p_loc, + edges_loc, + p_ind_loc) + + # Add a tag to trace the edges during splitting + edges_2d[2] = edges_loc[2] + + # Flag of whether the constraint is an internal boundary (as + # opposed to boundary of the polygon) + internal_boundary = np.where([not is_boundary_edge[i] + for i in edges_2d[2]])[0] + + # Create a dictionary of input information to triangle + triangle_opts = {'vertices': p_2d.transpose(), + 'segments': edges_2d[:2].transpose(), + 'segment_markers': + edges_2d[2].transpose().astype('int32') + } + # Run a constrained Delaunay triangulation. + t = triangle.triangulate(triangle_opts, 'p') + + # Pull out information on the triangulation + segment_markers = \ + np.squeeze(np.asarray(t['segment_markers']).transpose()) + tri = np.sort(np.asarray(t['triangles']).transpose(), axis=0) + p = np.asarray(t['vertices']).transpose() + segments = np.sort(np.asarray(t['segments']).transpose(), axis=0) + + p = cg.snap_to_grid(p, tol=self.tol) + + # It turns out that Triangle sometime creates (almost) duplicate + # points. Our algorithm are based on the points staying, thus these + # need to go. + # The problem is characterized by output from Triangle having more + # points than the input. + if p.shape[1] > p_2d.shape[1]: + + # Identify duplicate points + # Not sure about tolerance used here + p, new_2_old, old_2_new = \ + setmembership.unique_columns_tol(p, tol=np.sqrt(self.tol)) + # Update segment and triangle coordinates to refer to unique + # points + segments = old_2_new[segments] + tri = old_2_new[tri] + + # Remove segments with the same start and endpoint + segments_remove = np.argwhere(np.diff(segments, axis=0)[0] == 0) + segments = np.delete(segments, segments_remove, axis=1) + segment_markers = np.delete(segment_markers, segments_remove) + + # Remove segments that exist in duplicates (a, b) and (b, a) + segments.sort(axis=0) + segments, new_2_old_seg, old_2_new_seg = \ + setmembership.unique_columns_tol(segments) + segment_markers = segment_markers[new_2_old_seg] + + # Look for degenerate triangles, where the same point occurs + # twice + tri.sort(axis=0) + degenerate_tri = np.any(np.diff(tri, axis=0) == 0, axis=0) + tri = tri[:, np.logical_not(degenerate_tri)] + + # Remove duplicate triangles, if any + tri, *rest = setmembership.unique_columns_tol(tri) + + + # We need a connection map between cells. The simplest option was + # to construct a simplex grid, and use the associated function. + # While we were at it, we could have used this to find cell + # centers, cell-face (segment) mappings etc, but for reason, that + # did not happen. + # TODO: Remove dependency on core.grids.simplex + g = simplex.TriangleGrid(p, tri) + g.compute_geometry() + c2c = g.cell_connection_map() + + def cells_of_segments(cells, s): + # Find the cells neighboring + hit_0 = np.logical_or(cells[0] == s[0], cells[0] == + s[1]).astype('int') + hit_1 = np.logical_or( + cells[1] == s[0], cells[1] == s[1]).astype('int') + hit_2 = np.logical_or( + cells[2] == s[0], cells[2] == s[1]).astype('int') + hit = np.squeeze(np.where(hit_0 + hit_1 + hit_2 == 2)) + return hit + + px = p[0] + py = p[1] + # Cell centers (in 2d coordinates) + cell_c = np.vstack((np.mean(px[tri], axis=0), + np.mean(py[tri], axis=0))) + + # The target sub-polygons should have intersections as an external + # boundary. The sub-polygons are grown from triangles on each side of + # the intersection, denoted cw and ccw. + cw_cells = [] + ccw_cells = [] + + # For each internal boundary, find the cells on each side of the + # boundary; these will be assigned to different polygons. For the + # moment, there should be a single cell along each side of the + # boundary, but this may change in the future. + + if internal_boundary.size == 0: + # For non-intersecting fractures, we just need a single seed + cw_cells = [0] + else: + # Full treatment + for ib in internal_boundary: + segment_match = np.squeeze(np.where(segment_markers == + edges_2d[2, ib])) + loc_segments = segments[:, segment_match] + loc_cells = cells_of_segments(tri, loc_segments) + + p_0 = p_2d[:, edges_2d[0, ib]] + p_1 = p_2d[:, edges_2d[1, ib]] + + cw_loc = [] + ccw_loc = [] + + for ci in np.atleast_1d(loc_cells): + if cg.is_ccw_polyline(p_0, p_1, cell_c[:, ci]): + ccw_loc.append(ci) + else: + cw_loc.append(ci) + cw_cells.append(cw_loc) + ccw_cells.append(ccw_loc) + + polygon = np.zeros(tri.shape[1], dtype='int') + counter = 1 + for c in cw_cells + ccw_cells: + polygon[c] = counter + counter += 1 + + # A cell in the partitioning may border more than one internal + # constraint, and thus be assigned two values (one overwritten) in + # the above loop. This should be fine; the corresponding index + # will have no cells assigned to it, but that is taken care of + # below. + + num_iter = 0 + + # From the seeds, assign a polygon index to all triangles + while np.any(polygon == 0): + + num_occurences = np.bincount(polygon) + # Disregard the 0 (which we know is there, since the while loop + # took another round). + order = np.argsort(num_occurences[1:]) + for pi in order: + vec = 0 * polygon + # We know that the values in polygon are mapped to + vec[np.where(polygon == pi + 1)] = 1 + new_val = c2c * vec + not_found = np.where(np.logical_and(polygon == 0, + new_val > 0)) + polygon[not_found] = pi + 1 + + num_iter += 1 + if num_iter >= tri.shape[1]: + # Sanity check, since we're in a while loop. The code + # should terminate long before this, but I have not + # tried to find an exact maximum number of iterations. + raise ValueError('This must be too many iterations') + + if self.verbose > 2: + print(' Split fracture into ' + str(np.unique(polygon).size) + + ' polygons') + + # For each cell, find its boundary + for poly in np.unique(polygon): + tri_loc = tri[:, np.squeeze(np.where(polygon == poly))] + # Special case of a single triangle + if tri_loc.size == 3: + tri_loc = tri_loc.reshape((-1, 1)) + + seg_loc = np.sort(np.hstack((tri_loc[:2], + tri_loc[1:], + tri_loc[::2])), axis=0) + # Really need something like accumarray here.. + unique_s, _, all_2_unique = setmembership.unique_columns_tol( + seg_loc) + bound_segment_ind = np.squeeze(np.where(np.bincount(all_2_unique) + == 1)) + bound_segment = unique_s[:, bound_segment_ind] + boundary = sort_points.sort_point_pairs(bound_segment) + + # Ensure that the boundary is stored ccw + b_points = p_2d[:, boundary[:, 0]] + if not cg.is_ccw_polygon(b_points): + boundary = boundary[::-1] + + # The boundary is expressed in terms of the local point + # numbering. Convert to global numbering. + boundary_glob = p_ind_loc[boundary].astype('int') + poly_segments.append(boundary_glob) + poly_2_frac.append(fi) + # End of processing this sub-polygon + + # End of processing this fracture. + + # We have now split all fractures into non-intersecting polygons. The + # polygon boundary consists of fracture boundaries, fracture + # intersections and auxiliary edges. The latter must be identified, and + # added to the global edge list. + all_edges = np.empty((2, 0), dtype='int') + for p in poly_segments: + all_edges = np.hstack((all_edges, p)) + + # Find edges of the polygons that can be found in the list of existing + # edges + edge_exist, map_ind = setmembership.ismember_rows(all_edges, edges) + # Also check with the reverse ordering of edge points + edge_exist_reverse,\ + map_ind_reverse = setmembership.ismember_rows(all_edges[::-1], + edges) + # The edge exists if it is found of the orderings + edge_exist = np.logical_or(edge_exist, edge_exist_reverse) + # Where in the list of all edges are the new ones located? + new_edge_ind = np.where([~i for i in edge_exist])[0] + new_edges = all_edges[:, new_edge_ind] + + # If we have found new edges (will most likely happen if there are + # intersecting fractures in the network), these should be added to the + # edge list, in a unique representation. + if new_edges.size > 0: + # Sort the new edges to root out the same edge being found twice + # from different directions + new_edges = np.sort(new_edges, axis=0) + # Find unique representation + unique_new_edges, * \ + rest = setmembership.unique_columns_tol(new_edges) + edges = np.hstack((edges, unique_new_edges)) + + if self.verbose > 0: + print('Done with splitting into polygons. Elapsed time ' + + str(time.time() - start_time)) + + return all_p, edges, is_boundary_edge, poly_segments, poly_2_frac + + + def report_on_decomposition(self, do_print=True, verbose=None): + """ + Compute various statistics on the decomposition. + + The coverage is rudimentary for now, will be expanded when needed. + + Parameters: + do_print (boolean, optional): Print information. Defaults to True. + verbose (int, optional): Override the verbosity level of the + network itself. If not provided, the network value will be + used. + + Returns: + str: String representation of the statistics + + """ + if verbose is None: + verbose = self.verbose + d = self.decomposition + + s = str(len(self._fractures)) + ' fractures are split into ' + s += str(len(d['polygons'])) + ' polygons \n' + + s += 'Number of points: ' + str(d['points'].shape[1]) + '\n' + s += 'Number of edges: ' + str(d['edges'].shape[1]) + '\n' + + if verbose > 1: + # Compute minimum distance between points in point set + dist = np.inf + p = d['points'] + num_points = p.shape[1] + hit = np.ones(num_points, dtype=np.bool) + for i in range(num_points): + hit[i] = False + dist_loc = cg.dist_point_pointset(p[:, i], p[:, hit]) + dist = np.minimum(dist, dist_loc.min()) + hit[i] = True + s += 'Minimal disance between points ' + str(dist) + '\n' + + if do_print: + print(s) + + return s + + def fractures_of_points(self, pts): + """ + For a given point, find all fractures that refer to it, either as + vertex or as internal. + + Returns: + list of np.int: indices of fractures, one list item per point. + """ + fracs_of_points = [] + pts = np.atleast_1d(np.asarray(pts)) + for i in pts: + fracs_loc = [] + + # First identify edges that refers to the point + edge_ind = np.argwhere(np.any(self.decomposition['edges'][:2]\ + == i, axis=0)).ravel('F') + edges_loc = self.decomposition['edges'][:, edge_ind] + # Loop over all polygons. If their edges are found in edges_loc, + # store the corresponding fracture index + for poly_ind, poly in enumerate(self.decomposition['polygons']): + ismem, _ = setmembership.ismember_rows(edges_loc, poly) + if any(ismem): + fracs_loc.append(self.decomposition['polygon_frac']\ + [poly_ind]) + fracs_of_points.append(list(np.unique(fracs_loc))) + return fracs_of_points + + def close_points(self, dist): + """ + In the set of points used to describe the fractures (after + decomposition), find pairs that are closer than a certain distance. + + Parameters: + dist (double): Threshold distance, all points closer than this will + be reported. + + Returns: + List of tuples: Each tuple contain indices of a set of close + points, and the distance between the points. The list is not + symmetric; if (a, b) is a member, (b, a) will not be. + + """ + c_points = [] + + pt = self.decomposition['points'] + for pi in range(pt.shape[1]): + d = cg.dist_point_pointset(pt[:, pi], pt[:, pi+1:]) + ind = np.argwhere(d < dist).ravel('F') + for i in ind: + # Indices of close points, with an offset to compensate for + # slicing of the point cloud. + c_points.append((pi, i + pi + 1, d[i])) + + return c_points + + def _verify_fractures_in_plane(self, p, edges, edges_2_frac): + """ + Essentially a debugging method that verify that the given set of + points, edges and edge connections indeed form planes. + + This has turned out to be a common symptom of trouble. + + """ + for fi, frac in enumerate(self._fractures): + + # Identify the edges associated with this fracture + edges_loc_ind = [] + for ei, e in enumerate(edges_2_frac): + if np.any(e == fi): + edges_loc_ind.append(ei) + + edges_loc = edges[:, edges_loc_ind] + p_ind_loc = np.unique(edges_loc) + p_loc = p[:, p_ind_loc] + + p_2d, edges_2d, p_loc_c, rot \ + = self._points_2_plane(p_loc, edges_loc, p_ind_loc) + + + def _points_2_plane(self, p_loc, edges_loc, p_ind_loc): + """ + Convenience method for rotating a point cloud into its own 2d-plane. + """ + + # Center point cloud around the origin + p_loc_c = np.mean(p_loc, axis=1).reshape((-1, 1)) + p_loc -= p_loc_c + + # Project the points onto the local plane defined by the fracture + rot = cg.project_plane_matrix(p_loc) + p_2d = rot.dot(p_loc) + + scaling = max(p_loc.max(axis=1) - p_loc.min(axis=1)) + assert np.max(np.abs(p_2d[2])) < 2*self.tol * np.sqrt(3)# * scaling + # Dump third coordinate + p_2d = p_2d[:2] + + # The edges must also be redefined to account for the (implicit) + # local numbering of points + edges_2d = np.empty_like(edges_loc) + for ei in range(edges_loc.shape[1]): + edges_2d[0, ei] = np.argwhere(p_ind_loc == edges_loc[0, ei]) + edges_2d[1, ei] = np.argwhere(p_ind_loc == edges_loc[1, ei]) + + assert edges_2d[:2].max() < p_loc.shape[1] + + return p_2d, edges_2d, p_loc_c, rot + + def change_tolerance(self, new_tol): + """ + Redo the whole configuration based on the new tolerance + """ + pass + + def __repr__(self): + s = 'Fracture set with ' + str(len(self._fractures)) + ' fractures' + return s + + def plot_fractures(self, ind=None): + if ind is None: + ind = np.arange(len(self._fractures)) + fig = plt.figure() + ax = fig.gca(projection='3d') + for f in self._fractures: + f.plot_frame(ax) + return fig + + def compute_distances(self): + nf = len(self._fractures) + + # Distance between vertexes + p_2_p = np.zeros((nf, nf)) + # Distance between segments + s_2_s = np.zeros((nf, nf)) + + for i, f_1 in enumerate(self._fractures): + for j, f_2 in enumerate(self._fractures[i + 1:]): + d = np.Inf + for p_1 in f_1.points(): + for p_2 in f_2.points(): + d = np.minimum(d, cg.dist_point_pointset(p_1, p_2)[0]) + p_2_p[i, i + j + 1] = d + + d = np.Inf + for s_1 in f_1.segments(): + for s_2 in f_2.segments(): + d = np.minimum(d, + cg.distance_segment_segment(s_1[:, 0], + s_1[:, 1], + s_2[:, 0], + s_2[:, 1])) + s_2_s[i, i + j + 1] = d + + return p_2_p, s_2_s + + + def impose_external_boundary(self, box, truncate_fractures=True): + """ + Set an external boundary for the fracture set. + + The boundary takes the form of a 3D box, described by its minimum and + maximum coordinates. + + If desired, the fratures will be truncated to lay within the bounding + box; that is, Fracture.p will be modified. The orginal coordinates of + the fracture boundary can still be recovered from the attribute + Fracture.orig_points. + + Fractures that are completely outside the bounding box will be deleted + from the fracture set. + + Parameters: + box (dictionary): Has fields 'xmin', 'xmax', and similar for y and + z. + truncate_fractures (boolean, optional): If True, fractures outside + the bounding box will be disregarded, while fractures crossing the + boundary will be truncated. + + """ + # Insert boundary in the form of a box, and kick out (parts of) + # fractures outside the box + self.domain = box + + if truncate_fractures: + # Keep track of fractures that are completely outside the domain. + # These will be deleted. + delete_frac = [] + + # Loop over all fractures, use method in fractures to truncate if + # necessary. + for i, frac in enumerate(self._fractures): + frac.impose_boundary(box, self.tol) + if frac.p.shape[1] == 0: + delete_frac.append(i) + + # Delete fractures that have all points outside the bounding box + # There may be some uncovered cases here, with a fracture barely + # touching the box from the outside, but we leave that for now. + for i in np.unique(delete_frac)[::-1]: + del self._fractures[i] + + # Final sanity check: All fractures should have at least three + # points at the end of the manipulations + for f in self._fractures: + assert f.p.shape[1] >= 3 + + + def _classify_edges(self, polygon_edges): + """ + Classify the edges into fracture boundary, intersection, or auxiliary. + Also identify points on intersections between interesctions (fractures + of co-dimension 3) + + Parameters: + polygon_edges (list of lists): For each polygon the global edge + indices that forms the polygon boundary. + + Returns: + tag: Tag of the fracture, using the values in GmshConstants. Note + that auxiliary points will not be tagged (these are also + ignored in gmsh_interface.GmshWriter). + is_0d_grid: boolean, one for each point. True if the point is + shared by two or more intersection lines. + + """ + edges = self.decomposition['edges'] + is_bound = self.decomposition['is_bound'] + num_edges = edges.shape[1] + + poly_2_frac = self.decomposition['polygon_frac'] + + # Construct a map from edges to polygons + edge_2_poly = [[] for i in range(num_edges)] + for pi, poly in enumerate(polygon_edges[0]): + for ei in np.unique(poly): + edge_2_poly[ei].append(poly_2_frac[pi]) + + # Count the number of referals to the edge from polygons belonging to + # different fractures (not polygons) + num_referals = np.zeros(num_edges) + for ei, ep in enumerate(edge_2_poly): + num_referals[ei] = np.unique(np.array(ep)).size + + # A 1-d grid is inserted where there is more than one fracture + # referring. + has_1d_grid = np.where(num_referals > 1)[0] + + num_constraints = len(is_bound) + constants = GmshConstants() + tag = np.zeros(num_edges, dtype='int') + + # Find fractures that are tagged as a boundary + bound_ind = np.where(is_bound)[0] + # Remove those that are referred to by more than fracture - this takes + # care of L-type intersections + bound_ind = np.setdiff1d(bound_ind, has_1d_grid) + + # Index of lines that should have a 1-d grid. This are all of the first + # num-constraints, minus those on the boundary. + # Note that edges with index > num_constraints are known to be of the + # auxiliary type. These will have tag zero; and treated in a special + # manner by the interface to gmsh. + intersection_ind = np.setdiff1d(np.arange(num_constraints), bound_ind) + tag[bound_ind] = constants.FRACTURE_TIP_TAG + tag[intersection_ind] = constants.FRACTURE_INTERSECTION_LINE_TAG + + # Count the number of times a point is referred to by an intersection + # between two fractures. If this is more than one, the point should + # have a 0-d grid assigned to it. + isect_p = edges[:, intersection_ind].ravel() + num_occ_pt = np.bincount(isect_p) + is_0d_grid = np.where(num_occ_pt > 1)[0] + + return tag, is_0d_grid + + def _poly_2_segment(self): + """ + Represent the polygons by the global edges, and determine if the lines + must be reversed (locally) for the polygon to form a closed loop. + + """ + edges = self.decomposition['edges'] + poly = self.decomposition['polygons'] + + poly_2_line = [] + line_reverse = [] + for p in poly: + hit, ind = setmembership.ismember_rows(p, edges[:2], sort=False) + hit_reverse, ind_reverse = setmembership.ismember_rows( + p[::-1], edges[:2], sort=False) + assert np.all(hit + hit_reverse == 1) + + line_ind = np.zeros(p.shape[1]) + + hit_ind = np.where(hit)[0] + hit_reverse_ind = np.where(hit_reverse)[0] + line_ind[hit_ind] = ind + line_ind[hit_reverse_ind] = ind_reverse + + poly_2_line.append(line_ind.astype('int')) + line_reverse.append(hit_reverse) + + return poly_2_line, line_reverse + + def _determine_mesh_size(self, **kwargs): + """ + Set the preferred mesh size for geometrical points as specified by + gmsh. + + Currently, the only option supported is to specify a single value for + all fracture points, and one value for the boundary. + + See the gmsh manual for further details. + + """ + mode = kwargs.get('mode', 'constant') + + num_pts = self.decomposition['points'].shape[1] + + if mode == 'constant': + val = kwargs.get('value', None) + bound_val = kwargs.get('bound_value', None) + if val is not None: + mesh_size = val * np.ones(num_pts) + else: + mesh_size = None + if bound_val is not None: + mesh_size_bound = bound_val + else: + mesh_size_bound = None + return mesh_size, mesh_size_bound + else: + raise ValueError('Unknown mesh size mode ' + mode) + + def distance_point_segment(): + pass + + def distance_segment_segment(): + # What happens if two segments are close? + pass + + def proximate_fractures(self): + # Find fractures with center distance less than the sum of their major + # axis + 2 * force length + return fracture_pairs + + def to_vtk(self, file_name, data=None, binary=True): + """ + Export the fracture network to vtk. + + The fractures are treated as polygonal cells, with no special treatment + of intersections. + + Fracture numbers are always exported (1-offset). In addition, it is + possible to export additional data, as specified by the + keyword-argument data. + + Parameters: + file_name (str): Name of the target file. + data (dictionary, optional): Data associated with the fractures. + The values in the dictionary should be numpy arrays. 1d and 3d + data is supported. Fracture numbers are always exported. + binary (boolean, optional): Use binary export format. Defaults to + True. + + """ + network_vtk = vtk.vtkUnstructuredGrid() + + point_counter = 0 + pts_vtk = vtk.vtkPoints() + for f in self._fractures: + # Add local points + [pts_vtk.InsertNextPoint(*p) for p in f.p.T] + + # Indices of local points + loc_pt_id = point_counter + np.arange(f.p.shape[1], + dtype='int') + # Update offset + point_counter += f.p.shape[1] + + # Add bounding polygon + frac_vtk = vtk.vtkIdList() + [frac_vtk.InsertNextId(p) for p in loc_pt_id] + # Close polygon + frac_vtk.InsertNextId(loc_pt_id[0]) + + network_vtk.InsertNextCell(vtk.VTK_POLYGON, frac_vtk) + + # Add the points + network_vtk.SetPoints(pts_vtk) + + writer = vtk.vtkXMLUnstructuredGridWriter() + writer.SetInputData(network_vtk) + writer.SetFileName(file_name) + + if not binary: + writer.SetDataModeToAscii() + + # Cell-data to be exported is at least the fracture numbers + if data is None: + data = {} + # Use offset 1 for fracture numbers (should we rather do 0?) + frac_num = {'Fracture Number': 1 + np.arange(len(self._fractures))} + data = {**data, **frac_num} + + for name, data in data.items(): + data_vtk = vtk_np.numpy_to_vtk(data.ravel(order='F'), deep=True, + array_type=vtk.VTK_DOUBLE) + data_vtk.SetName(name) + data_vtk.SetNumberOfComponents(1 if data.ndim == 1 else 3) + network_vtk.GetCellData().AddArray(data_vtk) + + writer.Update() + + def to_gmsh(self, file_name, **kwargs): + p = self.decomposition['points'] + edges = self.decomposition['edges'] + + poly = self._poly_2_segment() + + edge_tags, intersection_points = self._classify_edges(poly) + edges = np.vstack((self.decomposition['edges'], edge_tags)) + + self.zero_d_pt = intersection_points + + if 'mesh_size' in kwargs.keys(): + mesh_size, mesh_size_bound = \ + determine_mesh_size(self.decomposition['points'].shape[1], + **kwargs['mesh_size']) + else: + mesh_size = None + mesh_size_bound = None + + # The tolerance applied in gmsh should be consistent with the tolerance + # used in the splitting of the fracture network. The documentation of + # gmsh is not clear, but it seems gmsh scales a given tolerance with + # the size of the domain - presumably by the largest dimension. To + # counteract this, we divide our (absolute) tolerance self.tol with the + # domain size. + dx = np.array([[self.domain['xmax'] - self.domain['xmin']], + [self.domain['ymax'] - self.domain['ymin']], + [self.domain['zmax'] - self.domain['zmin']]]) + gmsh_tolerance = self.tol / dx.max() + + writer = GmshWriter(p, edges, polygons=poly, domain=self.domain, + intersection_points=intersection_points, + mesh_size_bound=mesh_size_bound, + mesh_size=mesh_size, tolerance=gmsh_tolerance) + writer.write_geo(file_name) diff --git a/src/porepy/fracs/meshing.py b/src/porepy/fracs/meshing.py new file mode 100644 index 0000000000..a0857ad0e1 --- /dev/null +++ b/src/porepy/fracs/meshing.py @@ -0,0 +1,245 @@ +""" +Main module for grid generation in fractured domains in 2d and 3d. + +The module serves as the only neccessary entry point to create the grid. It +will therefore wrap interface to different mesh generators, pass options to the +generators etc. + +""" +import numpy as np +import scipy.sparse as sps + +from gridding.fractured import structured, simplex, split_grid +from gridding.grid_bucket import GridBucket +from utils import setmembership, mcolon +from core.grids.grid import FaceTag + + +def simplex_grid(fracs, domain, **kwargs): + """ + Main function for grid generation. + + Parameters: + fracs (list of np.ndarray): One list item for each fracture. Each item + consist of a (nd x n) array describing fracture vertices. The + fractures may be intersecting. + domain (dict): Domain specification, determined by xmin, xmax, ... + **kwargs: May contain fracture tags, options for gridding, etc. + Returns: + GridBucket: A complete bucket where all fractures are represented as + lower dim grids. The higher dim faces are split in two, and on the + edges of the GridBucket graph the mapping from lower dim cells to + higher dim faces are stored as 'face_cells'. Each face is given a + FaceTag depending on the type: + NONE: None of the below (i.e. an internal face) + DOMAIN_BOUNDARY: All faces that lie on the domain boundary + (i.e. should be given a boundary condition). + FRACTURE: All faces that are split (i.e. has a connection to a + lower dim grid). + TIP: A boundary face that is not on the domain boundary, nor + coupled to a lower domentional domain. + """ + if 'zmax' in domain: + ndim = 3 + elif 'ymax' in domain: + ndim = 2 + else: + raise ValueError('simplex_grid only supported for 2 or 3 dimensions') + + # Call relevant method, depending on grid dimensions. + if ndim == 2: + # Convert the fracture to a fracture dictionary. + if len(fracs) == 0: + f_lines = np.zeros((2, 0)) + f_pts = np.zeros((2, 0)) + else: + f_lines = np.reshape(np.arange(2 * len(fracs)), (2, -1), order='F') + f_pts = np.hstack(fracs) + frac_dic = {'points': f_pts, 'edges': f_lines} + print(frac_dic) + grids = simplex.triangle_grid(frac_dic, domain, **kwargs) + elif ndim == 3: + grids = simplex.tetrahedral_grid(fracs, domain, **kwargs) + else: + raise ValueError('Only support for 2 and 3 dimensions') + # Tag tip faces + tag_faces(grids) + + # Assemble grids in a bucket + gb = assemble_in_bucket(grids) + gb.compute_geometry() + # Split the grids. + split_grid.split_fractures(gb) + return gb + + +def cart_grid(fracs, nx, **kwargs): + """ + Creates a tensor fractured GridBucket. + + Parameters: + fracs (list of np.ndarray): One list item for each fracture. Each item + consist of a (nd x 3) array describing fracture vertices. The + fractures has to be rectangles(3D) or straight lines(2D) that + alignes with the axis. The fractures may be intersecting. + The fractures will snap to closest grid faces. + nx (np.ndarray): Number of cells in each direction. Should be 2D or 3D + kwargs: + physdims (np.ndarray): Physical dimensions in each direction. + Defaults to same as nx, that is, cells of unit size. + offset (float): defaults to 0. Will perturb the nodes around the + faces that are split. NOTE: this is only for visualization. + E.g., the face centers are not perturbed. + Returns: + GridBucket: A complete bucket where all fractures are represented as + lower dim grids. The higher dim faces are split in two, and on the + edges of the GridBucket graph the mapping from lower dim cells to + higher dim faces are stored as 'face_cells' + """ + ndim = np.asarray(nx).size + offset = kwargs.get('offset', 0) + physdims = kwargs.get('physdims', None) + + if physdims is None: + physdims = nx + elif np.asarray(physdims).size != ndim: + raise ValueError('Physical dimension must equal grid dimension') + + # Call relevant method, depending on grid dimensions + if ndim == 2: + grids = structured.cart_grid_2d(fracs, nx, physdims=physdims) + elif ndim == 3: + grids = structured.cart_grid_3d(fracs, nx, physdims=physdims) + else: + raise ValueError('Only support for 2 and 3 dimensions') + + # Tag tip faces. + tag_faces(grids) + + # Asemble in bucket + gb = assemble_in_bucket(grids) + gb.compute_geometry() + + # Split grid. + split_grid.split_fractures(gb, **kwargs) + return gb + + +def tag_faces(grids): + # Assume only one grid of highest dimension + assert len(grids[0]) == 1, 'Must be exactly'\ + '1 grid of dim: ' + str(len(grids)) + g_h = grids[0][0] + bnd_faces = g_h.get_boundary_faces() + g_h.add_face_tag(bnd_faces, FaceTag.DOMAIN_BOUNDARY) + bnd_nodes, _, _ = sps.find(g_h.face_nodes[:, bnd_faces]) + bnd_nodes = np.unique(bnd_nodes) + for g_dim in grids[1:-1]: + for g in g_dim: + # We find the global nodes of all boundary faces + bnd_faces_l = g.get_boundary_faces() + indptr = g.face_nodes.indptr + fn_loc = mcolon.mcolon( + indptr[bnd_faces_l], indptr[bnd_faces_l + 1] - 1) + nodes_loc = g.face_nodes.indices[fn_loc] + # Convert to global numbering + nodes_glb = g.global_point_ind[nodes_loc] + # We then tag each node as a tip node if it is not a global + # boundary node + is_tip = np.in1d(nodes_glb, bnd_nodes, invert=True) + # We reshape the nodes such that each column equals the nodes of + # one face. If a face only contains global boundary nodes, the + # local face is also a boundary face. Otherwise, we add a TIP tag. + nodes_per_face = find_nodes_per_face(g) + is_tip = np.any(is_tip.reshape( + (nodes_per_face, bnd_faces_l.size), order='F'), axis=0) + g.add_face_tag(bnd_faces_l[is_tip], FaceTag.TIP) + g.add_face_tag(bnd_faces_l[is_tip == False], + FaceTag.DOMAIN_BOUNDARY) + + +def find_nodes_per_face(g): + if 'TensorGrid'in g.name and g.dim == 3: + nodes_per_face = 4 + elif 'TetrahedralGrid' in g.name: + nodes_per_face = 3 + elif 'TensorGrid'in g.name and g.dim == 2: + nodes_per_face = 2 + elif 'TriangleGrid'in g.name: + nodes_per_face = 2 + elif 'TensorGrid' in g.name and g.dim == 1: + nodes_per_face = 1 + else: + raise ValueError( + "Can not find number of nodes per face for grid: " + str(g.name)) + return nodes_per_face + + +def assemble_in_bucket(grids): + """ + Create a GridBucket from a list of grids. + Parameters: + grids: A list of lists of grids. Each element in the list is a list + of all grids of a the same dimension. It is assumed that the + grids are sorted from high dimensional grids to low dimensional grids. + All grids must also have the mapping g.global_point_ind which maps + the local nodes of the grid to the nodes of the highest dimensional + grid. + Returns: + GridBucket: A GridBucket class where the mapping face_cells are given to + each edge. face_cells maps from lower-dim cells to higher-dim faces. + """ + + # Create bucket + bucket = GridBucket() + [bucket.add_nodes(g_d) for g_d in grids] + + # We now find the face_cell mapings. + for dim in range(len(grids) - 1): + for hg in grids[dim]: + # We have to specify the number of nodes per face to generate a + # matrix of the nodes of each face. + nodes_per_face = find_nodes_per_face(hg) + fn_loc = hg.face_nodes.indices.reshape((nodes_per_face, hg.num_faces), + order='F') + # Convert to global numbering + fn = hg.global_point_ind[fn_loc] + fn = np.sort(fn, axis=0) + + for lg in grids[dim + 1]: + cell_2_face, cell = obtain_interdim_mappings( + lg, fn, nodes_per_face) + face_cells = sps.csc_matrix( + (np.array([True] * cell.size), (cell, cell_2_face)), + (lg.num_cells, hg.num_faces)) + + # This if may be unnecessary, but better safe than sorry. + if face_cells.size > 0: + bucket.add_edge([hg, lg], face_cells) + + return bucket + + +def obtain_interdim_mappings(lg, fn, nodes_per_face): + # Next, find mappings between faces in one dimension and cells in the lower + # dimension + if lg.dim > 0: + cn_loc = lg.cell_nodes().indices.reshape((nodes_per_face, + lg.num_cells), + order='F') + cn = lg.global_point_ind[cn_loc] + cn = np.sort(cn, axis=0) + else: + cn = np.array([lg.global_point_ind]) + # We also know that the higher-dimensional grid has faces + # of a single node. This sometimes fails, so enforce it. + if cn.ndim == 1: + fn = fn.ravel() + is_mem, cell_2_face = setmembership.ismember_rows( + cn.astype(np.int32), fn.astype(np.int32), sort=False) + # An element in cell_2_face gives, for all cells in the + # lower-dimensional grid, the index of the corresponding face + # in the higher-dimensional structure. + + low_dim_cell = np.where(is_mem)[0] + return cell_2_face, low_dim_cell diff --git a/src/porepy/fracs/simplex.py b/src/porepy/fracs/simplex.py new file mode 100644 index 0000000000..f7829c018a --- /dev/null +++ b/src/porepy/fracs/simplex.py @@ -0,0 +1,320 @@ +import numpy as np +import time + +from gridding.gmsh import gmsh_interface, mesh_io, mesh_2_grid +from gridding import constants +from gridding.fractured import fractures, utils +import compgeom.basics as cg + + +def tetrahedral_grid(fracs=None, box=None, network=None, **kwargs): + """ + Create grids for a domain with possibly intersecting fractures in 3d. + + Based on the specified fractures, the method computes fracture + intersections if necessary, creates a gmsh input file, runs gmsh and reads + the result, and then constructs grids in 3d (the whole domain), 2d (one for + each individual fracture), 1d (along fracture intersections), and 0d + (meeting between intersections). + + The fractures can be specified is terms of the keyword 'fracs' (either as + numpy arrays or Fractures, see below), or as a ready-made FractureNetwork + by the keyword 'network'. For fracs, the boundary of the domain must be + specified as well, by 'box'. For a ready network, the boundary will be + imposed if provided. For a network will use pre-computed intersection and + decomposition if these are available (attributes 'intersections' and + 'decomposition'). + + TODO: The method finds the mapping between faces in one dimension and cells + in a lower dimension, but the information is not used. Should be + returned in a sensible format. + + Parameters: + fracs (list, optional): List of either pre-defined fractures, or + np.ndarrays, (each 3xn) of fracture vertexes. + box (dictionary, optional). Domain specification. Should have keywords + xmin, xmax, ymin, ymax, zmin, zmax. + network (fractures.FractureNetwork, optional): A FractureNetwork + containing fractures. + + The fractures should be specified either by a combination of fracs and + box, or by network (possibly combined with box). See above. + + **kwargs: To be explored. Should contain the key 'gmsh_path'. + + Returns: + list (length 4): For each dimension (3 -> 0), a list of all grids in + that dimension. + + """ + + # Verbosity level + verbose = kwargs.get('verbose', 1) + + # File name for communication with gmsh + file_name = kwargs.pop('file_name', 'gmsh_frac_file') + + if network is None: + + frac_list = [] + for f in fracs: + if isinstance(f, fractures.Fracture): + frac_list.append(f) + else: + # Convert the fractures from numpy representation to our 3D + # fracture data structure.. + frac_list.append(fractures.Fracture(f)) + + # Combine the fractures into a network + network = fractures.FractureNetwork(frac_list, verbose=verbose, + tol=kwargs.get('tol', 1e-4)) + + # Impose domain boundary. + if box is not None: + network.impose_external_boundary(box) + + # Find intersections and split them, preparing the way for dumping the + # network to gmsh + if not network.has_checked_intersections: + network.find_intersections() + else: + print('Use existing intersections') + if not hasattr(network, 'decomposition'): + network.split_intersections() + else: + print('Use existing decomposition') + + in_file = file_name + '.geo' + out_file = file_name + '.msh' + + network.to_gmsh(in_file, **kwargs) + gmsh_path = kwargs.get('gmsh_path') + + gmsh_opts = kwargs.get('gmsh_opts', {}) + gmsh_verbose = kwargs.get('gmsh_verbose', verbose) + gmsh_opts['-v'] = gmsh_verbose + gmsh_status = gmsh_interface.run_gmsh(gmsh_path, in_file, out_file, dims=3, + **gmsh_opts) + + if verbose > 0: + start_time = time.time() + if gmsh_status == 0: + print('Gmsh processed file successfully') + else: + print('Gmsh failed with status ' + str(gmsh_status)) + + pts, cells, phys_names, cell_info = gmsh_interface.read_gmsh(out_file) + + # Call upon helper functions to create grids in various dimensions. + # The constructors require somewhat different information, reflecting the + # different nature of the grids. + g_3d = mesh_2_grid.create_3d_grids(pts, cells) + g_2d = mesh_2_grid.create_2d_grids( + pts, cells, is_embedded=True, phys_names=phys_names, + cell_info=cell_info, network=network) + g_1d, _ = mesh_2_grid.create_1d_grids(pts, cells, phys_names, cell_info) + g_0d = mesh_2_grid.create_0d_grids(pts, cells) + + grids = [g_3d, g_2d, g_1d, g_0d] + + if verbose > 0: + print('\n') + print('Grid creation completed. Elapsed time ' + str(time.time() - + start_time)) + print('\n') + for g_set in grids: + if len(g_set) > 0: + s = 'Created ' + str(len(g_set)) + ' ' + str(g_set[0].dim) + \ + '-d grids with ' + num = 0 + for g in g_set: + num += g.num_cells + s += str(num) + ' cells' + print(s) + print('\n') + + return grids + + +def triangle_grid(fracs, domain, **kwargs): + """ + Generate a gmsh grid in a 2D domain with fractures. + + The function uses modified versions of pygmsh and mesh_io, + both downloaded from github. + + To be added: + Functionality for tuning gmsh, including grid size, refinements, etc. + + Examples + >>> p = np.array([[-1, 1, 0, 0], [0, 0, -1, 1]]) + >>> lines = np.array([[0, 2], [1, 3]]) + >>> char_h = 0.5 * np.ones(p.shape[1]) + >>> tags = np.array([1, 3]) + >>> fracs = {'points': p, 'edges': lines} + >>> box = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2} + >>> path_to_gmsh = '~/gmsh/bin/gmsh' + >>> g = create_grid(fracs, box, gmsh_path=path_to_gmsh) + >>> plot_grid.plot_grid(g) + + Parameters + ---------- + fracs: (dictionary) Two fields: points (2 x num_points) np.ndarray, + lines (2 x num_lines) connections between points, defines fractures. + box: (dictionary) keys xmin, xmax, ymin, ymax, [together bounding box + for the domain] + **kwargs: To be explored. Must contain the key 'gmsh_path' + Returns + ------- + list (length 3): For each dimension (2 -> 0), a list of all grids in + that dimension. + """ + # Verbosity level + verbose = kwargs.get('verbose', 1) + + # File name for communication with gmsh + file_name = kwargs.get('file_name', 'gmsh_frac_file') + + in_file = file_name + '.geo' + out_file = file_name + '.msh' + + # Pick out fracture points, and their connections + frac_pts = fracs['points'] + frac_con = fracs['edges'] + + # Unified description of points and lines for domain, and fractures + pts_all, lines = __merge_domain_fracs_2d(domain, frac_pts, frac_con) + + # We split all fracture intersections so that the new lines do not + # intersect, except possible at the end points + dx = np.array( + [[domain['xmax'] - domain['xmin']], [domain['ymax'] - domain['ymin']]]) + pts_split, lines_split = cg.remove_edge_crossings( + pts_all, lines, box=dx) + # We find the end points that is shared by more than one intersection + intersections = __find_intersection_points(lines_split) + + # Constants used in the gmsh.geo-file + const = constants.GmshConstants() + # Gridding size + if 'mesh_size' in kwargs.keys(): + mesh_size, mesh_size_bound = \ + utils.determine_mesh_size( + pts_split.shape[1], **kwargs['mesh_size']) + else: + mesh_size = None + mesh_size_bound = None + + # gmsh options + gmsh_path = kwargs.get('gmsh_path') + + gmsh_verbose = kwargs.get('gmsh_verbose', verbose) + gmsh_opts = {'-v': gmsh_verbose} + + # Create a writer of gmsh .geo-files + gw = gmsh_interface.GmshWriter( + pts_split, lines_split, domain=domain, mesh_size=mesh_size, + mesh_size_bound=mesh_size_bound, intersection_points=intersections) + gw.write_geo(in_file) + + # Run gmsh + gmsh_status = gmsh_interface.run_gmsh(gmsh_path, in_file, out_file, dims=2, + **gmsh_opts) + + if verbose > 0: + start_time = time.time() + if gmsh_status == 0: + print('Gmsh processed file successfully') + else: + print('Gmsh failed with status ' + str(gmsh_status)) + + pts, cells, phys_names, cell_info = mesh_io.read(out_file) + + # Create grids from gmsh mesh. + g_2d = mesh_2_grid.create_2d_grids(pts, cells, is_embedded=False) + g_1d, _ = mesh_2_grid.create_1d_grids( + pts, cells, phys_names, cell_info, line_tag=const.PHYSICAL_NAME_FRACTURES) + g_0d = mesh_2_grid.create_0d_grids(pts, cells) + grids = [g_2d, g_1d, g_0d] + + if verbose > 0: + print('\n') + print('Grid creation completed. Elapsed time ' + str(time.time() - + start_time)) + print('\n') + for g_set in grids: + if len(g_set) > 0: + s = 'Created ' + str(len(g_set)) + ' ' + str(g_set[0].dim) + \ + '-d grids with ' + num = 0 + for g in g_set: + num += g.num_cells + s += str(num) + ' cells' + print(s) + print('\n') + + return grids + + +def __merge_domain_fracs_2d(dom, frac_p, frac_l): + """ + Merge fractures, domain boundaries and lines for compartments. + The unified description is ready for feeding into meshing tools such as + gmsh + + Parameters: + dom: dictionary defining domain. fields xmin, xmax, ymin, ymax + frac_p: np.ndarray. Points used in fracture definition. 2 x num_points. + frac_l: np.ndarray. Connection between fracture points. 2 x num_fracs + + returns: + p: np.ndarary. Merged list of points for fractures, compartments and domain + boundaries. + l: np.ndarray. Merged list of line connections (first two rows), tag + identifying which type of line this is (third row), and a running index + for all lines (fourth row) + """ + + # Use constants set outside. If we ever + const = constants.GmshConstants() + + # First create lines that define the domain + x_min = dom['xmin'] + x_max = dom['xmax'] + y_min = dom['ymin'] + y_max = dom['ymax'] + dom_p = np.array([[x_min, x_max, x_max, x_min], + [y_min, y_min, y_max, y_max]]) + dom_lines = np.array([[0, 1], [1, 2], [2, 3], [3, 0]]).T + + num_dom_lines = dom_lines.shape[1] # Should be 4 + + # The lines will have all fracture-related tags set to zero. + # The plan is to ignore these tags for the boundary and compartments, + # so it should not matter + dom_tags = const.DOMAIN_BOUNDARY_TAG * np.ones((1, num_dom_lines)) + dom_l = np.vstack((dom_lines, dom_tags)) + + # Also add a tag to the fractures, signifying that these are fractures + frac_l = np.vstack((frac_l, + const.FRACTURE_TAG * np.ones(frac_l.shape[1]))) + + # Merge the point arrays, compartment points first + p = np.hstack((dom_p, frac_p)) + + # Adjust index of fracture points to account for the compart points + frac_l[:2] += dom_p.shape[1] + + l = np.hstack((dom_l, frac_l)).astype('int') + + # Add a second tag as an identifier of each line. + l = np.vstack((l, np.arange(l.shape[1]))) + + return p, l + + +def __find_intersection_points(lines): + const = constants.GmshConstants() + frac_id = np.ravel(lines[:2, lines[2] == const.FRACTURE_TAG]) + _, ia, count = np.unique(frac_id, True, False, True) + return frac_id[ia[count > 1]] diff --git a/src/porepy/fracs/split_grid.py b/src/porepy/fracs/split_grid.py new file mode 100644 index 0000000000..4ef55cbe6e --- /dev/null +++ b/src/porepy/fracs/split_grid.py @@ -0,0 +1,523 @@ +""" +Module for splitting a grid at the fractures. +""" + +import numpy as np +import matplotlib.pylab as plt +from scipy import sparse as sps +from utils.half_space import half_space_int +from utils.graph import Graph +from utils.mcolon import mcolon +from core.grids.grid import Grid, FaceTag + + +def split_fractures(bucket, **kwargs): + """ + Wrapper function to split all fractures. For each grid in the bucket, + we locate the corresponding lower-dimensional grids. The faces and + nodes corresponding to these grids are then split, creating internal + boundaries. + + Parameters + ---------- + bucket - A grid bucket + **kwargs: + offset - FLOAT, defaults to 0. Will perturb the nodes around the + faces that are split. NOTE: this is only for visualization. + E.g., the face centers are not perturbed. + + Returns + ------- + bucket - A valid bucket where the faces are split at + internal boundaries. + + + Examples + >>> import numpy as np + >>> from gridding.fractured import meshing, split_grid + >>> from viz.exporter import export_vtk + >>> + >>> f_1 = np.array([[-1, 1, 1, -1 ], [0, 0, 0, 0], [-1, -1, 1, 1]]) + >>> f_2 = np.array([[0, 0, 0, 0], [-1, 1, 1, -1 ], [-.7, -.7, .8, .8]]) + >>> f_set = [f_1, f_2] + >>> domain = {'xmin': -2, 'xmax': 2, + 'ymin': -2, 'ymax': 2, 'zmin': -2, 'zmax': 2} + >>> # SET TO GMSH PATH + >>> path_to_gmsh = '~/gmsh/bin/gmsh' + >>> bucket = meshing.create_grid(f_set, domain, gmsh_path=path_to_gmsh) + >>> [g.compute_geometry(is_embedded=True) for g,_ in bucket] + >>> + >>> split_grid.split_fractures(bucket, offset=0.1) + >>> export_vtk(bucket, "grid") + """ + + offset = kwargs.get('offset', 0) + + # For each vertex in the bucket we find the corresponding lower- + # dimensional grids. + for gh, _ in bucket: + if gh.dim < 1: + # Nothing to do. We can not split 0D grids. + continue + # Find connected vertices and corresponding edges. + neigh = np.array(bucket.node_neighbors(gh)) + + # Find the neighbours that are lower dimensional + is_low_dim_grid = np.where([w.dim < gh.dim + for w in neigh]) + edges = [(gh, w) for w in neigh[is_low_dim_grid]] + if len(edges) == 0: + # No lower dim grid. Nothing to do. + continue + face_cells = bucket.edge_prop(edges, 'face_cells') + + # We split all the faces that are connected to a lower-dim grid. + # The new faces will share the same nodes and properties (normals, + # etc.) + + face_cells = split_faces(gh, face_cells) + bucket.add_edge_prop('face_cells', edges, face_cells) + + # We now find which lower-dim nodes correspond to which higher- + # dim nodes. We split these nodes according to the topology of + # the connected higher-dim cells. At a X-intersection we split + # the node into four, while at the fracture boundary it is not split. + + gl = [e[1] for e in edges] + gl_2_gh_nodes = [bucket.target_2_source_nodes( + g, gh) for g in gl] + + split_nodes(gh, gl, gl_2_gh_nodes, offset) + + # Remove zeros from cell_faces + + [g.cell_faces.eliminate_zeros() for g, _ in bucket] + return bucket + + +def split_faces(gh, face_cells): + """ + Split faces of the grid along each fracture. This function will + add an extra face to each fracture face. Note that the original + and new fracture face will share the same nodes. However, the + cell_faces connectivity is updated such that the fractures are + be internal boundaries (cells on left side of fractures are not + connected to cells on right side of fracture and vise versa). + The face_cells are updated such that the copy of a face also + map to the same lower-dim cell. + """ + for i in range(len(face_cells)): + # We first we duplicate faces along tagged faces. The duplicate + # faces will share the same nodes as the original faces, + # however, the new faces are not yet added to the cell_faces map + # (to save computation). + face_id = duplicate_faces(gh, face_cells[i]) + face_cells = update_face_cells(face_cells, face_id, i) + + # We now set the cell_faces map based on which side of the + # fractures the cells lie. We assume that all fractures are + # flat surfaces and pick the normal of the first face as + # a normal for the whole fracture. + n = np.reshape(gh.face_normals[:, face_id[0]], (3, 1)) + n = n / np.linalg.norm(n) + x0 = np.reshape(gh.face_centers[:, face_id[0]], (3, 1)) + update_cell_connectivity(gh, face_id, n, x0) + + return face_cells + + +def split_nodes(gh, gl, gh_2_gl_nodes, offset=0): + """ + Splits the nodes of a grid given a set of lower-dimensional grids + and a connection mapping between them. + Parameters + ---------- + gh - Higher-dimension grid. + gl - A list of lower dimensional grids + gh_2_gl_nodes - A list of connection arrays. Each array in the + list gives the mapping from the lower-dim nodes + to the higher dim nodes. gh_2_gl_nodes[0][0] is + the higher-dim index of the first node of the + first lower-dim. + offset - float + Optional, defaults to 0. This gives the offset from the + fracture to the new nodes. Note that this is only for + visualization, e.g., g.face_centers is not updated. + """ + # We find the higher-dim node indices of all lower-dim nodes + nodes = np.array([], dtype=int) + for i in range(len(gl)): + nodes = np.append(nodes, gh_2_gl_nodes[i]) + nodes = np.unique(nodes) + + # Each of these nodes are duplicated dependig on the cell- + # topology of the higher-dim around each node. For a X-intersection + # we get four duplications, for a T-intersection we get three + # duplications, etc. Each of the duplicates are then attached + # to the cells on one side of the fractures. + node_count = duplicate_nodes(gh, nodes, offset) + + # We remove the old nodes. + #gh = remove_nodes(gh, nodes) + + # Update the number of nodes + gh.num_nodes = gh.num_nodes + node_count # - nodes.size + + return True + + +def duplicate_faces(gh, face_cells): + """ + Duplicate all faces that are connected to a lower-dim cell + + Parameters + ---------- + gh - Higher-dim grid + face_cells - A list of connection matrices. Each matrix gives + the mapping from the cells of a lower-dim grid + to the faces of the higher diim grid. + """ + # We find the indices of the higher-dim faces to be duplicated. + # Each of these faces are duplicated, and the duplication is + # attached to the same nodes. We do not attach the faces to + # any cells as this connection will have to be undone later + # anyway. + _, frac_id, _ = sps.find(face_cells) + frac_id = np.unique(frac_id) + node_start = gh.face_nodes.indptr[frac_id] + node_end = gh.face_nodes.indptr[frac_id + 1] + nodes = gh.face_nodes.indices[mcolon(node_start, node_end - 1)] + added_node_pos = np.cumsum(node_end - node_start) + \ + gh.face_nodes.indptr[-1] + assert(added_node_pos.size == frac_id.size) + assert(added_node_pos[-1] - gh.face_nodes.indptr[-1] == nodes.size) + gh.face_nodes.indices = np.hstack((gh.face_nodes.indices, nodes)) + gh.face_nodes.indptr = np.hstack((gh.face_nodes.indptr, added_node_pos)) + gh.face_nodes.data = np.hstack((gh.face_nodes.data, + np.ones(nodes.size, dtype=bool))) + gh.face_nodes._shape = ( + gh.num_nodes, gh.face_nodes.shape[1] + frac_id.size) + assert(gh.face_nodes.indices.size == gh.face_nodes.indptr[-1]) + + node_start = gh.face_nodes.indptr[frac_id] + node_end = gh.face_nodes.indptr[frac_id + 1] + + #frac_nodes = gh.face_nodes[:, frac_id] + + #gh.face_nodes = sps.hstack((gh.face_nodes, frac_nodes)) + # We also copy the attributes of the original faces. + gh.num_faces += frac_id.size + gh.face_normals = np.hstack( + (gh.face_normals, gh.face_normals[:, frac_id])) + gh.face_areas = np.append(gh.face_areas, gh.face_areas[frac_id]) + gh.face_centers = np.hstack( + (gh.face_centers, gh.face_centers[:, frac_id])) + + # Not sure if this still does the correct thing. Might have to + # send in a logical array instead of frac_id. + gh.add_face_tag(frac_id, FaceTag.FRACTURE | FaceTag.BOUNDARY) + gh.face_tags = np.append(gh.face_tags, gh.face_tags[frac_id]) + + return frac_id + + +def update_face_cells(face_cells, face_id, i): + """ + Add duplicate faces to connection map between lower-dim grids + and higher dim grids. To be run after duplicate_faces. + """ + # We duplicated the faces associated with lower-dim grid i. + # The duplications should also be associated with grid i. + # For the other lower-dim grids we just add zeros to conserve + # the right matrix dimensions. + for j, f_c in enumerate(face_cells): + if j == i: + f_c = sps.hstack((f_c, f_c[:, face_id])) + else: + empty = sps.csc_matrix(f_c[:, face_id].shape) + f_c = sps.hstack((f_c, empty)) + face_cells[j] = f_c + return face_cells + + +def update_cell_connectivity(g, face_id, normal, x0): + """ + After the faces in a grid is duplicated, we update the cell connectivity list. + Cells on the right side of the fracture does not change, but the cells + on the left side are attached to the face duplicates. We assume that all + faces that have been duplicated lie in the same plane. This plane is + described by a normal and a point, x0. We attach cell on the left side of the + plane to the duplicate of face_id. The cells on the right side is attached + to the face frac_id + + Parameters: + ---------- + g - The grid for wich the cell_face mapping is uppdated + frac_id - Indices of the faces that have been duplicated + normal - Normal of faces that have been duplicated. Note that we assume + that all faces have the same normal + x0 - A point in the plane where the faces lie + """ + + # We find the cells attached to the tagged faces. + cell_frac = g.cell_faces[face_id, :] + cell_face_id = np.argwhere(cell_frac) + + # We devide the cells into the cells on the right side of the fracture + # and cells on the left side of the fracture. + left_cell = half_space_int(normal, x0, + g.cell_centers[:, cell_face_id[:, 1]]) + + if np.all(left_cell) or not np.any(left_cell): + # Fracture is on boundary of domain. There is nothing to do. + # Remove the extra faces. We have not yet updated cell_faces, + # so we should not delete anything from this matrix. + rem = np.arange(g.cell_faces.shape[0], g.num_faces) + remove_faces(g, rem, rem_cell_faces=False) + return + + # Assume that fracture is either on boundary (above case) or completely + # innside domain. Check that each face added two cells: + assert sum(left_cell) * 2 == left_cell.size, 'Fractures must either be' \ + 'on boundary or completely innside domain' + + # We create a cell_faces mapping for the new faces. This will be added + # on the end of the excisting cell_faces mapping. We have here assumed + # that we do not add any mapping during the duplication of faces. + col = cell_face_id[left_cell, 1] + row = cell_face_id[left_cell, 0] + data = np.ravel(g.cell_faces[np.ravel(face_id[row]), col]) + assert data.size == face_id.size + cell_frac_left = sps.csc_matrix((data, (row, col)), + (face_id.size, g.cell_faces.shape[1])) + + # We now update the cell_faces map of the faces on the right side of + # the fracture. These faces should only be attached to the right cells. + # We therefore remove their connection to the cells on the left side of + # the fracture. + col = cell_face_id[~left_cell, 1] + row = cell_face_id[~left_cell, 0] + data = np.ravel(g.cell_faces[np.ravel(face_id[row]), col]) + cell_frac_right = sps.csc_matrix((data, (row, col)), + (face_id.size, g.cell_faces.shape[1])) + g.cell_faces[face_id, :] = cell_frac_right + + # And then we add the new left-faces to the cell_face map. We do not + # change the sign of the matrix since we did not flip the normals. + # This means that the normals of right and left cells point in the same + # direction, but their cell_faces values have oposite signs. + g.cell_faces = sps.vstack((g.cell_faces, cell_frac_left), format='csc') + + +def remove_faces(g, face_id, rem_cell_faces=True): + """ + Remove faces from grid. + + PARAMETERS: + ----------- + g - A grid + face_id - Indices of faces to remove + rem_cell_faces - Defaults to True. If set to false, the g.cell_faces matrix + is not changed. + """ + # update face info + keep = np.array([True] * g.num_faces) + keep[face_id] = False + g.face_nodes = g.face_nodes[:, keep] + g.num_faces -= face_id.size + g.face_normals = g.face_normals[:, keep] + g.face_areas = g.face_areas[keep] + g.face_centers = g.face_centers[:, keep] + # Not sure if still works + g.face_tags = g.face_tags[keep] + + if rem_cell_faces: + g.cell_faces = g.cell_faces[keep, :] + + +def duplicate_nodes(g, nodes, offset): + """ + Duplicate nodes on a fracture. The number of duplication will depend on + the cell topology around the node. If the node is not on a fracture 1 + duplicate will be added. If the node is on a single fracture 2 duplicates + will be added. If the node is on a T-intersection 3 duplicates will be + added. If the node is on a X-intersection 4 duplicates will be added. + Equivalently for other types of intersections. + + Parameters: + ---------- + g - The grid for which the nodes are duplicated + nodes - The nodes to be duplicated + offset - How far from the original node the duplications should be + placed. + """ + node_count = 0 + + # We wish to convert the sparse csc matrix to a sparse + # csr matrix to easily add rows. However, the convertion sorts the + # indices, which will change the node order when we convert back. We + # therefore find the inverse sorting of the nodes of each face. + # After we have performed the row operations we will map the nodes + # back to their original position. + + _, iv = sort_sub_list(g.face_nodes.indices, g.face_nodes.indptr) + g.face_nodes = g.face_nodes.tocsr() + + # Iterate over each internal node and split it according to the graph. + # For each cell attached to the node, we check wich color the cell has. + # All cells with the same color is then attached to a new copy of the + # node. + for node in nodes: + # t_node takes into account the added nodes. + t_node = node + node_count + # Find cells connected to node + (_, cells, _) = sps.find(g.cell_nodes()[t_node, :]) + #cells = np.unique(cells) + # Find the color of each cell. A group of cells is given the same color + # if they are connected by faces. This means that all cells on one side + # of a fracture will have the same color, but a different color than + # the cells on the other side of the fracture. Equivalently, the cells + # at a X-intersection will be given four different colors + colors = find_cell_color(g, cells) + # Find which cells share the same color + colors, ix = np.unique(colors, return_inverse=True) + # copy coordinate of old node + new_nodes = np.repeat(g.nodes[:, t_node, None], colors.size, axis=1) + faces = np.array([], dtype=int) + face_pos = np.array([g.face_nodes.indptr[t_node]]) + for j in range(colors.size): + # For each color we wish to add one node. First we find all faces that + # are connected to the fracture node, and have the correct cell + # color + local_faces, _, _ = sps.find(g.cell_faces[:, cells[ix == j]]) + local_faces = np.unique(local_faces) + con_to_node = np.ravel(g.face_nodes[t_node, local_faces].todense()) + faces = np.append(faces, local_faces[con_to_node]) + # These faces is then attached to new node number j. + face_pos = np.append(face_pos, face_pos[-1] + np.sum(con_to_node)) + # If an offset is given, we will change the position of the nodes. + # We move the nodes a length of offset away from the fracture(s). + if offset > 0 and colors.size > 1: + new_nodes[:, j] -= avg_normal(g, + local_faces[con_to_node]) * offset + # The total number of faces should not have changed, only their + # connection to nodes. We can therefore just update the indices and + # indptr map. + g.face_nodes.indices[face_pos[0]:face_pos[-1]] = faces + node_count += colors.size - 1 + g.face_nodes.indptr = np.insert(g.face_nodes.indptr, + t_node + 1, face_pos[1:-1]) + g.face_nodes._shape = (g.face_nodes.shape[0] + colors.size - 1, + g.face_nodes._shape[1]) + # We delete the old node because of the offset. If we do not + # have an offset we could keep it and add one less node. + g.nodes = np.delete(g.nodes, t_node, axis=1) + g.nodes = np.insert(g.nodes, [t_node] * new_nodes.shape[1], + new_nodes, axis=1) + + # Transform back to csc format and fix node ordering. + g.face_nodes = g.face_nodes.tocsc() + g.face_nodes.indices = g.face_nodes.indices[iv] # For fast row operation + + return node_count + + +def sort_sub_list(indices, indptr): + ix = np.zeros(indices.size, dtype=int) + for i in range(indptr.size - 1): + sub_ind = mcolon(indptr[i], indptr[i + 1] - 1) + loc_ix = np.argsort(indices[sub_ind]) + ix[sub_ind] = loc_ix + indptr[i] + indices = indices[ix] + iv = np.zeros(indices.size, dtype=int) + iv[ix] = np.arange(indices.size) + return indices, iv + + +def find_cell_color(g, cells): + """ + Color the cells depending on the cell connections. Each group of cells + that are connected (either directly by a shared face or through a series + of shared faces of many cells) is are given different colors. + c_1-c_3 c_4 + / + c_7 | | + \ + c_2 c_5 + In this case, cells c_1, c_2, c_3 and c_7 will be given color 0, while + cells c_4 and c_5 will be given color 1. + + Parameters: + ---------- + g - Grid for which the cells belong + cells - indecies of cells (=np.array([1,2,3,4,5,7]) for case above) + """ + c = np.sort(cells) + # Local cell-face and face-node maps. + cf_sub, _ = __extract_submatrix(g.cell_faces, c) + child_cell_ind = np.array([-1] * g.num_cells, dtype=np.int) + child_cell_ind[c] = np.arange(cf_sub.shape[1]) + + # Create a copy of the cell-face relation, so that we can modify it at + # will + cell_faces = cf_sub.copy() + # Direction of normal vector does not matter here, only 0s and 1s + cell_faces.data = np.abs(cell_faces.data) + + # Find connection between cells via the cell-face map + c2c = cell_faces.transpose() * cell_faces + # Only care about absolute values + c2c.data = np.clip(c2c.data, 0, 1).astype('bool') + + graph = Graph(c2c) + graph.color_nodes() + return graph.color[child_cell_ind[cells]] + + +def avg_normal(g, faces): + """ + Calculates the average face normal of a set of faces. The average normal + is only constructed from the boundary faces, that is, a face thatbelongs + to exactly one cell. If a face is not a boundary face, it will be ignored. + The faces normals are fliped such that they point out of the cells. + + Parameters: + ---------- + g - Grid + faces - Face indecies of face normals that should be averaged + """ + frac_face = np.ravel( + np.sum(np.abs(g.cell_faces[faces, :]), axis=1) == 1) + f, _, sign = sps.find(g.cell_faces[faces[frac_face], :]) + n = g.face_normals[:, faces[frac_face]] + n = n[:, f] * sign + n = np.mean(n, axis=1) + n = n / np.linalg.norm(n) + return n + + +def remove_nodes(g, rem): + """ + Remove nodes from grid. + g - a valid grid definition + rem - a ndarray of indecies of nodes to be removed + """ + all_rows = np.arange(g.face_nodes.shape[0]) + rows_to_keep = np.where(np.logical_not(np.in1d(all_rows, rem)))[0] + g.face_nodes = g.face_nodes[rows_to_keep, :] + g.nodes = g.nodes[:, rows_to_keep] + return g + + +def __extract_submatrix(mat, ind): + """ From a matrix, extract the column specified by ind. All zero columns + are stripped from the sub-matrix. Mappings from global to local row numbers + are also returned. + """ + sub_mat = mat[:, ind] + cols = sub_mat.indptr + rows = sub_mat.indices + data = sub_mat.data + unique_rows, rows_sub = np.unique(sub_mat.indices, + return_inverse=True) + return sps.csc_matrix((data, rows_sub, cols)), unique_rows diff --git a/src/porepy/fracs/structured.py b/src/porepy/fracs/structured.py new file mode 100644 index 0000000000..c0df17ba0c --- /dev/null +++ b/src/porepy/fracs/structured.py @@ -0,0 +1,290 @@ +""" + +Main module for grid generation in fractured domains in 2d and 3d. + +The module serves as the only neccessary entry point to create the grid. It +will therefore wrap interface to different mesh generators, pass options to the +generators etc. + +""" +import numpy as np +import scipy.sparse as sps + +from gridding.gmsh import mesh_2_grid +from gridding import constants +from gridding.fractured import fractures +from utils import half_space +from core.grids import structured, point_grid +from compgeom import basics as cg + + +def cart_grid_3d(fracs, nx, physdims=None): + """ + Create grids for a domain with possibly intersecting fractures in 3d. + + Based on rectangles describing the individual fractures, the method + constructs grids in 3d (the whole domain), 2d (one for each individual + fracture), 1d (along fracture intersections), and 0d (meeting between + intersections). + + Parameters: + fracs (list of np.ndarray, each 3x4): Vertexes in the rectangle for each + fracture. The vertices must be sorted and aligned to the axis. + The fractures will snap to the closest grid faces. + nx (np.ndarray): Number of cells in each direction. Should be 3D. + physdims (np.ndarray): Physical dimensions in each direction. + Defaults to same as nx, that is, cells of unit size. + + Returns: + list (length 4): For each dimension (3 -> 0), a list of all grids in + that dimension. + + """ + nx = np.asarray(nx) + if physdims is None: + physdims = nx + elif np.asarray(physdims).size != nx.size: + raise ValueError('Physical dimension must equal grid dimension') + else: + physdims = np.asarray(physdims) + + # We create a 3D cartesian grid. The global node mapping is trivial. + g_3d = structured.CartGrid(nx, physdims=physdims) + g_3d.global_point_ind = np.arange(g_3d.num_nodes) + g_3d.compute_geometry() + g_2d = [] + g_1d = [] + g_0d = [] + # We set the tolerance for finding points in a plane. This can be any + # small number, that is smaller than .25 of the cell sizes. + tol = .1 * physdims / nx + + # Create 2D grids + for f in fracs: + is_xy_frac = np.allclose(f[2, 0], f[2]) + is_xz_frac = np.allclose(f[1, 0], f[1]) + is_yz_frac = np.allclose(f[0, 0], f[0]) + assert is_xy_frac + is_xz_frac + is_yz_frac == 1, \ + 'Fracture must align to x- or y-axis' + # snap to grid + f_s = np.round(f * nx[:, np.newaxis] / physdims[:, np.newaxis] + ) * physdims[:, np.newaxis] / nx[:, np.newaxis] + if is_xy_frac: + flat_dim = [2] + active_dim = [0, 1] + elif is_xz_frac: + flat_dim = [1] + active_dim = [0, 2] + else: + flat_dim = [0] + active_dim = [1, 2] + # construct normal vectors. If the rectangle is ordered + # clockwise we need to flip the normals so they point + # outwards. + sign = 2 * cg.is_ccw_polygon(f_s[active_dim]) - 1 + tangent = f_s.take( + np.arange(f_s.shape[1]) + 1, axis=1, mode='wrap') - f_s + normal = tangent + normal[active_dim] = tangent[active_dim[1::-1]] + normal[active_dim[1]] = -normal[active_dim[1]] + normal = sign * normal + # We find all the faces inside the convex hull defined by the + # rectangle. To find the faces on the fracture plane, we remove any + # faces that are further than tol from the snapped fracture plane. + in_hull = half_space.half_space_int( + normal, f_s, g_3d.face_centers) + f_tag = np.logical_and( + in_hull, + np.logical_and(f_s[flat_dim, 0] - tol[flat_dim] <= + g_3d.face_centers[flat_dim], + g_3d.face_centers[flat_dim] < + f_s[flat_dim, 0] + tol[flat_dim])) + f_tag = f_tag.ravel() + nodes = sps.find(g_3d.face_nodes[:, f_tag])[0] + nodes = np.unique(nodes) + loc_coord = g_3d.nodes[:, nodes] + g = _create_embedded_2d_grid(loc_coord, nodes) + g_2d.append(g) + + # Create 1D grids: + # Here we make use of the network class to find the intersection of + # fracture planes. We could maybe avoid this by doing something similar + # as for the 2D-case, and count the number of faces belonging to each edge, + # but we use the FractureNetwork class for now. + frac_list = [] + for f in fracs: + frac_list.append(fractures.Fracture(f)) + # Combine the fractures into a network + network = fractures.FractureNetwork(frac_list) + # Impose domain boundary. For the moment, the network should be immersed in + # the domain, or else gmsh will complain. + box = {'xmin': 0, 'ymin': 0, 'zmin': 0, + 'xmax': physdims[0], 'ymax': physdims[1], 'zmax': physdims[2]} + network.impose_external_boundary(box) + + # Find intersections and split them + network.find_intersections() + network.split_intersections() + + pts = network.decomposition['points'] + edges = network.decomposition['edges'] + poly = network._poly_2_segment() + edge_tags, intersection_points = network._classify_edges(poly) + edges = np.vstack((edges, edge_tags)) + const = constants.GmshConstants() + + for e in np.ravel(np.where(edges[2] == const.FRACTURE_INTERSECTION_LINE_TAG)): + # We find the start and end point of each fracture intersection (1D + # grid) and then the corresponding global node index. + s_pt = pts[:, edges[0, e]] + e_pt = pts[:, edges[1, e]] + nodes = _find_nodes_on_line(g_3d, nx, s_pt, e_pt) + loc_coord = g_3d.nodes[:, nodes] + g = mesh_2_grid.create_embedded_line_grid(loc_coord, nodes) + g_1d.append(g) + + # Create 0D grids + # Here we also use the intersection information from the FractureNetwork + # class. + for p in intersection_points: + node = np.argmin(cg.dist_point_pointset(p, g_3d.nodes)) + g = point_grid.PointGrid(g_3d.nodes[:, node]) + g.global_point_ind = np.asarray(node) + g_0d.append(g) + + grids = [[g_3d], g_2d, g_1d, g_0d] + return grids + + +def cart_grid_2d(fracs, nx, physdims=None): + """ + Create grids for a domain with possibly intersecting fractures in 2d. + + Based on lines describing the individual fractures, the method + constructs grids in 2d (whole domain), 1d (individual fracture), and 0d + (fracture intersections). + + Parameters: + fracs (list of np.ndarray, each 2x2): Vertexes of the line for each + fracture. The fracture lines must align to the coordinat axis. + The fractures will snap to the closest grid nodes. + nx (np.ndarray): Number of cells in each direction. Should be 2D. + physdims (np.ndarray): Physical dimensions in each direction. + Defaults to same as nx, that is, cells of unit size. + + Returns: + list (length 3): For each dimension (2 -> 0), a list of all grids in + that dimension. + + """ + nx = np.asarray(nx) + if physdims is None: + physdims = nx + elif np.asarray(physdims).size != nx.size: + raise ValueError('Physical dimension must equal grid dimension') + else: + physdims = np.asarray(physdims) + + g_2d = structured.CartGrid(nx, physdims=physdims) + g_2d.global_point_ind = np.arange(g_2d.num_nodes) + g_2d.compute_geometry() + g_1d = [] + g_0d = [] + + # 1D grids: + shared_nodes = np.zeros(g_2d.num_nodes) + for f in fracs: + is_x_frac = f[1, 0] == f[1, 1] + is_y_frac = f[0, 0] == f[0, 1] + assert is_x_frac != is_y_frac, 'Fracture must align to x- or y-axis' + if f.shape[0] == 2: + f = np.vstack((f, np.zeros(f.shape[1]))) + nodes = _find_nodes_on_line(g_2d, nx, f[:, 0], f[:, 1]) + #nodes = np.unique(nodes) + loc_coord = g_2d.nodes[:, nodes] + g = mesh_2_grid.create_embedded_line_grid(loc_coord, nodes) + g_1d.append(g) + shared_nodes[nodes] += 1 + + # Create 0-D grids + if np.any(shared_nodes > 1): + for global_node in np.where(shared_nodes > 1): + g = point_grid.PointGrid(g_2d.nodes[:, global_node]) + g.global_point_ind = np.asarray(global_node) + g_0d.append(g) + + grids = [[g_2d], g_1d, g_0d] + return grids + + +def _create_embedded_2d_grid(loc_coord, glob_id): + loc_center = np.mean(loc_coord, axis=1).reshape((-1, 1)) + loc_coord -= loc_center + # Check that the points indeed form a line + assert cg.is_planar(loc_coord) + # Find the tangent of the line + # Projection matrix + rot = cg.project_plane_matrix(loc_coord) + loc_coord_2d = rot.dot(loc_coord) + # The points are now 2d along two of the coordinate axis, but we + # don't know which yet. Find this. + sum_coord = np.sum(np.abs(loc_coord_2d), axis=1) + active_dimension = np.logical_not(np.isclose(sum_coord, 0)) + # Check that we are indeed in 2d + assert np.sum(active_dimension) == 2 + # Sort nodes, and create grid + coord_2d = loc_coord_2d[active_dimension] + sort_ind = np.lexsort((coord_2d[0], coord_2d[1])) + sorted_coord = coord_2d[:, sort_ind] + sorted_coord = np.round(sorted_coord * 1e10) / 1e10 + unique_x = np.unique(sorted_coord[0]) + unique_y = np.unique(sorted_coord[1]) + # assert unique_x.size == unique_y.size + g = structured.TensorGrid(unique_x, unique_y) + assert np.all(g.nodes[0:2] - sorted_coord == 0) + + # Project back to active dimension + nodes = np.zeros(g.nodes.shape) + nodes[active_dimension] = g.nodes[0:2] + g.nodes = nodes + # Project back again to 3d coordinates + + irot = rot.transpose() + g.nodes = irot.dot(g.nodes) + g.nodes += loc_center + + # Add mapping to global point numbers + g.global_point_ind = glob_id[sort_ind] + return g + + +def _find_nodes_on_line(g, nx, s_pt, e_pt): + s_node = np.argmin(cg.dist_point_pointset(s_pt, g.nodes)) + e_node = np.argmin(cg.dist_point_pointset(e_pt, g.nodes)) + + # We make sure the nodes are ordered from low to high. + if s_node > e_node: + tmp = s_node + s_node = e_node + e_node = tmp + # We now find the other grid nodes. We here use the node ordering of + # meshgrid (which is used by the TensorGrid class). + + # We find the number of nodes along each dimension. From this we find the + # jump in node number between two consecutive nodes. + + if np.all(np.isclose(s_pt[1:], e_pt[1:])): + # x-line: + nodes = np.arange(s_node, e_node + 1) + elif np.all(np.isclose(s_pt[[0, 2]], e_pt[[0, 2]])): + # y-line + nodes = np.arange(s_node, e_node + 1, nx[0] + 1, dtype=int) + + elif nx.size == 3 and np.all(np.isclose(s_pt[0:2], e_pt[0:2])): + # is z-line + nodes = np.arange(s_node, e_node + 1, + (nx[0] + 1) * (nx[1] + 1), dtype=int) + else: + raise RuntimeError( + 'Something went wrong. Found a diagonal intersection') + return nodes diff --git a/src/porepy/fracs/utils.py b/src/porepy/fracs/utils.py new file mode 100644 index 0000000000..bea3807fa2 --- /dev/null +++ b/src/porepy/fracs/utils.py @@ -0,0 +1,30 @@ +import numpy as np + + +def determine_mesh_size(num_pts, **kwargs): + """ + Set the preferred mesh size for geometrical points as specified by + gmsh. + + Currently, the only option supported is to specify a single value for + all fracture points, and one value for the boundary. + + See the gmsh manual for further details. + + """ + mode = kwargs.get('mode', 'constant') + + if mode == 'constant': + val = kwargs.get('value', None) + bound_val = kwargs.get('bound_value', None) + if val is not None: + mesh_size = val * np.ones(num_pts) + else: + mesh_size = None + if bound_val is not None: + mesh_size_bound = bound_val + else: + mesh_size_bound = None + return mesh_size, mesh_size_bound + else: + raise ValueError('Unknown mesh size mode ' + mode) diff --git a/src/porepy/grids/__init__.py b/src/porepy/grids/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/porepy/grids/coarsening.py b/src/porepy/grids/coarsening.py new file mode 100644 index 0000000000..6e4cc1285e --- /dev/null +++ b/src/porepy/grids/coarsening.py @@ -0,0 +1,315 @@ +# -*- coding: utf-8 -*- + +import numpy as np +import scipy.sparse as sps + +from core.grids.grid import Grid +from core.constit import second_order_tensor +from core.bc import bc + +from utils import matrix_compression, mcolon, accumarray, unique + +from fvdiscr import tpfa + +#------------------------------------------------------------------------------# + +def generate_coarse_grid( g, subdiv ): + """ Generate a coarse grid clustering the cells according to the flags + given by subdiv. Subdiv should be long as the number of cells in the + original grid, it contains integers (possibly not continuous) which + represent the cells in the final mesh. + + The values computed in "compute_geometry" are not preserved and they should + be computed out from this function. + + Note: there is no check for disconnected cells in the final grid. + + Parameters: + g: the grid + subdiv: a list of flags, one for each cell of the original grid + + How to use: + subdiv = np.array([0,0,1,1,1,1,3,4,6,4,6,4]) + g = generate_coarse_grid( g, subdiv ) + + """ + + subdiv = np.asarray( subdiv ) + assert( subdiv.size == g.num_cells ) + + # declare the storage array to build the cell_faces map + cell_faces = np.empty(0, dtype=g.cell_faces.indptr.dtype) + cells = np.empty(0, dtype=cell_faces.dtype) + orient = np.empty(0, dtype=g.cell_faces.data.dtype) + + # declare the storage array to build the face_nodes map + face_nodes = np.empty(0, dtype=g.face_nodes.indptr.dtype) + nodes = np.empty(0, dtype=face_nodes.dtype) + visit = np.zeros(g.num_faces, dtype=np.bool) + + # compute the face_node indexes + num_nodes_per_face = g.face_nodes.indptr[1:] - g.face_nodes.indptr[:-1] + face_node_ind = matrix_compression.rldecode(np.arange(g.num_faces), \ + num_nodes_per_face) + + cells_list = np.unique( subdiv ) + for cellId, cell in enumerate( cells_list ): + # extract the cells of the original mesh associated to a specific label + cells_old = np.where( subdiv == cell )[0] + + # reconstruct the cell_faces mapping + faces_old, _, orient_old = sps.find( g.cell_faces[:, cells_old] ) + mask = np.ones( faces_old.size, dtype=np.bool ) + mask[ np.unique( faces_old, return_index=True )[1] ] = False + # extract the indexes of the internal edges, to be discared + index = np.array([ np.where( faces_old == f )[0] \ + for f in faces_old[mask]]).ravel() + faces_new = np.delete( faces_old, index ) + cell_faces = np.r_[ cell_faces, faces_new ] + cells = np.r_[ cells, np.repeat( cellId, faces_new.shape[0] ) ] + orient = np.r_[ orient, np.delete( orient_old, index ) ] + + # reconstruct the face_nodes mapping + # consider only the unvisited faces + not_visit = ~visit[faces_new] + if not_visit.size == 0: continue + # mask to consider only the external faces + mask = np.sum( [ face_node_ind == f for f in faces_new[not_visit] ], \ + axis = 0, dtype = np.bool ) + face_nodes = np.r_[ face_nodes, face_node_ind[ mask ] ] + nodes_new = g.face_nodes.indices[ mask ] + nodes = np.r_[ nodes, nodes_new ] + visit[faces_new] = True + + # Rename the faces + cell_faces_unique = np.unique(cell_faces) + cell_faces_id = np.arange(cell_faces_unique.size, dtype=cell_faces.dtype) + cell_faces = np.array([cell_faces_id[np.where( cell_faces_unique == f )[0]]\ + for f in cell_faces]).ravel() + + shape = (cell_faces_unique.size, cells_list.size) + cell_faces = sps.csc_matrix((orient, (cell_faces, cells)), shape = shape) + + # Rename the nodes + face_nodes = np.array([cell_faces_id[np.where( cell_faces_unique == f )[0]]\ + for f in face_nodes]).ravel() + nodes_list = np.unique(nodes) + nodes_id = np.arange(nodes_list.size, dtype=nodes.dtype) + nodes = np.array([nodes_id[np.where( nodes_list == n )[0]] \ + for n in nodes]).ravel() + + # sort the nodes + nodes = nodes[ np.argsort(face_nodes,kind='mergesort') ] + data = np.ones(nodes.size, dtype=g.face_nodes.data.dtype) + indptr = np.r_[0, np.cumsum( np.bincount( face_nodes ) )] + face_nodes = sps.csc_matrix(( data, nodes, indptr )) + + name = g.name + name.append( "coarse" ) + return Grid( g.dim, g.nodes[:,nodes_list], face_nodes, cell_faces, name ) + +#------------------------------------------------------------------------------# + +def tpfa_matrix(g, perm=None, faces=None): + """ + Compute a two-point flux approximation matrix useful related to a call of + create_partition. + + Parameters + ---------- + g: the grid + perm: (optional) permeability, the it is not given unitary tensor is assumed + faces (np.array, int): Index of faces where TPFA should be applied. + Defaults all faces in the grid. + + Returns + ------- + out: sparse matrix + Two-point flux approximation matrix + + """ + if perm is None: + perm = second_order_tensor.SecondOrderTensor(g.dim,np.ones(g.num_cells)) + + bound = bc.BoundaryCondition(g, np.empty(0), '') + trm, _ = tpfa.tpfa(g, perm, bound, faces) + div = g.cell_faces.T + return div * trm + +#------------------------------------------------------------------------------# + +def create_partition(A, cdepth=2, epsilon=0.25, seeds=None): + """ + Create the partition based on an input matrix using the algebraic multigrid + method coarse/fine-splittings based on direct couplings. The standard values + for cdepth and epsilon are taken from the following reference. + + For more information see: U. Trottenberg, C. W. Oosterlee, and A. Schuller. + Multigrid. Academic press, 2000. + + Parameters + ---------- + A: sparse matrix used for the agglomeration + cdepth: the greather is the more intense the aggregation will be, e.g. less + cells if it is used combined with generate_coarse_grid + epsilon: weight for the off-diagonal entries to define the "strong + negatively cupling" + seeds: (optional) to define a-priori coarse cells + + Returns + ------- + out: agglomeration indices + + How to use + ---------- + part = create_partition(tpfa_matrix(g)) + g = generate_coarse_grid(g, part) + + """ + + if A.size == 0: return np.zeros(1) + Nc = A.shape[0] + + # For each node, which other nodes are strongly connected to it + ST = sps.lil_matrix((Nc,Nc),dtype=np.bool) + + # In the first instance, all cells are strongly connected to each other + At = A.T + + for i in np.arange(Nc): + ci, _, vals = sps.find(At[:,i]) + neg = vals < 0. + nvals = vals[neg] + nci = ci[neg] + minId = np.argmin(nvals) + ind = -nvals >= epsilon * np.abs(nvals[minId]) + ST[nci[ind], i] = True + + # Temporary field, will store connections of depth 1 + STold = ST.copy() + for _ in np.arange(2, cdepth+1): + for j in np.arange(Nc): + rowj = np.array(STold.rows[j]) + row = np.hstack([STold.rows[r] for r in rowj]) + ST[j, np.concatenate((rowj, row))] = True + STold = ST.copy() + + del STold + + ST.setdiag(False) + lmbda = np.array([len(s) for s in ST.rows]) + + # Define coarse nodes + candidate = np.ones(Nc, dtype=np.bool) + is_fine = np.zeros(Nc, dtype=np.bool) + is_coarse = np.zeros(Nc, dtype=np.bool) + + # cells that are not important for any other cells are on the fine scale. + for row_id, row in enumerate(ST.rows): + if not row: + is_fine[row_id] = True + candidate[row_id] = False + + ST = ST.tocsr() + it = 0 + while np.any(candidate): + i = np.argmax(lmbda) + is_coarse[i] = True + j = ST.indices[ST.indptr[i]:ST.indptr[i+1]] + jf = j[candidate[j]] + is_fine[jf] = True + candidate[np.r_[i, jf]] = False + loop = ST.indices[ mcolon.mcolon(ST.indptr[jf], ST.indptr[jf+1]-1) ] + for row in np.unique(loop): + s = ST.indices[ST.indptr[row]:ST.indptr[row+1]] + lmbda[row] = s[candidate[s]].size + 2*s[is_fine[s]].size + lmbda[np.logical_not(candidate)]= -1 + it = it + 1 + + # Something went wrong during aggregation + assert it <= Nc + + del lmbda, ST + + if seeds is not None: + is_coarse[seeds] = True + is_fine[seeds] = False + + # If two neighbors are coarse, eliminate one of them + c2c = np.abs(A) > 0 + c2c_rows, _, _ = sps.find(c2c) + + pairs = np.empty((2,0), dtype=np.int) + for idx, it in enumerate(np.where(is_coarse)[0]): + loc = slice(c2c.indptr[it], c2c.indptr[it+1]) + ind = np.setdiff1d(c2c_rows[loc], it) + cind = ind[is_coarse[ind]] + new_pair = np.stack((np.repeat(it, cind.size), cind)) + pairs = np.append(pairs, new_pair, axis=1) + + if pairs.size: + pairs = unique.unique_np113(np.sort(pairs, axis=0), axis=1) + for ij in pairs.T: + mi = np.argmin(A[ij, ij]) + is_coarse[ij[mi]] = False + is_fine[ij[mi]] = True + + coarse = np.where(is_coarse)[0] + + # Primal grid + NC = coarse.size + primal = sps.lil_matrix((NC,Nc),dtype=np.bool) + for i in np.arange(NC): + primal[i, coarse[i]] = True + + connection = sps.lil_matrix((Nc,Nc),dtype=np.double) + for it in np.arange(Nc): + n = np.setdiff1d(c2c_rows[c2c.indptr[it]:c2c.indptr[it+1]], it) + connection[it, n] = np.abs(A[it, n] / At[it, it]) + + connection = connection.tocsr() + + candidates_rep = np.ediff1d(connection.indptr) + candidates_idx = np.repeat(is_coarse, candidates_rep) + candidates = np.stack((connection.indices[candidates_idx], + np.repeat(np.arange(NC), candidates_rep[is_coarse])), + axis=-1) + + connection_idx = mcolon.mcolon(connection.indptr[coarse], + connection.indptr[coarse+1]-1) + vals = accumarray.accum(candidates, connection.data[connection_idx], + size=[Nc,NC]) + del candidates_rep, candidates_idx, connection_idx + + mcind = np.argmax(vals, axis=0) + mcval = [ vals[r,c] for c,r in enumerate(mcind) ] + + it = NC + not_found = np.logical_not(is_coarse) + # Process the strongest connection globally + while np.any(not_found): + mi = np.argmax(mcval) + nadd = mcind[mi] + + primal[mi, nadd] = True + not_found[nadd] = False + vals[nadd, :] *= 0 + + nc = connection.indices[connection.indptr[nadd]:connection.indptr[nadd+1]] + af = not_found[nc] + nc = nc[af] + nv = mcval[mi] * connection[nadd, :] + nv = nv.data[af] + if len(nc) > 0: + vals += sps.csr_matrix((nv,(nc, np.repeat(mi,len(nc)))), + shape=(Nc,NC)).todense() + mcind = np.argmax(vals, axis=0) + mcval = [ vals[r,c] for c,r in enumerate(mcind) ] + + it = it + 1 + if it > Nc + 5: break + + coarse, fine = primal.tocsr().nonzero() + return coarse[np.argsort(fine)] + +#------------------------------------------------------------------------------# diff --git a/src/porepy/grids/constants.py b/src/porepy/grids/constants.py new file mode 100644 index 0000000000..a53c3f2644 --- /dev/null +++ b/src/porepy/grids/constants.py @@ -0,0 +1,29 @@ +class GmshConstants(object): + """ + This class is a container for storing constant values that are used in + the meshing algorithm. The intention is to make them available to all + functions and modules. + + This may not be the most pythonic way of doing this, but it works. + """ + def __init__(self): + self.DOMAIN_BOUNDARY_TAG = 1 + self.COMPARTMENT_BOUNDARY_TAG = 2 + self.FRACTURE_TAG = 3 + # Tag for objects on the fracture tip + self.FRACTURE_TIP_TAG = 4 + # Tag for objcets on the intersection between two fractures + # (co-dimension n-2) + self.FRACTURE_INTERSECTION_LINE_TAG = 5 + # Tag for objects on the intersection between three fractures + # (co-dimension n-3) + self.FRACTURE_INTERSECTION_POINT_TAG = 6 + + self.PHYSICAL_NAME_DOMAIN = 'DOMAIN' + self.PHYSICAL_NAME_FRACTURES = 'FRACTURE_' + # Physical name for fracture tips + self.PHYSICAL_NAME_FRACTURE_TIP ='FRACTURE_TIP_' + self.PHYSICAL_NAME_FRACTURE_LINE = 'FRACTURE_LINE_' + self.PHYSICAL_NAME_AUXILIARY_LINE = 'AUXILIARY_LINE_' + self.PHYSICAL_NAME_FRACTURE_POINT = 'FRACTURE_POINT_' + diff --git a/src/porepy/grids/examples/coarsening.py b/src/porepy/grids/examples/coarsening.py new file mode 100644 index 0000000000..c2850d1d3d --- /dev/null +++ b/src/porepy/grids/examples/coarsening.py @@ -0,0 +1,270 @@ +""" +Various method for creating grids for relatively simple fracture networks. + +The module doubles as a test framework (though not unittest), and will report +on any problems if ran as a main method. + +""" +import sys +import getopt +import numpy as np +import scipy.sparse as sps +import time +import traceback +import logging +from inspect import isfunction, getmembers + +from core.grids import structured, simplex +from core.constit import second_order_tensor + +from viz.plot_grid import plot_grid +from gridding.coarsening import * + +#------------------------------------------------------------------------------# + +def coarsening_example0(**kwargs): + ####################### + # Simple 2d coarsening based on tpfa for Cartesian grids + # isotropic permeability + ####################### + Nx = Ny = 7 + g = structured.CartGrid( [Nx, Ny], [1,1] ) + g.compute_geometry() + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + part = create_partition(tpfa_matrix(g)) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = structured.CartGrid( [Nx, Ny], [1,1] ) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g), cdepth=3) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + +#------------------------------------------------------------------------------# + +def coarsening_example1(**kwargs): + ####################### + # Simple 2d coarsening based on tpfa for simplex grids + # isotropic permeability + ####################### + Nx = Ny = 7 + g = simplex.StructuredTriangleGrid( [Nx, Ny], [1,1]) + g.compute_geometry() + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + part = create_partition(tpfa_matrix(g)) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = simplex.StructuredTriangleGrid( [Nx, Ny], [1,1]) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g), cdepth=3) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + +#------------------------------------------------------------------------------# + +def coarsening_example2(**kwargs): + ####################### + # Simple 2d coarsening based on tpfa for Cartesian grids + # anisotropic permeability + ####################### + Nx = Ny = 7 + g = structured.CartGrid( [Nx, Ny], [1,1] ) + g.compute_geometry() + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + kxx = 3*np.ones(g.num_cells) + kyy = np.ones(g.num_cells) + perm = second_order_tensor.SecondOrderTensor(g.dim, kxx=kxx, kyy=kyy) + + part = create_partition(tpfa_matrix(g, perm=perm)) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = structured.CartGrid( [Nx, Ny], [1,1] ) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g, perm=perm), cdepth=3) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = structured.CartGrid( [Nx, Ny], [1,1] ) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g, perm=perm), cdepth=2, epsilon=1e-2) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + +# THERE IS A BUG, NEED TO BE FIXED +# g = structured.CartGrid( [Nx, Ny], [1,1] ) +# g.compute_geometry() +# +# part = create_partition(tpfa_matrix(g, perm=perm), cdepth=2, epsilon=1) +# g = generate_coarse_grid(g, part) +# g.compute_geometry(is_starshaped=True) +# +# if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + +#------------------------------------------------------------------------------# + +def coarsening_example3(**kwargs): + ####################### + # Simple 2d coarsening based on tpfa for simplex grids + # anisotropic permeability + ####################### + Nx = Ny = 7 + g = simplex.StructuredTriangleGrid( [Nx, Ny], [1,1]) + g.compute_geometry() + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + kxx = 3*np.ones(g.num_cells) + kyy = np.ones(g.num_cells) + perm = second_order_tensor.SecondOrderTensor(g.dim, kxx=kxx, kyy=kyy) + + part = create_partition(tpfa_matrix(g, perm=perm)) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = simplex.StructuredTriangleGrid( [Nx, Ny], [1,1]) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g, perm=perm), cdepth=3) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = simplex.StructuredTriangleGrid( [Nx, Ny], [1,1]) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g, perm=perm), cdepth=2, epsilon=1e-2) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + + g = simplex.StructuredTriangleGrid( [Nx, Ny], [1,1]) + g.compute_geometry() + + part = create_partition(tpfa_matrix(g, perm=perm), cdepth=2, epsilon=1) + g = generate_coarse_grid(g, part) + g.compute_geometry(is_starshaped=True) + + if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + +#------------------------------------------------------------------------------# + +# ONLY WHEN THE STAR_SHAPE COMPUTATION IS AVAILABLE ALSO IN 3D +#def coarsening_example_____(**kwargs): +# ####################### +# # Simple 3d coarsening based on tpfa for Cartesian grids +# ####################### +# Nx = Ny = Nz = 4 +# g = structured.CartGrid( [Nx, Ny, Nz], [1, 1, 1] ) +# g.compute_geometry() +# +# if kwargs['visualize']: plot_grid(g, info="all", alpha=0) +# +# part = create_partition(tpfa_matrix(g)) +# g = generate_coarse_grid(g, part) +# g.compute_geometry(is_starshaped=True) +# +# if kwargs['visualize']: plot_grid(g, info="all", alpha=0) +# +# g = structured.CartGrid( [Nx, Ny, Nz], [1, 1, 1] ) +# g.compute_geometry() +# +# part = create_partition(tpfa_matrix(g), cdepth=3) +# g = generate_coarse_grid(g, part) +# g.compute_geometry(is_starshaped=True) +# +# if kwargs['visualize']: plot_grid(g, info="all", alpha=0) + +#------------------------------------------------------------------------------# + +if __name__ == '__main__': + # If invoked as main, run all tests + try: + opts, args = getopt.getopt(sys.argv[1:], 'v:',['verbose=', 'visualize=']) + except getopt.GetoptError as err: + print(err) + sys.exit(2) + + verbose = True + visualize = False + # process options + for o, a in opts: + if o in ('-v', '--verbose'): + verbose = bool(a) + elif o == '--visualize': + visualize = bool(a) + + success_counter = 0 + failure_counter = 0 + + if not visualize: + print('It is more fun if the visualization option is active') + + time_tot = time.time() + + functions_list = [o for o in getmembers( + sys.modules[__name__]) if isfunction(o[1])] + + for f in functions_list: + func = f + if func[0] == 'isfunction' or func[0] == 'getmembers' or \ + func[0] == 'tpfa_matrix' or func[0] == 'plot_grid' or \ + func[0] == 'generate_coarse_grid' or func[0] == 'create_partition': + continue + if verbose: + print('Running ' + func[0]) + + time_loc = time.time() + try: + func[1](visualize=visualize) + + except Exception as exp: + print('\n') + print(' ************** FAILURE **********') + print('Example ' + func[0] + ' failed') + print(exp) + logging.error(traceback.format_exc()) + failure_counter += 1 + continue + + # If we have made it this far, this is a success + success_counter += 1 + ################################# + # Summary + # + print('\n') + print(' --- ') + print('Ran in total ' + str(success_counter + failure_counter) + ' tests,' + + ' out of which ' + str(failure_counter) + ' failed.') + print('Total elapsed time is ' + str(time.time() - time_tot) + ' seconds') + print('\n') diff --git a/src/porepy/grids/gmsh/__init__.py b/src/porepy/grids/gmsh/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/porepy/grids/gmsh/gmsh_interface.py b/src/porepy/grids/gmsh/gmsh_interface.py new file mode 100644 index 0000000000..4113938a3c --- /dev/null +++ b/src/porepy/grids/gmsh/gmsh_interface.py @@ -0,0 +1,452 @@ +# Methods to work directly with the gmsh format + +import numpy as np +from compgeom import sort_points +from gridding.gmsh import mesh_io +import sys +import os +import gridding.constants as gridding_constants + + +class GmshWriter(object): + """ + Write a gmsh.geo file for a fractured 2D domains, possibly including + compartments + """ + + def __init__(self, pts, lines, polygons=None, domain=None, nd=None, + mesh_size=None, mesh_size_bound=None, line_type=None, + intersection_points=None, tolerance=None): + """ + + :param pts: np.ndarary, Points + :param lines: np.ndarray. Non-intersecting lines in the geometry. + :param nd: Dimension. Inferred from points if not provided + """ + self.pts = pts + self.lines = lines + self.polygons = polygons + if nd is None: + if pts.shape[0] == 2: + self.nd = 2 + elif pts.shape[0] == 3: + self.nd = 3 + else: + self.nd = nd + + self.lchar = mesh_size + self.lchar_bound = mesh_size_bound + + if domain is not None: + self.domain = domain + + # Points that should be decleared physical (intersections between 3 + # fractures) + self.intersection_points = intersection_points + self.tolerance = tolerance + + def write_geo(self, file_name): + + if self.tolerance is not None: + s = 'Geometry.Tolerance = ' + str(self.tolerance) + ';\n' + else: + s = '\n' + s += self.__write_points() + + if self.nd == 2: + s += self.__write_boundary_2d() + s += self.__write_fractures_compartments_2d() + s += self.__write_physical_points() + elif self.nd == 3: + s += self.__write_boundary_3d() + s += self.__write_lines() + s += self.__write_polygons() + s += self.__write_physical_points() + + with open(file_name, 'w') as f: + f.write(s) + + def __write_fractures_compartments_2d(self): + # Both fractures and compartments are + constants = gridding_constants.GmshConstants() + + frac_ind = np.argwhere(np.logical_or( + self.lines[2] == constants.COMPARTMENT_BOUNDARY_TAG, + self.lines[2] == constants.FRACTURE_TAG)).ravel() + frac_lines = self.lines[:, frac_ind] + + s = '// Start specification of fractures \n' + for i in range(frac_ind.size): + s += 'frac_line_' + str(i) + ' = newl; Line(frac_line_' + str(i) \ + + ') ={' + s += 'p' + str(int(frac_lines[0, i])) + ', p' \ + + str(int(frac_lines[1, i])) + '}; \n' + s += 'Physical Line(\"' + constants.PHYSICAL_NAME_FRACTURES \ + + str(i) + '\") = { frac_line_' + str(i) + ' };\n' + s += 'Line{ frac_line_' + str(i) + '} In Surface{domain_surf}; \n' + s += '\n' + + s += '// End of fracture specification \n\n' + return s + + def __write_boundary_2d(self): + constants = gridding_constants.GmshConstants() + bound_line_ind = np.argwhere(self.lines[2] == + constants.DOMAIN_BOUNDARY_TAG).ravel() + bound_line = self.lines[:2, bound_line_ind] + bound_line = sort_points.sort_point_pairs(bound_line, + check_circular=True) + + s = '// Start of specification of domain' + s += '// Define lines that make up the domain boundary \n' + + loop_str = '{' + for i in range(bound_line.shape[1]): + s += 'bound_line_' + str(i) + ' = newl; Line(bound_line_'\ + + str(i) + ') ={' + s += 'p' + str(int(bound_line[0, i])) + ', p' + \ + str(int(bound_line[1, i])) + '}; \n' + loop_str += 'bound_line_' + str(i) + ', ' + + s += '\n' + loop_str = loop_str[:-2] # Remove last comma + loop_str += '}; \n' + s += '// Line loop that makes the domain boundary \n' + s += 'Domain_loop = newll; \n' + s += 'Line Loop(Domain_loop) = ' + loop_str + s += 'domain_surf = news; \n' + s += 'Plane Surface(domain_surf) = {Domain_loop}; \n' + s += 'Physical Surface(\"' + constants.PHYSICAL_NAME_DOMAIN + \ + '\") = {domain_surf}; \n' + s += '// End of domain specification \n \n' + return s + + def __write_boundary_3d(self): + # Write the bounding box in 3D + # Pull out bounding coordinates + xmin = str(self.domain['xmin']) + ', ' + xmax = str(self.domain['xmax']) + ', ' + ymin = str(self.domain['ymin']) + ', ' + ymax = str(self.domain['ymax']) + ', ' + zmin = str(self.domain['zmin']) # + ', ' + zmax = str(self.domain['zmax']) # + ', ' + + # Add mesh size on boundary points if these are provided + if self.lchar_bound is not None: + zmin += ', ' + zmax += ', ' + h = str(self.lchar_bound) + '};' + else: + h = '};' + ls = '\n' + + constants = gridding_constants.GmshConstants() + s = '// Define bounding box \n' + + # Points in bottom of box + s += 'p_bound_000 = newp; Point(p_bound_000) = {' + s += xmin + ymin + zmin + h + ls + s += 'p_bound_100 = newp; Point(p_bound_100) = {' + s += xmax + ymin + zmin + h + ls + s += 'p_bound_110 = newp; Point(p_bound_110) = {' + s += xmax + ymax + zmin + h + ls + s += 'p_bound_010 = newp; Point(p_bound_010) = {' + s += xmin + ymax + zmin + h + ls + s += ls + + # Lines connecting points + s += 'bound_line_1 = newl; Line(bound_line_1) = { p_bound_000,' \ + + 'p_bound_100};' + ls + s += 'bound_line_2 = newl; Line(bound_line_2) = { p_bound_100,' \ + + 'p_bound_110};' + ls + s += 'bound_line_3 = newl; Line(bound_line_3) = { p_bound_110,' \ + + 'p_bound_010};' + ls + s += 'bound_line_4 = newl; Line(bound_line_4) = { p_bound_010,' \ + + 'p_bound_000};' + ls + s += 'bottom_loop = newll;' + ls + s += 'Line Loop(bottom_loop) = {bound_line_1, bound_line_2, ' \ + + 'bound_line_3, bound_line_4};' + ls + s += 'bottom_surf = news;' + ls + s += 'Plane Surface(bottom_surf) = {bottom_loop};' + ls + + dz = self.domain['zmax'] - self.domain['zmin'] + s += 'Extrude {0, 0, ' + str(dz) + '} {Surface{bottom_surf}; }' + ls + s += 'Physical Volume(\"' + \ + constants.PHYSICAL_NAME_DOMAIN + '\") = {1};' + ls + s += '// End of domain specification ' + ls + ls + + return s + + def __write_points(self): + p = self.pts + num_p = p.shape[1] + if p.shape[0] == 2: + p = np.vstack((p, np.zeros(num_p))) + s = '// Define points \n' + for i in range(self.pts.shape[1]): + s += 'p' + str(i) + ' = newp; Point(p' + str(i) + ') = ' + s += '{' + str(p[0, i]) + ', ' + str(p[1, i]) + ', '\ + + str(p[2, i]) + if self.lchar is not None: + s += ', ' + str(self.lchar[i]) + ' };\n' + else: + s += '};\n' + + s += '// End of point specification \n \n' + return s + + def __write_lines(self, embed_in=None): + l = self.lines + num_lines = l.shape[1] + ls = '\n' + s = '// Define lines ' + ls + constants = gridding_constants.GmshConstants() + if l.shape[0] > 2: + lt = l[2] + has_tags = True + else: + has_tags = False + + for i in range(num_lines): + si = str(i) + s += 'frac_line_' + si + '= newl; Line(frac_line_' + si \ + + ') = {p' + str(l[0, i]) + ', p' + str(l[1, i]) \ + + '};' + ls + if has_tags: + s += 'Physical Line(\"' + if l[2, i] == constants.FRACTURE_TIP_TAG: + s += constants.PHYSICAL_NAME_FRACTURE_TIP + elif l[2, i] == constants.FRACTURE_INTERSECTION_LINE_TAG: + s += constants.PHYSICAL_NAME_FRACTURE_LINE + else: + # This is a line that need not be physical (recognized by + # the parser of output from gmsh). + s += constants.PHYSICAL_NAME_AUXILIARY_LINE + + s += si + '\") = {frac_line_' + si + '};' + ls + s += ls + s += '// End of line specification ' + ls + ls + return s + + def __write_polygons(self): + + constants = gridding_constants.GmshConstants() + ls = '\n' + s = '// Start fracture specification' + ls + for pi in range(len(self.polygons[0])): + p = self.polygons[0][pi].astype('int') + reverse = self.polygons[1][pi] + # First define line loop + s += 'frac_loop_' + str(pi) + ' = newll; ' + s += 'Line Loop(frac_loop_' + str(pi) + ') = { ' + for i, li in enumerate(p): + if reverse[i]: + s += '-' + s += 'frac_line_' + str(li) + if i < p.size - 1: + s += ', ' + + s += '};' + ls + + # Then the surface + s += 'fracture_' + str(pi) + ' = news; ' + s += 'Plane Surface(fracture_' + str(pi) + ') = {frac_loop_' \ + + str(pi) + '};' + ls + s += 'Physical Surface(\"' + constants.PHYSICAL_NAME_FRACTURES \ + + str(pi) + '\") = {fracture_' + str(pi) + '};' + ls + s += 'Surface{fracture_' + str(pi) + '} In Volume{1};' + ls + ls + + s += '// End of fracture specification' + ls + ls + + return s + + def __write_physical_points(self): + ls = '\n' + s = '// Start physical point specification' + ls + + constants = gridding_constants.GmshConstants() + + for i, p in enumerate(self.intersection_points): + s += 'Physical Point(\"' + constants.PHYSICAL_NAME_FRACTURE_POINT \ + + str(i) + '\") = {p' + str(p) + '};' + ls + s += '// End of physical point specification ' + ls + ls + return s + +# ----------- end of GmshWriter ---------------------------------------------- + +class GmshGridBucketWriter(object): + """ + Dump a grid bucket to a gmsh .msh file, to be read by other software. + + The function assumes that the grid consists of simplices, and error + messages will be raised if otherwise. The extension should not be + difficult, but the need has not been there yet. + + All grids in all dimensions will have a separate physical name (in the gmsh + sense), on the format GRID_#ID_DIM_#DIMENSION. Here #ID is the index of + the corresponding node in the grid bucket, as defined by + gb.assign_node_ordering. #DIMENSION is the dimension of the grid. + + """ + + def __init__(self, gb): + """ + Parameters: + gb (gridding.grid_bucket): Grid bucket to be dumped. + + """ + self.gb = gb + + # Assign ordering of the nodes in gb - used for unique identification + # of each grid + gb.assign_node_ordering() + + # Compute number of grids in each dimension of the gb + self._num_grids() + + def write(self, file_name): + """ + Write the whole bucket to a .msh file. + + Parameters: + file_name (str): Name of dump file. + + """ + s = self._preamble() + s += self._physical_names() + s += self._points() + s += self._elements() + + with open(file_name, 'w') as f: + f.write(s) + + def _preamble(self): + # Write the preamble (mesh Format) section + s_preamble = '$MeshFormat\n' + s_preamble += '2.2 0 8\n' + s_preamble += '$EndMeshFormat\n' + return s_preamble + + def _num_grids(self): + # Find number of grids in each dimension + max_dim = 3 + num_grids = np.zeros(max_dim + 1, dtype='int') + for dim in range(max_dim + 1): + num_grids[dim] = len(self.gb.grids_of_dimension(dim)) + + # Identify the highest dimension + while num_grids[-1] == 0: + num_grids = num_grids[:-1] + + # We will pick the global point set from the highest dimensional + # grid. The current implementation assumes there is a single grid + # in that dimension. Taking care of multiple grids should not be + # difficult, but it has not been necessary up to know. + if num_grids[-1] != 1: + raise NotImplementedError('Have not considered several grids\ + in the highest dimension') + self.num_grids = num_grids + + def _points(self): + # The global point set + p = self.gb.grids_of_dimension(len(self.num_grids)-1)[0].nodes + + ls = '\n' + s = '$Nodes' + ls + s += str(p.shape[1]) + ls + for i in range(p.shape[1]): + s += str(i+1) + ' ' + str(p[0, i]) + ' ' + str(p[1, i]) + \ + ' ' + str(p[2, i]) + ls + s += '$EndNodes' + ls + return s + + def _physical_names(self): + ls = '\n' + s = '$PhysicalNames' + ls + + # Use one physical name for each grid (including highest dimensional + # one) + s += str(self.gb.size()) + ls + for i, g in enumerate(self.gb): + dim = g[0].dim + s += str(dim) + ' ' + str(i+1) + ' ' + 'GRID_' + \ + str(g[1]['node_number']) + '_DIM_' + str(dim) + ls + s += '$EndPhysicalNames' + ls + return s + + def _elements(self): + ls = '\n' + s = '$Elements' + ls + + num_cells = 0 + for g, _ in self.gb: + num_cells += g.num_cells + s += str(num_cells) + ls + + # Element types (as specified by the gmsh .msh format), index by + # dimensions. This assumes all cells are simplices. + elem_type = [15, 1, 2, 4] + for i, gr in enumerate(self.gb): + g = gr[0] + gn = str(gr[1]['node_number']) + + # Sanity check - all cells should be simplices + assert np.all(np.diff(g.cell_nodes().indptr) == g.dim+1) + + # Write the cell-node relation as an num_cells x dim+1 array + cn = g.cell_nodes().indices.reshape((g.num_cells, g.dim+1)) + + et = str(elem_type[g.dim]) + for ci in range(g.num_cells): + s += str(ci+1) + ' ' + et + ' ' + str(1) + ' ' + gn + ' ' + # There may be underlaying assumptions in gmsh on the ordering + # of nodes. + for d in range(g.dim+1): + # Increase vertex offset by 1 + s += str(cn[ci, d] + 1) + ' ' + s += ls + + s += '$EndElements' + ls + return s + +#------------------ End of GmshGridBucketWriter------------------------------ + +def read_gmsh(out_file): + points, cells, phys_names, cell_info = mesh_io.read(out_file) + return points, cells, phys_names, cell_info + + +def run_gmsh(path_to_gmsh, in_file, out_file, dims, **kwargs): + """ + Convenience function to run gmsh. + + Parameters: + path_to_gmsh (str): Path to the location of the gmsh binary + in_file (str): Name of gmsh configuration file (.geo) + out_file (str): Name of output file for gmsh (.msh) + dims (int): Number of dimensions gmsh should grid. If dims is less than + the geometry dimensions, gmsh will grid all lower-dimensional + objcets described in in_file (e.g. all surfaces embeded in a 3D + geometry). + **kwargs: Options passed on to gmsh. See gmsh documentation for + possible values. + + Returns: + double: Status of the generation, as returned by os.system. 0 means the + simulation completed successfully, >0 signifies problems. + + """ + opts = ' ' + for key, val in kwargs.items(): + # Gmsh keywords are specified with prefix '-' + if key[0] != '-': + key = '-' + key + opts += key + ' ' + str(val) + ' ' + + if dims == 2: + cmd = path_to_gmsh + ' -2 ' + in_file + ' -o ' + out_file + opts + else: + cmd = path_to_gmsh + ' -3 ' + in_file + ' -o ' + out_file + opts + status = os.system(cmd) + + return status diff --git a/src/porepy/grids/gmsh/mesh_2_grid.py b/src/porepy/grids/gmsh/mesh_2_grid.py new file mode 100644 index 0000000000..4d7f23a334 --- /dev/null +++ b/src/porepy/grids/gmsh/mesh_2_grid.py @@ -0,0 +1,201 @@ +""" +Module for converting gmsh output file to our grid structure. +Maybe we will add the reverse mapping. +""" +import numpy as np + +from core.grids import simplex, structured, point_grid +from gridding import constants +import compgeom.basics as cg + + +def create_3d_grids(pts, cells): + tet_cells = cells['tetra'] + g_3d = simplex.TetrahedralGrid(pts.transpose(), tet_cells.transpose()) + + # Create mapping to global numbering (will be a unit mapping, but is + # crucial for consistency with lower dimensions) + g_3d.global_point_ind = np.arange(pts.shape[0]) + + # Convert to list to be consistent with lower dimensions + # This may also become useful in the future if we ever implement domain + # decomposition approaches based on gmsh. + g_3d = [g_3d] + return g_3d + + +def create_2d_grids(pts, cells, **kwargs): + # List of 2D grids, one for each surface + g_2d = [] + is_embedded = kwargs.get('is_embedded', False) + if is_embedded: + phys_names = kwargs.get('phys_names', False) + cell_info = kwargs.get('cell_info', False) + network = kwargs.get('network', False) + + # Check input + if not phys_names: + raise TypeError('Need to specify phys_names for embedded grids') + if not cell_info: + raise TypeError('Need to specify cell_info for embedded grids') + if not network: + raise TypeError('Need to specify network for embedded grids') + + # Special treatment of the case with no fractures + if not 'triangle' in cells: + return g_2d + + # Recover cells on fracture surfaces, and create grids + tri_cells = cells['triangle'] + + # Map from split polygons and fractures, as defined by the network + # decomposition + poly_2_frac = network.decomposition['polygon_frac'] + + num_tri = len(phys_names['triangle']) + gmsh_num = np.zeros(num_tri) + frac_num = np.zeros(num_tri) + + for i, pn in enumerate(phys_names['triangle']): + offset = pn[2].rfind('_') + frac_num[i] = poly_2_frac[int(pn[2][offset + 1:])] + gmsh_num[i] = pn[1] + + for fi in np.unique(frac_num): + loc_num = np.where(frac_num == fi)[0] + loc_gmsh_num = gmsh_num[loc_num] + + loc_tri_glob_ind = np.empty((0, 3)) + for ti in loc_gmsh_num: + # It seems the gmsh numbering corresponding to the physical tags + # (as found in physnames) is stored in the first column of info + gmsh_ind = np.where(cell_info['triangle'][:, 0] == ti)[0] + loc_tri_glob_ind = np.vstack((loc_tri_glob_ind, + tri_cells[gmsh_ind, :])) + + loc_tri_glob_ind = loc_tri_glob_ind.astype('int') + pind_loc, p_map = np.unique(loc_tri_glob_ind, return_inverse=True) + loc_tri_ind = p_map.reshape((-1, 3)) + g = simplex.TriangleGrid(pts[pind_loc, :].transpose(), + loc_tri_ind.transpose()) + # Add mapping to global point numbers + g.global_point_ind = pind_loc + + # Append to list of 2d grids + g_2d.append(g) + + else: + triangles = cells['triangle'].transpose() + # Construct grid + g_2d = simplex.TriangleGrid(pts.transpose(), triangles) + + # Create mapping to global numbering (will be a unit mapping, but is + # crucial for consistency with lower dimensions) + g_2d.global_point_ind = np.arange(pts.shape[0]) + + # Convert to list to be consistent with lower dimensions + # This may also become useful in the future if we ever implement domain + # decomposition approaches based on gmsh. + g_2d = [g_2d] + return g_2d + + +def create_1d_grids(pts, cells, phys_names, cell_info, + line_tag=constants.GmshConstants().PHYSICAL_NAME_FRACTURE_LINE): + # Recover lines + # There will be up to three types of physical lines: intersections (between + # fractures), fracture tips, and auxiliary lines (to be disregarded) + + g_1d = [] + + # If there are no fracture intersections, we return empty lists + if not 'line' in cells: + return g_1d, np.empty(0) + + gmsh_const = constants.GmshConstants() + + line_tags = cell_info['line'][:, 0] + line_cells = cells['line'] + + gmsh_tip_num = [] + tip_pts = np.empty(0) + + for i, pn in enumerate(phys_names['line']): + # Index of the final underscore in the physical name. Chars before this + # will identify the line type, the one after will give index + offset_index = pn[2].rfind('_') + loc_line_cell_num = np.where(line_tags == pn[1])[0] + loc_line_pts = line_cells[loc_line_cell_num, :] + + assert loc_line_pts.size > 1 + + line_type = pn[2][:offset_index] + + if line_type == gmsh_const.PHYSICAL_NAME_FRACTURE_TIP[:-1]: + gmsh_tip_num.append(i) + + # We need not know which fracture the line is on the tip of (do + # we?) + tip_pts = np.append(tip_pts, np.unique(loc_line_pts)) + + elif line_type == line_tag[:-1]: + loc_pts_1d = np.unique(loc_line_pts) # .flatten() + loc_coord = pts[loc_pts_1d, :].transpose() + g = create_embedded_line_grid(loc_coord, loc_pts_1d) + g_1d.append(g) + + else: # Auxiliary line + pass + return g_1d, tip_pts + + +def create_0d_grids(pts, cells): + # Find 0-d grids (points) + # We know the points are 1d, so squeeze the superflous dimension + g_0d = [] + if 'vertex' in cells: + point_cells = cells['vertex'].ravel() + for pi in point_cells: + g = point_grid.PointGrid(pts[pi]) + g.global_point_ind = np.asarray(pi) + g_0d.append(g) + return g_0d + + +def create_embedded_line_grid(loc_coord, glob_id): + loc_center = np.mean(loc_coord, axis=1).reshape((-1, 1)) + loc_coord -= loc_center + # Check that the points indeed form a line + assert cg.is_collinear(loc_coord) + # Find the tangent of the line + tangent = cg.compute_tangent(loc_coord) + # Projection matrix + rot = cg.project_plane_matrix(loc_coord, tangent) + loc_coord_1d = rot.dot(loc_coord) + # The points are now 1d along one of the coordinate axis, but we + # don't know which yet. Find this. + + sum_coord = np.sum(np.abs(loc_coord_1d), axis=1) + active_dimension = np.logical_not(np.isclose(sum_coord, 0)) + # Check that we are indeed in 1d + assert np.sum(active_dimension) == 1 + # Sort nodes, and create grid + coord_1d = loc_coord_1d[active_dimension] + sort_ind = np.argsort(coord_1d)[0] + sorted_coord = coord_1d[0, sort_ind] + g = structured.TensorGrid(sorted_coord) + + # Project back to active dimension + nodes = np.zeros(g.nodes.shape) + nodes[active_dimension] = g.nodes[0] + g.nodes = nodes + + # Project back again to 3d coordinates + + irot = rot.transpose() + g.nodes = irot.dot(g.nodes) + g.nodes += loc_center + + # Add mapping to global point numbers + g.global_point_ind = glob_id[sort_ind] + return g diff --git a/src/porepy/grids/gmsh/mesh_io.py b/src/porepy/grids/gmsh/mesh_io.py new file mode 100644 index 0000000000..2198db0c85 --- /dev/null +++ b/src/porepy/grids/gmsh/mesh_io.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +# +# This file is modified. +# https://raw.githubusercontent.com/nschloe/meshio/master/meshio/msh_io.py +# whihc is a part of the meshio.py package. +# Install from pip failed, so cut and paste was the path of least resistance. +# +# Modifications include update to python 3, and extended support for cell +# attributes and physical names +# +""" +The licence agreement for the orginal file, as found on github, reads as +follows: + + +The MIT License (MIT) + +Copyright (c) 2015 Nico Schlömer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +""" +''' +I/O for Gmsh's msh format, cf. +. + +.. moduleauthor:: Nico Schlömer +''' +from itertools import islice +import numpy + + +def read(filename): + '''Reads a Gmsh msh file. + ''' + # The format is specified at + # . + with open(filename) as f: + while True: + try: + line = next(islice(f, 1)) + except StopIteration: + break + assert(line[0] == '$') + environ = line[1:].strip() + if environ == 'MeshFormat': + line = next(islice(f, 1)) + # 2.2 0 8 + line = next(islice(f, 1)) + assert(line.strip() == '$EndMeshFormat') + elif environ == 'Nodes': + # The first line is the number of nodes + line = next(islice(f, 1)) + num_nodes = int(line) + points = numpy.empty((num_nodes, 3)) + for k, line in enumerate(islice(f, num_nodes)): + # Throw away the index immediately + points[k, :] = numpy.array(line.split(), dtype=float)[1:] + line = next(islice(f, 1)) + assert(line.strip() == '$EndNodes') + elif environ == 'Elements': + # The first line is the number of elements + line = next(islice(f, 1)) + num_cells = int(line) + cells = {} + cell_info = {'columns': ('Physical tag', 'Elementary tag')} + gmsh_to_meshio_type = { + 15: ('vertex', 1), + 1: ('line', 2), + 2: ('triangle', 3), + 3: ('quad', 4), + 4: ('tetra', 4), + 5: ('hexahedron', 8), + 6: ('wedge', 6) + } + for k, line in enumerate(islice(f, num_cells)): + # Throw away the index immediately; + data = numpy.array(line.split(), dtype=int) + t = gmsh_to_meshio_type[data[1]] + # Subtract one to account for the fact that python indices + # are 0-based. + if t[0] in cells: + cells[t[0]].append(data[-t[1]:] - 1) + cell_info[t[0]].append(data[3:5]) + else: + cells[t[0]] = [data[-t[1]:] - 1] + cell_info[t[0]] = [data[3:5]] + + line = next(islice(f, 1)) + assert(line.strip() == '$EndElements') + elif environ == 'PhysicalNames': + line = next(islice(f, 1)) + num_phys_names = int(line) + + physnames = {'columns': ('Cell type', 'Physical name tag', + 'Physical name')} + gmsh_to_meshio_type = { + 15: ('vertex', 1), + 0: ('point', 1), + 1: ('line', 2), + 2: ('triangle', 3), + 3: ('quad', 4), + 4: ('tetra', 4), + 5: ('hexahedron', 8), + 6: ('wedge', 6) + } + for k, line in enumerate(islice(f, num_phys_names)): + data = line.split(' ') + cell_type = int(data[0]) + t = gmsh_to_meshio_type[int(data[0])] + tag = int(data[1]) + name = data[2].strip().replace('\"','') + if t[0] in physnames: + physnames[t[0]].append((cell_type, tag, name)) + else: + physnames[t[0]] = [(cell_type, tag, name)] + + line = next(islice(f, 1)) + assert(line.strip() == '$EndPhysicalNames') + else: + raise RuntimeError('Unknown environment \'%s\'.' % environ) + + for key in cells: + cells[key] = numpy.vstack(cells[key]) + for key in cell_info: + cell_info[key] = numpy.vstack(cell_info[key]) + + if not 'physnames' in locals(): + physnames = None + + return points, cells, physnames, cell_info + +def write( + filename, + points, + cells, + point_data=None, + cell_data=None, + field_data=None + ): + '''Writes msh files, cf. + http://geuz.org/gmsh/doc/texinfo/gmsh.html#MSH-ASCII-file-format + ''' + if point_data is None: + point_data = {} + if cell_data is None: + cell_data = {} + if field_data is None: + field_data = {} + + with open(filename, 'w') as fh: + fh.write('$MeshFormat\n2 0 8\n$EndMeshFormat\n') + + # Write nodes + fh.write('$Nodes\n') + fh.write('%d\n' % len(points)) + for k, x in enumerate(points): + fh.write('%d %f %f %f\n' % (k+1, x[0], x[1], x[2])) + fh.write('$EndNodes\n') + + # Translate meshio types to gmsh codes + # http://geuz.org/gmsh/doc/texinfo/gmsh.html#MSH-ASCII-file-format + meshio_to_gmsh_type = { + 'vertex': 15, + 'line': 1, + 'triangle': 2, + 'quad': 3, + 'tetra': 4, + 'hexahedron': 5, + 'wedge': 6, + } + fh.write('$Elements\n') + num_cells = 0 + for key, data in cells.iteritems(): + num_cells += data.shape[0] + fh.write('%d\n' % num_cells) + num_cells = 0 + for key, data in cells.iteritems(): + n = data.shape[1] + form = '%d ' + '%d' % meshio_to_gmsh_type[key] + ' 0 ' + \ + ' '.join(n * ['%d']) + '\n' + for k, c in enumerate(data): + fh.write(form % ((num_cells+k+1,) + tuple(c + 1))) + num_cells += data.shape[0] + fh.write('$EndElements') + + return diff --git a/src/porepy/grids/grid_bucket.py b/src/porepy/grids/grid_bucket.py new file mode 100644 index 0000000000..ab74d642a0 --- /dev/null +++ b/src/porepy/grids/grid_bucket.py @@ -0,0 +1,584 @@ +""" +Module to store the grid hiearchy formed by a set of fractures and their +intersections in the form of a GridBucket. + +""" +from scipy import sparse as sps +import numpy as np +import networkx +import warnings + +from utils import setmembership + + +class GridBucket(object): + """ + Container for the hiererchy of grids formed by fractures and their + intersection. + + The information is stored in a graph borrowed from networkx, and the + GridBucket is to a large degree a wrapper around the graph. Each grid + defines a node in the graph, while edges are defined by grid pairs that + have a connection. + + To all nodes and vertexes, there is associated a dictionary (courtesy + networkx) that can store any type of data. Thus the GridBucket can double + as a data storage and management tool. + + Attributes: + graph (networkx.Graph): The of the grid. See above for further + description. + + """ + + def __init__(self): + self.graph = networkx.Graph(directed=False) + self.name = "grid bucket" + +#------------------------------------------------------------------------------# + + def __iter__(self): + """ + Iterator over the grid bucket. + + Yields: + core.grid.nodes: The grid associated with a node. + data: The dictionary storing all information in this node. + + """ + for g in self.graph: + data = self.graph.node[g] + yield g, data + +#------------------------------------------------------------------------------# + + def size(self): + """ + Returns: + int: Number of nodes in the grid. + + """ + return self.graph.number_of_nodes() + +#----------------------- + + def dim_max(self): + """ + Returns: + int: Maximum dimension of the grids present in the hierarchy. + + """ + return np.amax([g.dim for g, _ in self]) + +#------------------------------------------------------------------------------# + + def dim_min(self): + """ + Returns: + int: Minimum dimension of the grids present in the hierarchy. + + """ + return np.amin([g.dim for g, _ in self]) + +#------------------------------------------------------------------------------# + + def nodes(self): + """ + Yields: + An iterator over the graph nodes. + + """ + return self.graph.nodes() + +#------------------------------------------------------------------------------# + + def edges(self): + """ + Yields: + An iterator over the graph edges. + + """ + return self.graph.edges() + +#------------------------------------------------------------------------------# + + def sorted_nodes_of_edge(self, e): + """ + Obtain the vertices of an edge, in ascending order with respect their + dimension. + + Parameters: + e: An edge in the graph. + + Returns: + dictionary: The first vertex of the edge. + dictionary: The second vertex of the edge. + + """ + if e[0].dim < e[1].dim: + return e[0], e[1] + else: + return e[1], e[0] + +#------------------------------------------------------------------------------# + + def nodes_of_edge(self, e): + """ + Obtain the vertices of an edge. + + Parameters: + e: An edge in the graph. + + Returns: + dictionary: The first vertex of the edge. + dictionary: The second vertex of the edge. + + """ + return e[0], e[1] + +#------------------------------------------------------------------------------# + + def node_neighbors(self, n): + """ + Return: + list of networkx.node: Neighbors of node n + + """ + return self.graph.neighbors(n) + +#------------------------------------------------------------------------------# + + def add_node_prop(self, key, g=None, prop=None): + """ + Add a new property to existing nodes in the graph. + + Properties can be added either to all nodes, or to selected nodes as + specified by their grid. In the former case, all nodes will be assigned + the property, with None as the default option. + + No tests are done on whether the key already exist, values are simply + overwritten. + + Parameters: + prop (optional, defaults to None): Property to be added. + key (object): Key to the property to be handled. + g (list of core.grids.grid, optional): Nodes to be assigned values. + Defaults to None, in which case all nodes are assigned the same + value. + prop (list, or single object, optional): Value to be assigned. + Should be either a list with the same length as g, or a single + item which is assigned to all nodes. Defaults to None. + + Raises: + ValueError if the key is 'node_number', this is reserved for other + purposes. See self.assign_node_ordering() for details. + + """ + + # Check that the key is not 'node_number' - this is reserved + if key == 'node_number': + raise ValueError('Node number is a reserved key, stay away') + + # Do some checks of parameters first + if g is None: + assert prop is None or not isinstance(prop, list) + else: + assert len(g) == len(prop) + + # Set default value. + networkx.set_node_attributes(self.graph, key, None) + + if prop is not None: + if g is None: + for grid, n in self: + n[key] = prop + else: + for v, p in zip(g, prop): + self.graph.node[v][key] = p + +#------------------------------------------------------------------------------# + + def add_node_props(self, keys): + """ + Add new properties to existing nodes in the graph. + + Properties are be added to all nodes. + + No tests are done on whether the key already exist, values are simply + overwritten. + + Parameters: + keys (object): Keys to the properties to be handled. + + Raises: + ValueError if the key is 'node_number', this is reserved for other + purposes. See self.assign_node_ordering() for details. + + """ + [self.add_node_prop(key) for key in np.atleast_1d(keys)] + +#------------------------------------------------------------------------------# + + def add_edge_prop(self, key, grid_pairs=None, prop=None): + """ + Associate a property with an edge. + + Properties can be added either to all edges, or to selected edges as + specified by their grid pair. In the former case, all edges will be + assigned the property, with None as the default option. + + No tests are done on whether the key already exist, values are simply + overwritten. + + Parameters: + prop (optional, defaults to None): Property to be added. + key (object): Key to the property to be handled. + g (list of list of core.grids.grid, optional): Grid pairs defining + the edges to be assigned. values. Defaults to None, in which + case all edges are assigned the same value. + prop (list, or single object, optional): Value to be assigned. + Should be either a list with the same length as grid_pairs, or + a single item which is assigned to all nodes. Defaults to + None. + + Raises: + KeyError if a grid pair is not an existing edge in the grid. + + """ + + # Do some checks of parameters first + if grid_pairs is None: + assert prop is None or not isinstance(prop, list) + else: + assert len(grid_pairs) == len(prop) + + #networkx.set_edge_attributes(self.graph, key, None) + + if prop is not None: + for gp, p in zip(grid_pairs, prop): + if tuple(gp) in self.graph.edges(): + self.graph.edge[gp[0]][gp[1]][key] = p + elif tuple(gp[::-1]) in self.graph.edges(): + self.graph.edge[gp[1]][gp[0]][key] = p + else: + raise KeyError('Cannot assign property to undefined\ + edge') + +#------------------------------------------------------------------------------# + + def add_edge_props(self, keys): + """ + Add new propertiy to existing edges in the graph. + + Properties can be added either to all edges. + + No tests are done on whether the key already exist, values are simply + overwritten. + + Parameters: + keys (object): Keys to the properties to be handled. + + Raises: + KeyError if a grid pair is not an existing edge in the grid. + + """ + [self.add_edge_prop(key) for key in np.atleast_1d(keys)] + +#------------------------------------------------------------------------------# + + def node_prop(self, g, key): + """ + Getter for a node property of the bucket. + + Parameters: + grid (core.grids.grid): The grid associated with the node. + key (object): Key for the property to be retrieved. + + Returns: + object: The property. + + """ + return self.graph.node[g][key] + +#------------------------------------------------------------------------------# + + def has_nodes_prop(self, gs, key): + """ + Test if a key exists for a node property of the bucket, for several nodes. + Note: the property may contain None but the outcome of the test is + still true. + + Parameters: + grids (core.grids.grid): The grids associated with the nodes. + key (object): Key for the property to be tested. + + Returns: + object: The tested property. + + """ + return tuple([key in self.graph.node[g] for g in gs]) + +#------------------------------------------------------------------------------# + + def nodes_prop(self, gs, key): + """ + Getter for a node property of the bucket, for several nodes. + + Parameters: + grids (core.grids.grid): The grids associated with the nodes. + key (object): Key for the property to be retrieved. + + Returns: + object: The property. + + """ + return tuple([self.graph.node[g][key] for g in gs]) + +#------------------------------------------------------------------------------# + + def node_props(self, g): + """ + Getter for a node property of the bucket. + + Parameters: + grid (core.grids.grid): The grid associated with the node. + + Returns: + object: A dictionary with keys and properties. + + """ + return self.graph.node[g] + +#------------------------------------------------------------------------------# + + def node_props_of_keys(self, g, keys): + """ + Getter for a node property of the bucket. + + Parameters: + grid (core.grids.grid): The grid associated with the node. + keys (object): Key for the property to be retrieved. + + Returns: + object: A dictionary with key and property. + + """ + return {key: self.graph.node[g][key] for key in keys} + +#------------------------------------------------------------------------------# + + def edge_prop(self, grid_pairs, key): + """ + Getter for an edge property of the bucket. + + Parameters: + grid_pairs (list of core.grids.grid): The two grids making up the + edge. + key (object): Key for the property to be retrieved. + + Returns: + object: The property. + + Raises: + KeyError if the two grids do not form an edge. + + """ + prop_list = [] + for gp in np.atleast_2d(grid_pairs): + if tuple(gp) in self.graph.edges(): + prop_list.append(self.graph.edge[gp[0]][gp[1]][key]) + elif tuple(gp[::-1]) in self.graph.edges(): + prop_list.append(self.graph.edge[gp[1]][gp[0]][key]) + else: + raise KeyError('Unknown edge') + return prop_list +#------------------------------------------------------------------------------# + + def edge_props(self, gp): + """ + Getter for an edge properties of the bucket. + + Parameters: + grids_pair (list of core.grids.grid): The two grids making up the + edge. + key (object): Keys for the properties to be retrieved. + + Returns: + object: The properties. + + Raises: + KeyError if the two grids do not form an edge. + + """ + if tuple(gp) in self.graph.edges(): + return self.graph.edge[gp[0]][gp[1]] + elif tuple(gp[::-1]) in self.graph.edges(): + return self.graph.edge[gp[1]][gp[0]] + else: + raise KeyError('Unknown edge') + +#------------------------------------------------------------------------------# + + def edges_props(self): + """ + Iterator over the edges of the grid bucket. + + Yields: + core.grid.edges: The edge (pair of grids) associated with an edge. + data: The dictionary storing all information in this edge. + + """ + for e in self.graph.edges(): + yield e, self.edge_props(e) + +#------------------------------------------------------------------------------# + + def add_nodes(self, new_grids): + """ + Add grids to the hierarchy. + + The grids are added to self.grids. + + Parameters: + grids (iterable, list?): The grids to be added. None of these + should have been added previously. + + Returns: + np.ndarray, dtype object: Numpy array of vertexes, that are + identified with the grid hierarchy. Same order as input grid. + + """ + assert not np.any([i is j for i in new_grids for j in self.graph]) + + for g in new_grids: + self.graph.add_node(g) + +#------------------------------------------------------------------------------# + + def remove_nodes(self, cond): + """ + Remove nodes, and related edges, from the grid bucket subject to a + conditions. The latter takes as input a grid. + + Parameters: + cond: predicate to select the grids to remove. + + """ + + self.graph.remove_nodes_from([g for g in self.graph if cond(g)]) + +#------------------------------------------------------------------------------# + + def add_edge(self, grids, face_cells): + """ + Add an edge in the graph, based on the higher and lower-dimensional + grid. + + The coupling will be added with the higher-dimensional grid as the + first node. + + NOTE: If we are interested in couplings between grids of the same + dimension (e.g. in a domain-decomposition setting), we would need to + loosen assertions in this function. We would also need to reinterpret + face_cells. + + Parameters: + grids (list, len==2). Grids to be connected. Order is arbitrary. + face_cells (object): Identity mapping between cells in the + higher-dimensional grid and faces in the lower-dimensional + grid. No assumptions are made on the type of the object at this + stage. + + Raises: + ValueError if the two grids are not one dimension apart + + """ + assert np.asarray(grids).size == 2 + # Check that the connection does not already exist + assert not (tuple(grids) in self.graph.edges() + or tuple(grids[::-1]) in self.graph.edges()) + + # The higher-dimensional grid is the first node of the edge. + if grids[0].dim - 1 == grids[1].dim: + self.graph.add_edge(*grids, face_cells=face_cells) + elif grids[0].dim == grids[1].dim - 1: + + self.graph.add_edge(*grids[::-1], face_cells=face_cells) + else: + raise ValueError('Grid dimension mismatch') + +#------------------------------------------------------------------------------# + + def grids_of_dimension(self, dim): + """ + Get all grids in the bucket of a specific dimension. + + Returns: + list: Of grids of the specified dimension + + """ + return [g for g in self.graph.nodes() if g.dim == dim] + +#------------------------------------------------------------------------------# + + def assign_node_ordering(self): + """ + Assign an ordering of the nodes in the graph, stored as the attribute + 'node_number'. + + The intended use is to define the block structure of a discretization + on the grid hierarchy. + + The ordering starts with grids of highest dimension. The ordering + within each dimension is determined by an iterator over the graph, and + can in principle change between two calls to this function. + + If an ordering covering all nodes in the graph already exist, but does + not coincide with the new ordering, a warning is issued. + + """ + + # Check whether 'node_number' is defined for the grids already. + ordering_exists = True + for _, n in self: + if not 'node_number' in n.keys(): + ordering_exists = False + + counter = 0 + # Loop over grids in decreasing dimensions + for dim in range(self.dim_max(), self.dim_min() - 1, -1): + for g in self.grids_of_dimension(dim): + n = self.graph.node[g] + # Get old value, issue warning if not equal to the new one. + num = n.get('node_number', -1) + if ordering_exists and num != counter: + warnings.warn('Order of graph nodes has changed') + # Assign new value + n['node_number'] = counter + counter += 1 + +#------------------------------------------------------------------------------# + + def target_2_source_nodes(self, g_src, g_trg): + """ + Runar did this. + + """ + node_source = np.atleast_2d(g_src.global_point_ind) + node_target = np.atleast_2d(g_trg.global_point_ind) + _, trg_2_src_nodes = setmembership.ismember_rows( + node_source.astype(np.int32), node_target.astype(np.int32)) + return trg_2_src_nodes + +#------------------------------------------------------------------------------# + + def compute_geometry(self, is_embedded=True, is_starshaped=False): + """Compute geometric quantities for the grids. + + Note: the flag "is_embedded" is True by default. + """ + + [g.compute_geometry(is_embedded=is_embedded, + is_starshaped=is_starshaped) for g, _ in self] + +#------------------------------------------------------------------------------# diff --git a/src/porepy/grids/jupyter/WriteGmsh2D.ipynb b/src/porepy/grids/jupyter/WriteGmsh2D.ipynb new file mode 100644 index 0000000000..8310b83ac6 --- /dev/null +++ b/src/porepy/grids/jupyter/WriteGmsh2D.ipynb @@ -0,0 +1,947 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Background: This notebook is intended to illustrate the function to write a gmsh .geo-file that describes a fractured domain in 2D, runs gmsh, and then reads back the results." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Import of external packages" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We will define all points etc. as numpy arrays, as this seems the most convenient and general data format for whatever we will give to the filters later." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "import numpy as np" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Gmsh, like most other meshing packages, requires internal constraints to be non-intersecting. This means that, if the domain contains two fractures intersecting in a cross, gmsh likes to think of this as four lines, meeting in a central vertex. This splitting of lines can be handled by functions from the compgeom package.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "from compgeom import basics" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plotting by matplotlib" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import matplotlib.pyplot as plt\n", + "%matplotlib inline" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "%load_ext autoreload\n", + "%autoreload 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Geometry of the domain, including compartments" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We define the geometry of the entire domain, in terms of the max and min coordinates of x and y" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "domain = {'xmin': 0, 'xmax': 1, 'ymin': 0, 'ymax':2}" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define lines running through the domain. These will effectively split the domain into subdomains. The functionality is useful if the grid should conform to material discontinuities.\n", + "\n", + "The lines are defined on the format ax + by = 1, where we provide the coefficients a and b as 2-by-n numpy array. To be fed to the gridding algorithm, the intersection between the compartment lines and the domain boundary must be computed. This is not difficult, but it is boring, and has so far not been necessary. Therefore, **for the moment, we only consider lines in the x-direction, thus any y-component is ignored.** " + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "compartment_lines = np.array([[0.3, 0.5, 0.], # The x-component of the first line, together with the third line will be ignored\n", + " [0., 1.5, .7]]) " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Definition of internal constraints (fractures)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The next step is to define the fractures. The data format is a point coordinate array (Nd x num_points), together with connection information (2 x num_lines). Note that points can be shared by fractures (two fractures meeting in an L should be defined by three points, not four). Fractures may be intersecting; these lines will be split automatically later on.\n", + "\n", + "We also define tags for all fractures, here illustrated by 1) a numbering of the fractures, and 2) a grouping of fractures." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "frac_pts = np.array([[0.3, 0.5, 0.5, 0.3, 0.2, 0.8, 0.6, 0.6, 0.8],\n", + " [0.3, 0.3, 0.5, 0.5, 0.9, 1.9, 0.3, 0.5, 0.5]])\n", + "\n", + "frac_con = np.array([[0, 2], [1, 3], [4, 5], [6, 7], [7, 8]]).T\n", + "\n", + "# Two tags: One is a numbering, the other signifies fracture families (possibly from geological characterization)\n", + "frac_numbers = 1 + np.arange(frac_con.shape[1])\n", + "frac_families = np.array([1, 1, 2, 3, 1])\n", + "frac_tags = np.vstack((frac_numbers, frac_families))\n", + "\n", + "frac_tag_explanation = {'Fracture number', 'Fracture familiy'}\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define a simple function to visualize the fractures we have defined." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def plot_fractures(d, p, c, colortag=None):\n", + " \"\"\"\n", + " d: domain size in the form of a dictionary\n", + " p - points\n", + " c - connection between fractures\n", + " colortag - indicate that fractures should have different colors\n", + " \"\"\"\n", + " \n", + " # Assign a color to each tag. We define these by RBG-values (simplest option in pyplot).\n", + " # For the moment, some RBG values are hard coded, do something more intelligent if necessary.\n", + " if colortag is None:\n", + " tagmap = np.zeros(c.shape[1], dtype='int')\n", + " col = [(0, 0, 0)];\n", + " else:\n", + " utag, tagmap = np.unique(colortag, return_inverse=True)\n", + " ntag = utag.size\n", + " if ntag <= 3:\n", + " col = [(1, 0, 0), (0, 1, 0), (0, 0, 1)]\n", + " elif ntag < 6:\n", + " col = [(1, 0, 0), (0, 1, 0), (0, 0, 1),\n", + " (1, 1, 0), (1, 0, 1), (0, 0, 1)]\n", + " else:\n", + " raise NotImplementedError('Have not thought of more than six colors')\n", + " \n", + " \n", + " plt.figure()\n", + " # Simple for-loop to draw one fracture after another. Not fancy, but it serves its purpose.\n", + " for i in range(c.shape[1]):\n", + " plt.plot([p[0, c[0, i]], p[0, c[1, i]]], [p[1, c[0, i]], p[1, c[1, i]]], 'o-',color=col[tagmap[i]])\n", + " plt.axis([d['xmin'], d['xmax'], d['ymin'], d['ymax']])\n", + " plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Plot the fractures, with coloring according to the fracture's respective families" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "plot_fractures(domain, frac_pts, frac_con, frac_families)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Split intersecting fractures" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Before we write the input file to gmsh, the should be no intersections among the constraints. To that end, we first need to merge the fractures and the compartment lines. We also add an additional tag, telling whether the line is a fracture or a compartment." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "class GmshConstants(object):\n", + " \"\"\"\n", + " This class is a container for storing constant values that are used in \n", + " the meshing algorithm. The intention is to make them available to all\n", + " functions and modules.\n", + " \n", + " This may not be the most pythonic way of doing this, but it works.\n", + " \"\"\"\n", + " def __init__(self):\n", + " self.DOMAIN_BOUNDARY_TAG = 1\n", + " self.COMPARTMENT_BOUNDARY_TAG = 2\n", + " self.FRACTURE_TAG = 3\n", + " \n", + " self.PHYSICAL_NAME_DOMAIN = 'DOMAIN'\n", + " self.PHYSICAL_NAME_FRACTURES = 'FRACTURE_'" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def merge_fracs_compartments_domain(dom, frac_p, frac_l, comp):\n", + " \n", + " \n", + " constants = GmshConstants()\n", + " \n", + " # First create lines that define the domain\n", + " x_min = dom['xmin']\n", + " x_max = dom['xmax']\n", + " y_min = dom['ymin']\n", + " y_max = dom['ymax']\n", + " dom_p = np.array([[x_min, x_max, x_max, x_min],\n", + " [y_min, y_min, y_max, y_max]])\n", + " dom_lines = np.array([[0, 1], [1, 2], [2, 3], [3, 0]]).T\n", + " \n", + " num_dom_lines = dom_lines.shape[1] # Should be 4\n", + " \n", + " ## Then the compartments\n", + " # First ignore compartment lines that have no y-component\n", + " comp = np.squeeze(comp[:, np.argwhere(np.abs(comp[1]) > 1e-5)])\n", + " \n", + " num_comps = comp.shape[1]\n", + " # Assume that the compartments are not intersecting at the boundary.\n", + " # This may need revisiting when we allow for general lines (not only y=const)\n", + " comp_p = np.zeros((2, num_comps * 2))\n", + " \n", + " for i in range(num_comps):\n", + " # The line is defined as by=1, thus y = 1/b\n", + " comp_p[:, 2*i : 2*(i+1)] = np.array([[x_min, x_max], \n", + " [1./comp[1, i], 1./comp[1, i]]])\n", + " \n", + " comp_lines = np.arange(2*num_comps, dtype='int').reshape((2, -1), order='F')\n", + " \n", + " # Neither domain nor compartments have tags, add this\n", + " num_frac_tags = 1\n", + " \n", + " # The lines will have all fracture-related tags set to zero. \n", + " # The plan is to ignore these tags for the boundary and compartments, so it should not matter\n", + " dom_tags = constants.DOMAIN_BOUNDARY_TAG * np.ones((1, num_dom_lines))\n", + " dom_l = np.vstack((dom_lines, dom_tags))\n", + " comp_tags = constants.COMPARTMENT_BOUNDARY_TAG * np.ones((1, num_comps))\n", + " comp_l = np.vstack((comp_lines, comp_tags))\n", + " \n", + " # Also add a tag to the fractures, signifying that these are fractures\n", + " frac_l = np.vstack((frac_l, constants.FRACTURE_TAG * np.ones(frac_l.shape[1])))\n", + " \n", + " \n", + " # Merge the point arrays, compartment points first\n", + " p = np.hstack((dom_p, comp_p, frac_p))\n", + " \n", + " # Adjust index of compartment points to account for domain points in from of them\n", + " comp_l[:2] += dom_p.shape[1]\n", + " \n", + " # Adjust index of fracture points to account for the compart points\n", + " frac_l[:2] += dom_p.shape[1] + comp_p.shape[1]\n", + " # \n", + " \n", + " l = np.hstack((dom_l, comp_l, frac_l)).astype('int')\n", + " \n", + " # Add a second tag as an identifyer of each line\n", + " l = np.vstack((l, np.arange(l.shape[1])))\n", + " \n", + " tag_description = ['Line type', 'Line number']\n", + " \n", + " return p, l, tag_description\n", + " " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Merge all lines, and plot the resulting network using the final tag to color the lines to verify that we can distinguish between domain boundary, compartment lines and fractures" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAgsAAAFkCAYAAACuFXjcAAAABHNCSVQICAgIfAhkiAAAAAlwSFlz\nAAAPYQAAD2EBqD+naQAAIABJREFUeJzt3X2UVfV97/H39yglFzvq6jVB5GLBmliK7aSQpCVoYlcQ\n0CKhFdNOwFDS5WqakDFIYtqFt2iqdSUREStZeWiSmxuQJYF2BY2IN0mtDZmQCrVaSzWJxRYwmIde\nmFKlcz2/+8cZZBhnNnPOnKd9zvu11lnL2Wc/fOfn5pzP7P3de0dKCUmSpOEUGl2AJElqboYFSZKU\nybAgSZIyGRYkSVImw4IkScpkWJAkSZkMC5IkKZNhQZIkZTIsSJKkTIYFSZKUqaywEBF/HBHfjYgj\nEXEoIv4qIt4wguUui4jdEfFSRDwTEUsrL1mSJNVTuUcWLgX+HPg1YDYwBng4Iv7bcAtExGTgAeAb\nQCewDviLiLi8gnolSVKdxWgeJBUR5wAvAG9LKX1rmHk+DlyRUvqVAdM2AWellK6seOOSJKkuRtuz\ncDaQgJ9mzPPrwNcHTdsBzBzltiVJUh2cXumCERHAXcC3Ukr/lDHrucChQdMOAWdGxNiU0rEh1v3f\ngbnAPuClSmuUJKkNvQaYDOxIKf2kGiusOCwAnwJ+CZhVjUIGmQtsrMF6JUlqF4uBe6uxoorCQkTc\nA1wJXJpSev4Us/8QGD9o2njgyFBHFfrtA9gATKV0nuNdEXzly1+upFyN0Io1a1i7cmWjy2grjnn9\nOeb155jXXte113JvSgSwF1hSmryvWusvOyz0B4V3Am9PKf3rCBbpAa4YNG1O//ThvASloDAd+Brw\n+l/5FaYvXlxuuSrDWffd5xjXmWNef455/TnmtTf5E5/g0BNPMOiqgaqdxi8rLETEp4AuYAFwNCKO\nHzE4nFJ6qX+ePwMmppSO30vh08AH+q+K+ALwDmARDP6dXi1RCgorxo7lkQcfLKdUSZLaxhe3b+ey\nCy4gHTvGuTVYf7lXQ7wPOBN4BDg44PWuAfNMACYd/yGltA/4TUr3ZXgcWAH8fkpp8BUSr/LuCP68\ns5NHnn2W8847r8xSJUlqD+eddx6PPPssf97Zye8Uqn9z5rKOLKSUTllBSmnZENMeBWaUsy2ATV/+\nsoeuJEkagfPOO4+HHn+cPRs3MmPJkqqu22dD6BVdXV2NLqHtOOb155jXn2Oef4YFvcJ/0PXnmNef\nY15/jnn+GRYkSVImw4IkScpkWJAkSZkMC5KkmhrN043VHAwLkqSq6+3tpbt7NVOmzGbSpIVMmTKb\n7u7V9Pb2Nro0VWA0D5KSJOlVent7mTnzavbuvYFi8WYggMT69Tv45jevpqdnKx0dHQ2uUuXwyIIk\nqapWrbqjPyjMoxQUAIJicR57967gppvWNLI8VcCwIEmqqvvv30mxOHfI94rFeWzbtrPOFWm0DAuS\npKpJKfHii2dw4ojCYEFf3zibHnPGsCBJqorDh+GP/zg4dOgopecGDyUxZsxRIoYLE2pGhgVJ0qj0\n9cE998CFF8Ldd8Ob3zyLQmHHkPMWCg+xYMElda5Qo2VYkCRVJCX46lfh4ouhuxsWLIDvfQ++8Y0P\nM3XqnRQK2zlxhCFRKGxn6tS13HrrykaWrQoYFiRJZXvsMbjsMli4EM4/H/7+7+Hzn4eJE6Gjo4Oe\nnq0sX76LyZPnMHHiO5k8eQ7Ll+/yssmc8j4LkqQRe+45WLUKNm6EadNg+3aYOxcGtyB0dHSwbt3N\nrFtXanq0RyHfPLIgSTqlw4fhj/4ILroIvv51+Oxn4fHHYd68VweFwQwK+eeRBUnSsPr64DOfgVtu\ngaNH4aMfhY98BH72ZxtdmerJsCBJepWUYNs2uPHGUtPismXwsY+VehLUfjwNIUk6SVbzotqTYUGS\nBJSaF5csgTe/GX7yk1Lz4sMPQ2dnoytToxkWJKnNjaZ5Ue3BngVJalM2L2qkDAuS1GZsXlS5PA0h\nSW3E5kVVwrAgSW3A5kWNhmFBklqYzYuqBnsWJKkF2byoajIsSFILsXlRteBpCElqETYvqlYMC5KU\nczYvqtYMC5KUUzYvql7sWZCknLF5UfVmWJCknLB5UY3iaQhJygGbF9VIhgVJamI2L6oZGBYkqQnZ\nvKhmYs+CJDURmxfVjAwLktQEbF5UM/M0hCQ1mM2LanaGBUlqEJsXlReGBUmqM5sXlTf2LEhSndi8\nqLwyLEhSjdm8qLzzNIQk1ZDNi2oFhgVJqgGbF9VKDAuSVEU2L6oV2bMgSVVg86JamWFBkkbB5kW1\nA09DSFKFbF5UuzAsSFKZbF5UuzEsSNII2byodmXPgiSdgs2LaneGBUkahs2LUomnISRpCDYvSicY\nFiRpAJsXpVczLEgSNi9KWexZkNTWbF6UTs2wIKkt2bwojZynISS1HZsXpfIYFiS1DZsXpcoYFiS1\nPJsXpdGxZ0FSy7J5UaoOw4KklmPzolRdnoaQ1FJsXpSqz7AgqSXYvCjVTtlhISIujYhtEXEgIooR\nseAU87+9f76Br5cj4nWVly1JJTYvSrVXSc/CGcDjwOeBvxzhMgl4A9D7yoSUXqhg25IE2Lwo1VPZ\nYSGl9BDwEEBEWbn9RymlI+VuT5IGsnlRqr969SwE8HhEHIyIhyPirXXarqQWYvOi1Bj1CAvPA38A\nXA38NvBvwCMR8cY6bFtSC7B5UWqsmt9nIaX0DPDMgEnfiYhfAFYAS2u9fUn5dfgw3H473HUXnH12\nqXlx2TI43TvESHXVqH9y3wVmnWqmFWvWcNZ99500rauri66urlrVJakJ2LwojcymTZvYtGnTSdMO\n799f9e1ESqnyhSOKwMKU0rYyl3sYOJJSWjTM+9OB3bs3bGD64sUV1ycpX2xelEZvz8aNzFiyBGBG\nSmlPNdZZ9pGFiDgDuJBS0yLABRHRCfw0pfRvEXE7cF5KaWn//NcD/wI8BbwGuA74DeDyKtQvqUU8\n9hisXAmPPgqzZ8PmzfYkSM2ikgbHNwF/D+ymdP+ENcAe4Jb+988FJg2Y/2f653kCeAT4ZeAdKaVH\nKqpYUkuxeVFqfpXcZ+FvyAgZKaVlg37+JPDJ8kuT1MpsXpTyw3+WkurK5kUpfwwLkurC5kUpv5r6\nqZPz13yI7tXd9Pb2nnpmSU3LOy9Ktdfb20v36m7mr/lQ1dfd1GHh+c/9mPUz1zPz6pkGBimHbF6U\n6qO3t5eZV89k/cz1PP+5H1d9/U0dFggoziuyd8VeblpzU6OrkTRCPjZaqq9Vd6xi7w17Kc4rnrix\nQRXlomehOK/Ilju3sNS7Q0tNra8P/vIzr+Wzt5zLi0dP4z0fPcR7PnKIcT9b5IlGFye1sC07t1C8\nuViz9eciLBBwcNxBZqQZNUlMkkYpAdsWwI2fgO9NhGVfhI/9CZ+beJDPNbo2qdUl4Axq+v2Yj7CQ\nYMLRCTwQDzS6EkmD/NNj41i7ciJ7Hu3g12Yf4UObn+YNnb8K3N/o0qT2EDD/6HyeT8/XLDDkIiwU\nHipwzSXXMJ3pjS5FUr/nnoNVq2DjRpg2rdS8OHfumUSc2ejSpLazaNYi1u9YX+pZqIHmbnBMUNhe\nYOraqdy68tZGVyMJmxelZnTbh29j6p1TKWwvlE5LVFlTh4UJ153D8l3L6dnaQ0dHR6PLkdpaXx/c\ncw9ceCHcfXfpzovf/z5cd523aJYaraOjg56tPSzftZwJ151T9fWP6hHVteIjqqXmMfjOi7/3e/Cn\nf+oNlaRmVYtHVDf1kQVJjTXUnRe/8AWDgtRuDAuSXsU7L0oayLAg6RU2L0oaim1JknxstKRMhgWp\njdm8KGkkPA0htSmbFyWNlGFBajM2L0oql2FBahM2L0qqlD0LUouzeVHSaBkWpBZl86KkavE0hNSC\nbF6UVE2GBamF2LwoqRYMC1ILsHlRUi3ZsyDlmM2LkurBsCDlkM2LkurJ0xBSzti8KKneDAtSTti8\nKKlRDAtSk7N5UVKj2bMgNSmbFyU1C8OC1GRsXpTUbDwNITURmxclNSPDgtQEbF6U1MwMC1ID2bwo\nKQ/sWZAawOZFSXliWJDqyOZFSXnkaQipTmxelJRXhgWpxmxelJR3hgWpRmxelNQq7FmQqszmRUmt\nxrAgVYnNi5JalachpCqweVFSKzMsSKNg86KkdmBYkCpg86KkdmLPglQGmxcltSPDgjQCNi9Kamee\nhpBOweZFSe3OsCANw+ZFSSoxLEiD2LwoSSezZ0HqZ/OiJA3NsKC2Z/OiJGXzNITams2LknRqhgW1\nJZsXJWnkDAtqKzYvSlL57FlQW7B5UZIqZ1hQS7N5UZJGz9MQalk2L0pSdRgW1HJsXpSk6jIsqGXY\nvChJtWHPgnLP5kVJqi3DgnLL5kVJqg9PQyiXbF6UpPoxLChXbF6UpPozLCgXbF6UpMaxZ0FNzeZF\nSWo8w4Kaks2LktQ8yj4NERGXRsS2iDgQEcWIWDCCZS6LiN0R8VJEPBMRSysrV+3A5kVJai6V9Cyc\nATwOvB9Ip5o5IiYDDwDfADqBdcBfRMTlFWxbLczmRUlqTmWfhkgpPQQ8BBAxotayPwSeTSnd2P/z\n0xFxCbAC+D/lbl+t5/BhuP12uOsuOPvsUvPismVwuifJJKkp1ONqiF8Hvj5o2g5gZh22rSbW1wf3\n3AMXXgh3311qXvz+9+G66wwKktRM6hEWzgUODZp2CDgzIsbWYftqMinBV78KF18M3d1w1VWlJsZb\nbvEqB0lqRk3999uKNWs46777TprW1dVFV1dXgyrSaD32GKxcCY8+CrNnw+bN9iRIUqU2bdrEpk2b\nTpp2eP/+qm+nHmHhh8D4QdPGA0dSSseyFly7ciXTFy+uWWGqn+eeg1WrYONGmDat1Lw4d643VJKk\n0RjqD+g9GzcyY8mSqm6nHqcheoB3DJo2p3+6Wpx3XpSk/Cv7yEJEnAFcCBz/qL8gIjqBn6aU/i0i\nbgfOSykdv5fCp4EPRMTHgS9QCg6LgCtHXb2alndelKTWUclpiDcBf03pHgsJWNM//UvAeyk1NE46\nPnNKaV9E/CawFugG9gO/n1IafIWEWoB3XpSk1lPJfRb+hozTFymlZUNMexSYUe62lC82L0pSa/Kp\nkxo177woSa3NsKCK2bwoSe2hqe+zoOZk86IktRfDgkbM5kVJak+ehtCI+NhoSWpfhgW9IqVXP3Hc\n5kVJkmGhzfX29tLdvZopU2YzadJCpkyZTXf3avbv77V5UZIE2LPQ1np7e5k582r27r2BYvFmSjfl\nTNxzzw4+9amrGTNmKx/9aIfNi5LU5jyy0MZWrbqjPyjM48Tdu4OU5vHyyyt497vX+NhoSZJhoZ3d\nf/9OisW5w7w7j29+c2dd65EkNSfDQptKKdHXdwYnjigMFvT1jRuy6VGS1F4MC20qIhgz5iilZ4EN\nJTFmzFHCbkZJanuGhTZ21VWzKBR2DPleofAQCxZcUueKJEnNyLDQxm677cNMnXonhcJ2ThxhSBQK\n25k6dS233rqykeVJkpqEYaGNdXR00NOzleXLdzF58hwmTnwnkyfPYfnyXfT0bKWjo6PRJUqSmoD3\nWWhzHR0drFt3M+vWlZoe7VGQJA3mkQW9wqAgSRqKYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElS\nJsOCJEnKZFiQJEmZDAuSJCmTYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElSJsOCJEnKZFiQJEmZ\nDAuSJCmTYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElSJsOCJEnKZFiQJEmZDAuSJCmTYUGSJGVq\n6rAwf82H6F7dTW9vb6NLkSSpqfX29tK9upv5az5U9XVHSqnqKx2tiJgO7OYxKPyowNQ7p9KztYeO\njo5GlyZJUtPp7e1l5tUz2XvDXoqvLcKbAJiRUtpTjfU39ZEFAorziuxdsZeb1tzU6GokSWpKq+5Y\nVQoK84oQ1V//6dVfZfUV5xXZcucWlrK00aVIktR0tuzcQvHmYs3Wn4uwQMDBcQeZkWbUJDFJkpRb\nCTiDmn4/5iMsJJhwdAIPxAONrkSSpOYSMP/ofJ5Pz9csMOQiLBQeKnDNJdcwnemNLkWSpKazaNYi\n1u9YX+pZqIHmvxrihQJT13o1hCRJw3nlaogVeym+rs2uhphw3Tks37XcoCBJUoaOjg56tvawfNdy\nJlx3TtXX39RHFnZv2MD0xYsbXY4kSbmxZ+NGZixZAu1yZEGSJDWeYUGSJGUyLEiSpEyGBUmSlMmw\nIEmSMhkWJElSJsOCJEnKZFiQJEmZDAuSJCmTYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElSJsOC\nJEnKVFFYiIgPRMS/RMSLEfGdiHhzxrxvj4jioNfLEfG6ysuWTkgpNbqEtpPnMS8Wi40uoe3keX9R\nSdlhISJ+B1gDrAZ+FfgHYEdEnJOxWAJeD5zb/5qQUnqh/HKlkt7eXrq7VzNlymwmTVrIlCmz6e5e\nTW9vb6NLa1l5HvODBw/S2TmX00+/mDFj3sbpp19MZ+dcDh482OjSWlae9xcNIaVU1gv4DrBuwM8B\n7AduHGb+twMvA2eWsY3pQNq9YUOSBjty5EiaNu3yVChsT1BMkBIUU6GwPU2bdnk6cuRIo0tsOXke\n8wMHDqSxY1+f4Gsn1Q5fS2PHvj4dOHCg0SW2nDzvL61g94YNidIf6dNTmd/xw73KOrIQEWOAGcA3\nBoSNBHwdmJm1KPB4RByMiIcj4q3lbFcaaNWqO9i79waKxXmUdi2AoFicx969K7jppjWNLK8l5XnM\nr7hiGceO3QVcycDa4UqOHVvLlVe+t3HFtag87y8a2ullzn8OcBpwaND0Q8BFwyzzPPAHwGPAWOA6\n4JGIeEtK6fEyty9x//07KRZvHvK9YnEe27bdybp19a2p1eV5zJ966gBwxTDvXsmTT97Enj31rKj1\nbdmS3/1FQys3LJQtpfQM8MyASd+JiF8AVgBLs5ZdsWYNZ91330nTurq66OrqqnqdyoeUEn19Z3Di\nr5XBgsOHx3HkSOLMM4ebRyNVLMK3v5340Y+yx7yvbxwpJSKaa8yLxSIpnU1W7cXi+cyYkTLmUXkS\nkM/9JY82bdrEpk2bTpp2eP/+qm+n3LDwY0r9B+MHTR8P/LCM9XwXmHWqmdauXMn0xYvLWK1aXUQw\nZsxRSh9IQ33QJP7934/yutcFV1wB11wDV10FHR11LjTHikXo6YGvfAW2bIEDB4JCIXvMx4w52pQf\n/IVCgYj/S1bthcK/8nd/13y151cwf/5Rnn8+f/tLHg31B/SejRuZsWRJVbdTVlhIKfVFxG7gHcA2\ngCj9H38HcHcZq3ojpdMTUtmuumoW69fv6D8ferJC4SGWLr2EadNg82ZYvBjGjsXgcAqvDggwYQIs\nWgTvehfcd98sPvWp4cd8wYJLGlD1yEybNpEnnthOqWdhsAf55V8ez/Tp9a6qtS1alP1vtJn3Fw2j\n3I5I4F3AfwLvAX4R+AzwE+C1/e/fDnxpwPzXAwuAXwCmAXcBfcBlGdvwaggN60Sn9YODOq0ffFWn\n9b59Kd1xR0pveUtKkNLYsSktXJjSxo0ptXtD9ssvp/Stb6V0/fUpTZxYGp8JE1L64AdT+tu/Lb1/\nXDlj3mxOXA3xwKCrIR7waogayfP+0gpqcTVEZQvB+4F9wItAD/CmAe99EfjmgJ8/AnwPOAr8iNKV\nFG87xfoNC8p05MiR1N29Ok2ePDtNnLggTZ48O3V3r878EDI4lBcQBqtkzJvFgQMHUmfn3HTaaRcn\nmJVOO+3i1Nk516BQQ3neX/KuFmEhUmq+O2tFxHRg9+4NG+xZ0CmlChqlnnuudLh982b47ndb+1TF\nqU4xvPWtUCjz9myVjHkz2LMHZswosnt3wVMPdZTX/SWvBvQszEgpVeVan5pfDSHVWiUfQj//87By\nZek1MDi0So9DLQLCQPn+4PeROPWW7/1FYFiQWiY41DogSGpfhgVpgLwFBwOCpHowLEjDaNbgYECQ\nVG+GBWkEGh0cDAiSGsmwIJWpXsHBgCCpWRgWpFGodnAwIEhqRoYFqUoqDQ4GBEnNzrAg1cCpgsO8\nedDZCS+8APffb0CQ1NwMC1KNHQ8OK1bAX/0V3HUXPPggfPWrpfcvuAD+5E/ghhvgrLMaW6skDcW/\nXaQaKhZh5064/no4//zSkYMf/ADe977SaYdPfhLOOQc+9jEYPx5+67fg3nuht7fRlUvSCR5ZkKrs\neA/C5s2wdevJpxiuuQZmzTr5FMOHP9xc93GQpMEMC1IVlBsQBmv0fRwkKYthQarQaAPCcAwOkpqN\nYUEqQ60CwnAMDpKagWFBOoV6B4ThGBwkNYphQRpCswSE4RgcJNWTYUHq1+wBYTgGB0m1ZlhQW8tr\nQBiOwUFSLRgW1HZaLSAMx+AgqVoMC8q9lBIRkTlPuwSE4VQ7OIxkzJtXAvJau9QYLfzxqFbW29tL\nd/dqpkyZzaRJC5kyZTbd3avpHXCf5MG3Wr7kktItln/7t+HRR2H/frj7brj00tYOCoMdDw67dsG+\nfXDbbXDwYCk4vPa1w99yeiRj3qyO1z5//mxgIfPn56d2qSmklJruBUwH0u4NG5I02JEjR9K0aZen\nQmF7gmKClKCYCoXtadq0y9PDDx9J3d0pTZyYEqQ0YUJKH/xgSo8+mtLLLze6+ua1b19Kd9yR0lve\nUhq3sWNTWrgwpY0bUzpwIHvMjxw50ujyh3Wq/aWZa5cqsXvDhkTpENr0VKXv5Tb6e0qtYtWqO9i7\n9waKxXmcOJwcFIvzeOqpFcyZs8YjCBXIOuIwadIdPPXU0GO+d+8KbrppTQMrz5a1vzR77VKz8KNT\nuXP//TspFucO8+48zj13pwFhlAYHh7PO2gkMPebF4jy2bdtZ1/rKkbW/NHvtUrPwY1S5klKir+8M\nhm9QC047bRwRqZ5ltbTzz0+MG5c95n19446fQmwqI9lfmrV2qZkYFpQrEcGYMUcpnY4bSmLMmKM5\n7tRvPnke8zzXLjUTw4Jy56qrZlEo7BjyvULhIRYsuKTOFbW+PI95nmuXmoVhQblz220fZurUOykU\ntnPiL8ZEobCdqVPXcuutKxtZXkvK85jnuXapWRgWlDsdHR309Gxl+fJdTJ48h4kT38nkyXNYvnwX\nPT1b6fB2hFWX5zHPc+1Ss4hmbOyJiOnA7t0bNjB98eJGl6Mml3J9N8F8yvOY57l2aST2bNzIjCVL\nAGaklPZUY50eWVDu+cFff3ke8zzXLjWKYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElSJsOCJEnK\nZFiQJEmZDAuSJCmTYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElSJsOCJEnKZFiQJEmZDAuSJCmT\nYUGSJGUyLEiSpEyGBUmSlMmwIEmSMhkWJElSJsOCJEnKZFiQJEmZDAuSJCmTYUGSJGUyLEiSpEyG\nBUmSlMmwIEmSMhkWJElSJsOCJEnKZFiQJEmZDAuSJCmTYUGSJGUyLOgVmzZtanQJbccxrz/HvP4c\n8/yrKCxExAci4l8i4sWI+E5EvPkU818WEbsj4qWIeCYillZWrmrJf9D155jXn2Nef455/pUdFiLi\nd4A1wGrgV4F/AHZExDnDzD8ZeAD4BtAJrAP+IiIuP9W2uq69lrmdnRw8eLDcMiVJaisHDx5kbmcn\nXddeW/V1V3JkYQXwmZTS/04p/TPwPuA/gfcOM/8fAs+mlG5MKT2dUloPbOlfT6Z7U+L6J57gsgsu\nMDBIkjSMgwcPctkFF3D9E09wb0pVX39ZYSEixgAzKB0lACCllICvAzOHWezX+98faEfG/Ce2B1wJ\nrD12jPdeeWU5pUqS1DaWXXEFdx07xpWUvjur7fQy5z8HOA04NGj6IeCiYZY5d5j5z4yIsSmlY0Ms\n8xqAvQNW8P0nn2TPxo1llqtyHN6/3zGuM8e8/hzz+nPMa+/7Tz7JeGAPJ7476f8urYZIZRyuiIgJ\nwAFgZkpp14DpHwfellJ61dGCiHga+EJK6eMDpl1BqY9h3FBhISLeDbhnSZJUucUppXursaJyjyz8\nGHgZGD9o+njgh8Ms88Nh5j8yzFEFKJ2mWAzsA14qs0ZJktrZa4DJlL5Lq6KssJBS6ouI3cA7gG0A\nERH9P989zGI9wBWDps3pnz7cdn4CVCUNSZLUhr5dzZVVcjXEncB1EfGeiPhF4NPAOOB/AUTE7RHx\npQHzfxq4ICI+HhEXRcT7gUX965EkSU2u3NMQpJQ2999T4WOUTic8DsxNKf2of5ZzgUkD5t8XEb8J\nrAW6gf3A76eUBl8hIUmSmlBZDY6SJKn9+GwISZKUybAgSZIyNSQs+CCq+itnzCPityLi4Yh4ISIO\nR8S3I2JOPettBeXu5wOWmxURfRGxp9Y1tpoKPlt+JiJui4h9/Z8vz0bE79Wp3JZQwZgvjojHI+Jo\nRByMiM9HxM/Vq968i4hLI2JbRByIiGJELBjBMqP+Dq17WKjng6hUUu6YA28DHqZ0yet04K+B+yOi\nsw7ltoQKxvz4cmcBX+LVt0jXKVQ45l8BfgNYBrwB6AKernGpLaOCz/NZlPbvzwG/ROnKuLcAn61L\nwa3hDEoXFrwfOGXTYdW+Q1NKdX0B3wHWDfg5KF0hceMw838ceGLQtE3Ag/WuPa+vcsd8mHX8I3BT\no3+XvLwqHfP+ffsWSh++exr9e+TpVcFnyzzgp8DZja49r68Kxnwl8L1B05YD/9ro3yWPL6AILDjF\nPFX5Dq3rkYV6P4hKFY/54HUE0EHpg1WnUOmYR8QyYAqlsKAyVDjmVwGPAR+NiP0R8XREfDIiqnY/\n/VZW4Zj3AJP6b/lPRIwHrgG+Vttq21pVvkPrfRoi60FU5w6zTOaDqKpbXkuqZMwH+wilQ1+bq1hX\nKyt7zCPi9cCfUbqXe7G25bWkSvbzC4BLgWnAQuB6SofF19eoxlZT9pinlL4NLAHui4j/Ap4H/p3S\n0QXVRlW+Q70aQpn6H+r1P4FrUko/bnQ9rSgiCpQenLY6pfSD45MbWFK7KFA6jPvulNJjKaWHgBuA\npf4hUhvP+M3WAAACNUlEQVQR8UuUzpnfTKkfai6lo2mfaWBZGoGy7+A4SvV6EJVOqGTMAYiI36XU\neLQopfTXtSmvJZU75h3Am4A3RsTxv2oLlM4A/RcwJ6X0SI1qbRWV7OfPAwdSSv8xYNpeSkHtfwA/\nGHIpHVfJmP8RsDOldPx2///Y/wiAv42IVSmlwX8Ba/Sq8h1a1yMLKaU+4PiDqICTHkQ13EMvegbO\n3y/zQVQ6ocIxJyK6gM8Dv9v/F5dGqIIxPwJcDLyRUrdyJ6Vnqvxz/3/vGmIZDVDhfr4TOC8ixg2Y\ndhGlow37a1Rqy6hwzMcB/2/QtCKlrn6PptVGdb5DG9C9+S7gP4H3AL9I6fDTT4DX9r9/O/ClAfNP\nBnopdXReROlykf8CZje6EzUvrwrG/N39Y/w+Sgn0+OvMRv8ueXmVO+ZDLO/VEDUec0p9OM8B9wFT\nKV0y/DTw6Ub/Lnl5VTDmS4Fj/Z8tU4BZwHeBbzf6d8nLq3+/7aT0x0UR+FD/z5OGGfOqfIc26pd9\nP7APeJFSunnTgPe+CHxz0Pxvo5RgXwS+B1zb6P9heXuVM+aU7qvw8hCvLzT698jTq9z9fNCyhoU6\njDmleyvsAP6jPzh8Ahjb6N8jT68KxvwDwJP9Y76f0n0XJjT698jLC3h7f0gY8vO5Vt+hPkhKkiRl\n8moISZKUybAgSZIyGRYkSVImw4IkScpkWJAkSZkMC5IkKZNhQZIkZTIsSJKkTIYFSZKUybAgSZIy\nGRYkSVKm/w/bIK8+rrkFogAAAABJRU5ErkJggg==\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "pts, lines, line_tag_description = merge_fracs_compartments_domain(domain, frac_pts, frac_con, compartment_lines)\n", + "plot_fractures(domain, pts, lines, lines[-2]) " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We are now ready to remove intersections between fractures" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "image/png": "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\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "dx = np.array([[domain['xmax'] - domain['xmin']], [domain['ymax'] - domain['ymin']]])\n", + "pts_split, lines_split = basics.remove_edge_crossings(pts, lines, box=dx)\n", + "\n", + "# Plot the fractures again, with the line-type coloring. This should be equal to the previous one\n", + "plot_fractures(domain, pts_split, lines_split, lines_split[-2]) " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We are now finally in the position where gmsh can process the data. The next step is to write the .geo file" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Write the domain description to a GMSH .geo file" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We define a class for the gmsh writer" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "def sort_point_pairs(lines, check_circular=True):\n", + " \"\"\" Sort pairs of numbers to form a chain.\n", + " \n", + " The target application is to sort lines, defined by their \n", + " start end endpoints, so that they form a continuous polyline.\n", + " \n", + " The algorithm is brute-force, using a double for-loop. This can \n", + " surely be imporved.\n", + " \n", + " lines: np.ndarray, 2xn, the line pairs.\n", + " check_circular: Verify that the sorted polyline form a circle.\n", + " Defaluts to true.\n", + " \"\"\"\n", + " \n", + " num_lines = lines.shape[1]\n", + " sorted_lines = -np.ones((2, num_lines))\n", + " \n", + " # Start with the first line in input\n", + " sorted_lines[:, 0] = lines[:, 0]\n", + " \n", + " # The starting point for the next line\n", + " prev = sorted_lines[1, 0]\n", + " \n", + " # Keep track of which lines have been found, which are still candidates\n", + " found = np.zeros(num_lines, dtype='bool')\n", + " found[0] = True\n", + " \n", + " # The sorting algorithm: Loop over all places in sorted_line to be filled, \n", + " # for each of these, loop over all members in lines, check if the line is still\n", + " # a candidate, and if one of its points equals the current starting point.\n", + " # More efficient and more elegant approaches can surely be found, but this \n", + " # will do for now.\n", + " for i in range(1, num_lines): # The first line has already been found\n", + " for j in range(0, num_lines):\n", + " if not found[j] and lines[0, j] == prev:\n", + " sorted_lines[:, i] = lines[:, j]\n", + " found[j] = True\n", + " prev = lines[1, i]\n", + " break\n", + " elif not found[j] and lines[1, j] == prev:\n", + " sorted_lines[:, i] = lines[::-1, j]\n", + " found[j] = True\n", + " prev = lines[1, i]\n", + " break\n", + " # By now, we should have used all lines\n", + " assert(np.all(found))\n", + " if check_circular:\n", + " assert sorted_lines[0, 0] == sorted_lines[1, -1]\n", + " return sorted_lines" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import tempfile\n", + "\n", + "class GmshWriter(object):\n", + " \n", + " def __init__(self, pts, lines, file_name=None, nd=None):\n", + " self.pts = pts\n", + " self.lines = lines\n", + " if nd is None:\n", + " if pts.shape[0] == 2:\n", + " self.nd = 2\n", + " elif pts.shape[0] == 3:\n", + " self.nd = 3\n", + " else:\n", + " self.nd = nd\n", + " \n", + " if file_name is None:\n", + " fn = tempfile.mkstemp\n", + "# self.geo_file = fn + '.geo'\n", + " else:\n", + " self.geo_file_name = file_name + '.geo'\n", + " \n", + " def write_geo(self, file_name):\n", + " s = self.__write_points()\n", + " \n", + " if self.nd == 2:\n", + " s += self.__write_boundary_2d()\n", + " s += self.__write_fractures_compartments_2d()\n", + " else:\n", + " raise NotImplementedError('No 3D yet')\n", + " \n", + " with open(file_name, 'w') as f:\n", + " f.write(s)\n", + " \n", + " def __write_fractures_compartments_2d(self):\n", + " # Both fractures and compartments are \n", + " constants = GmshConstants()\n", + " \n", + " frac_ind = np.argwhere(np.logical_or(self.lines[2] == constants.COMPARTMENT_BOUNDARY_TAG, \n", + " self.lines[2] == constants.FRACTURE_TAG)).ravel()\n", + " frac_lines = self.lines[:, frac_ind]\n", + " \n", + " s = '// Start specification of fractures \\n'\n", + " for i in range(frac_ind.size):\n", + " s += 'frac_line_' + str(i) + ' = newl; Line(frac_line_' + str(i) + ') ={'\n", + " s += 'p' + str(int(frac_lines[0, i])) + ', p' + str(int(frac_lines[1, i])) + '}; \\n'\n", + " s += 'Physical Line(\\\"' + constants.PHYSICAL_NAME_FRACTURES + str(i) + '\\\") = { frac_line_' + str(i) + ' };\\n'\n", + " s += 'Line{ frac_line_' + str(i) + '} In Surface{domain_surf}; \\n' \n", + " \n", + " s += '// End of fracture specification \\n\\n'\n", + " return s\n", + " \n", + " \n", + " def __write_boundary_2d(self):\n", + " constants = GmshConstants()\n", + " bound_line_ind = np.argwhere(self.lines[2] == constants.DOMAIN_BOUNDARY_TAG).ravel()\n", + " bound_line = self.lines[:2, bound_line_ind]\n", + " bound_line = sort_point_pairs(bound_line, check_circular=True)\n", + " \n", + " s = '// Start of specification of domain'\n", + " s += '// Define lines that make up the domain boundary \\n'\n", + " \n", + " loop_str = '{'\n", + " for i in range(bound_line.shape[1]):\n", + " s += 'bound_line_' + str(i) + ' = newl; Line(bound_line_' + str(i) + ') ={'\n", + " s += 'p' + str(int(bound_line[0, i])) + ', p' + str(int(bound_line[1, i])) + '}; \\n'\n", + " loop_str += 'bound_line_' + str(i) + ', '\n", + " \n", + " s += '\\n'\n", + " loop_str = loop_str[:-2] # Remove last comma\n", + " loop_str += '}; \\n'\n", + " s += '// Line loop that makes the domain boundary \\n'\n", + " s += 'Domain_loop = newll; \\n'\n", + " s += 'Line Loop(Domain_loop) = ' + loop_str\n", + " s += 'domain_surf = news; \\n'\n", + " s += 'Plane Surface(domain_surf) = {Domain_loop}; \\n'\n", + " s += 'Physical Surface(\\\"' + constants.PHYSICAL_NAME_DOMAIN + '\\\") = {domain_surf}; \\n'\n", + " s += '// End of domain specification \\n \\n'\n", + " return s\n", + " \n", + " def __write_points(self):\n", + " p = self.pts\n", + " num_p = p.shape[1]\n", + " if p.shape[0] == 2:\n", + " p = np.vstack((p, np.zeros(num_p)))\n", + " s = '// Define points \\n'\n", + " for i in range(self.pts.shape[1]):\n", + " s += 'p' + str(i) + ' = newp; Point(p' + str(i) + ') = '\n", + " s += '{' + str(p[0, i]) + ', ' + str(p[1, i]) + ', ' + str(p[2, i]) + ', 0.05};\\n'\n", + " s += '// End of point specification \\n \\n'\n", + " return s\n", + " \n", + " " + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Define name of input and output file for gmsh. The input should have suffix .geo, the output .msh" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "in_file = 'gmsh_test.geo'\n", + "out_file = 'gmsh_test.msh'" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "gw = GmshWriter(pts_split, lines_split)\n", + "gw.write_geo(in_file)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Create grid with gmsh" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "With the .geo-file written, the next step is to create a grid by gmsh. This part naturally requires access to gmsh, so you will need to change this to fit with your installation. \n", + "\n", + "The simplest option is probably to start gmsh, import the .geo-file, create a 2D grid, and save. This will also allow you to tweak the geometry or the mesh parameters, refine the grid and so on. All of this can of course be added to the .geo-file, but a look at the grid often helps in deciding what modifications to do.\n", + "\n", + "The solution below runs gmsh from command line." + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import sys, os\n", + "\n", + "# Manually enter the path to gmsh; better options must surely exist\n", + "if sys.platform == 'windows' or sys.platform == 'win32':\n", + " path_to_gmsh = '~/gmsh/bin/gmsh' #C:\\Users\\Eirik\\Dropbox\\workspace\\python\\gridding\\gmsh\\gmsh_win_214'\n", + "else:\n", + " path_to_gmsh = '~/gmsh/bin/gmsh'#'/home/eke001/Dropbox/workspace/lib/gmsh/run/linux/gmsh'\n", + "\n", + "# The -2 option tells gmsh to create a 2D grid\n", + "cmd = path_to_gmsh + ' -2 ' + in_file + ' -o ' + out_file" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Gmsh can handle fairly large grids (hundreds of thousands of elements) in a matter of seconds, although it may require more time if the fracture geometry is complex relative to the desired grid resolution. Here, we run gmsh using os.system; there may be more appropriate ways of doing this." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "GMSH completed successfully\n" + ] + } + ], + "source": [ + "status = os.system(cmd)\n", + "\n", + "# Make sure we take note of whether gmsh did the job \n", + "if status == 0:\n", + " print('GMSH completed successfully')\n", + "else:\n", + " print('Gmsh ran into problems')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Read mesh info from file and identify fracture edges" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now that gmsh has created our mesh, we read it back into python. There are several python packages available that does the reading and parsing of a .msh file for us. We will use meshio, see https://pypi.python.org/pypi/meshio for details. \n", + "\n", + "meshio will give us all information needed for building the grid, but we still need to work a bit to identify edges that lies on individual fractures. Here, we use a slightly modified version of meshio." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "from gridding.gmsh import mesh_io\n", + "points, cells, physnames, cell_info = mesh_io.read(out_file)\n", + "# gmsh works with 3D points, whereas we only need 2D\n", + "points = points[:, :2].transpose()" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "from core.grids import simplex\n", + "triangles = cells['triangle'].transpose()\n", + "\n", + "# Construct grid\n", + "g = simplex.TriangleGrid(points, triangles)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "data": { + "image/png": "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\ng09gfvHixVdk2v89XM3zCv98f+HqnNt169YxZ84cNm3axKpVq6itraVfv35UVVX93X2u9rkNQEFE\nfuwvgH8jli5dKjabTZYsWSL79u2TW265RSIiIuT8+fPf2/7YsWMSFBQkd9xxh+zfv1+eeOIJMZvN\nsnLlyh88V8eOHWXu3LnGvz0ejyQlJckDDzzwve1zc3OlRYsWV2wbN26cDBgw4J/o4X8H/2xf16xZ\nI7quy6VLl36qS/yPQNM0Wb58+T9sczXP63fxY/p7rczt+fPnRdM0Wbdu3d9tcy3N7TWMH1xnAxby\nfwFPPvkkkydPxu12s3jxYsrLy3n66adxOp288MILV7R977336NevH5mZmVRVVbF+/XpOnDjB7Nmz\nGT16NI888sg/PFdtbS1bt26ld+/exjZN0+jTpw8bNmz43n3+nhD532v/c8G/0lfwfpS2bt2axMRE\n+vXrx1dfffVTXO5Pjqt1Xv9/cC3MbWlpKZqmERkZ+Xfb/BLn9lpEYEH+ieBzT1utVubMmYPL5eKx\nxx4zXKoXL1783oVj6dKlrF69GhFh8uTJ9OzZkyFDhrBjx44f9cBduHCBurq67xUWLyws/N59fkiI\n/OeKf6WvCQkJPPPMM7zzzju8++67pKSk0KNHD7755puf4pJ/Ulyt8/qv4lqYWxHh/7V3/zFV1X8c\nx1/nXu66wLUBwUCBsIn0Y8skZ8OWppVF/0ibhVHJjawpjSX5h+RaVv81miMjV6smA/+xza3unGVU\nXsEVWViMOZJGBa3wx1qjBTL7wfv7h+MU+xqCgvdcfD62u8G5n7N9Puezc1/3nPs5n091dbVuu+02\n3XDDDf9Z7nLr25mKx54ugdHfNd98801t27ZNZ86cUXd3t+bNm6fKykrt27dPO3fuVGZmprq7u939\nfvvtNx05ckR33323otGorr32WtXU1CgSiWjv3r1atGiRe8KxHumFKSgoUEFBgft/UVGRvvvuO9XV\n1TEoJs7NhL598skn1dXVpU8//TTWVcElwBXyJVBXV6f169errKxMXV1dqqmpkSTt3bt33FuqGzZs\n0MMPP6yioiJ3m5np999/H/f21b+lp6fL7/efc2HxrKysc+4TrwuRX0hbz+WWW25RT0/PVFcv5uK1\nX6dSPPVtVVWV3n//fR08eFCzZ88etyx9OzMQyNNo9Db14cOHtX//fn388cf6+++/VVBQIMdx1N7e\nLumfW6r9/f369ddfNXfuXAUCAb377rvKy8uTJAUCAZ08eVIvv/yyhoaGVFpaOqETLhAIaNGiRWMW\nFjczffLJJ7r11lvPuU+8LkR+IW09l46OjvN+AMajeO3XqRQvfVtVVaVIJKJoNOqucT4e+naGmMjI\nL2OU9aSNjqLevn27SbL77rvPUlJSTJJ9/vnnNnv2bMvKyjKzsyMki4qKLBgMWl5enjU1NVlGRobt\n2bPHPvvsM3vhhRcsMzPTcnNzLRQK2YEDB8zMrKysbEKjKN955x1LTEwcM6I7LS3NTp06ZWZmzzzz\njJWXl7vlf/jhBwuFQrZ582Y7duyY7dixwwKBgH300UfTcKSm1mTb+sorr1gkErGenh47evSobdy4\n0RISEiwajcaoBRM3ODhoHR0d9vXXX5vjOFZXV2cdHR32448/mtnM6lezybc3Xvu2srLSUlJSrLW1\n1U6cOOG+hoeH3TJbtmyZUX17mThvzhLIU+i1116zuXPnWjAYtOTkZFuzZo319/eb4zjW1tZm2dnZ\n5vP5LBKJ2KpVq0ySXX311eb3+83n85kkO3z4sC1evNiKi4vdE+7555+3nJwck2T333//BZ1wO3bs\nsLy8PAsGg1ZUVGRffvml+96jjz5qK1asGFO+paXFbr75ZgsGg5afn29NTU1Td6Cm2WTaWltba/n5\n+ZaUlGTp6el2xx13WEtLSyyqPWkHDx40x3HM5/ONeS1dutSuuuoqKy8vH9PWkpISu+eee+K2X/+r\nvRUVFWY2c/r2XG30+XzW2Njolplp5+xlgkC+VP79XHFnZ6c5jmOhUMj6+/stISHBIpGIhcNhS01N\ntaeeesruvfdek2SpqakmyRITEy0xMdHy8/NNZ6cpNUnm9/vNcRz37/nz53PCYVzDw8OWmppqe/bs\ncbedOnXKAoFAXAQSMEPxHPJ0+vdUfg8++KBWrlyp8vJypaeny8yUmJioXbt2ub9rZmZmKikpSfX1\n9frggw/k8/l00003KS0tTUuWLNGff/6p3t5eLVu2TG+88Yays7NVWFgox3GUlZWlaDSq5uZm9fb2\n6siRI1q7dm2sDwE8KBgMqqysTA0NDe62Xbt2KS8vT8uWLYthzQCMh0C+CKNT+b366quSpAULFrjv\nOY6jxYsXq62tTZs2bdJbb72lL774QsePH9ecOXMkSSMjI+50mEePHtXIyIiuueYa+f1+rV+/XvX1\n9Wpvb9fIyIhOnDih5cuXa968eZozZ46qq6tj0mbEhyeeeELNzc06fvy4JKmxsVEVFRUxrhWA8fAc\n8kUoLi5WcXGx+6GXkpIi6Z/Hb4LBoPr7+1VaWqpffvlF1dXVGhkZUW5urt577z319vaqrKzMnW82\nOTlZ33zzjfx+vyTp+uuvl8/nU1VVlQ4dOqSvvvoqNg1F3Fm4cKEWLFigpqYmrVy5Ul1dXQqHw7Gu\nFoBxcIU8DUYfv+nr63O3VVZWynEcLV26VG1tbUpKStLGjRu1evVqzZo1S9XV1RoeHta6devcfbq7\nu+Xz+VRbW0sYY9Ief/xxNTQ0qKGhQXfddZeys7NjXSUA4yCQp0B6erqks3POjtq0aZM6Ojr0xx9/\n6NixY9qwYYP++usv3X777ZKk0tJSBQIBhcNhDQ0N6bnnnlNGRoYaGxt16NAhtba2avPmzVq3bh0P\n9uOCPPTQQ/rpp5/09ttvj/miB8CbCOQpEAgEJEmdnZ3utgceeECzZs3S999/r8LCQnV2dionJ0eh\nUEiSNDg4OGY5teTkZG3fvl3S2Unh165dq5KSEncbMFlXXnmlVq9erVAopJKSklhXB8B5EMhTxHEc\nNTc3q6mpyb0i9vl8+vbbbzU8PKzly5frzJkz7vR2L774ogYGBrR7926FQiG1t7dr27ZtWrJkiU6f\nPq2+vj7V1tZydYyL8vPPP+uRRx5xvzQC8C4GdU2hiooKbd26VSdPntTChQv14YcfKiMjQ9LZ1Viu\nuOIKd3q7cDiswcFBPfvssxoaGtKaNWt055136qWXXoplEzBDDAwMKBqNqqWlRa+//nqsqwNgAgjk\nizA0NKSenp6zM6xIuu6661RZWam0tDTl5uZqy5Ytqq+vV2NjoxoaGtTb26sbb7xRNTU1euyxx+Q4\njk6fPq39+/f/31qmwMUoLCzUwMCAamtrNX/+/FhXB8AEOKNhMgETLni5aGlp0YoVK+Q4zpjt4XBY\nO3fuVEVFhfr6+nTgwAH3vdbWVj399NPq6upSTk6Otm7dygQfADDzOectQCADADDtzhvIDOoCAMAD\nCGQAADyAQAYAwAMIZAAAPIBABgDAAwhkAAA8gEAGAMADCGQAADyAQAYAwAMIZAAAPIBABgDAAwhk\nAAA8gEAGAMADCGQAADyAQAYAwAMIZAAAPIBABgDAAwhkAAA8gEAGAMADCGQAADyAQAYAwAMIZAAA\nPIBABgDAAwhkANwIfg4AAACeSURBVAA8gEAGAMADCGQAADyAQAYAwAMIZAAAPIBABgDAAwhkAAA8\ngEAGAMADCGQAADyAQAYAwAMIZAAAPIBABgDAAwhkAAA8gEAGAMADCGQAADyAQAYAwAMIZAAAPCBh\nEmWdaasFAACXOa6QAQDwAAIZAAAPIJABAPAAAhkAAA8gkAEA8AACGQAADyCQAQDwAAIZAAAPIJAB\nAPCA/wGAwpq32cHTtgAAAABJRU5ErkJggg==\n", + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "1" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from viz import plot_grid\n", + "g.compute_geometry()\n", + "plot_grid.plot_grid(g)" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "def __match_face_fractures(cell_info, phys_names):\n", + " # Match generated line cells (1D elements) with pre-defined fractures\n", + " # via physical names.\n", + " # Note that fracs and phys_names are both representation of the\n", + " # fractures (after splitting due to intersections); fracs is the\n", + " # information that goes into the gmsh algorithm (in the form of physical\n", + " # lines), while phys_names are those physical names generated in the\n", + " # .msh file\n", + " line_names = phys_names['line']\n", + "\n", + " # Tags of the cells, corrensponding to the physical entities (gmsh\n", + " # terminology) are known to be in the first column\n", + " cell_tags = cell_info['line'][:, 0]\n", + " \n", + " # Adjust the fracture tags so that the offset is the same as that provided by gmsh\n", + "\n", + " # Prepare array for tags. Assign nan values for faces an a starting\n", + " # point, these should be overwritten later\n", + " cell_2_frac = np.zeros(cell_tags.size)\n", + " cell_2_frac[:] = np.nan\n", + "\n", + " # Loop over all physical lines, compare the tag of the cell with the tag\n", + " # of the physical line.\n", + " for iter1, name in enumerate(line_names):\n", + " line_tag = name[1]\n", + " frac_name = name[2]\n", + " ind = frac_name.find('_')\n", + " cell_2_frac[np.argwhere(cell_tags == line_tag)] = frac_name[ind+1:]\n", + "\n", + " # Sanity check, we should have asigned tags to all fracture faces by now\n", + " assert np.all(np.isfinite(cell_2_frac))\n", + " return cell_2_frac" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "collapsed": false + }, + "outputs": [ + { + "ename": "NameError", + "evalue": "name 'line_2_frac' is not defined", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mNameError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m()\u001b[0m\n\u001b[1;32m 11\u001b[0m \u001b[0mface_tags\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mnp\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mzeros\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mg\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mnum_faces\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 12\u001b[0m \u001b[0mface_tags\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;32mNone\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m---> 13\u001b[0;31m \u001b[0mface_tags\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mfrac_ind\u001b[0m\u001b[0;34m]\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mline_2_frac\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mNameError\u001b[0m: name 'line_2_frac' is not defined" + ] + } + ], + "source": [ + "frac_face_nodes = cells['line'].transpose()\n", + "\n", + "# Nodes of faces in the grid\n", + "face_nodes = g.face_nodes.indices.reshape((2, g.num_faces), order='F').astype('int')\n", + "\n", + "\n", + "from utils import setmembership\n", + "ia, frac_ind = setmembership.ismember_rows(np.sort(frac_face_nodes, axis=0), np.sort(face_nodes, axis=0))\n", + "\n", + "# Assign tags according to fracture faces\n", + "face_tags = np.zeros(g.num_faces)\n", + "face_tags[:] = None\n", + "face_tags[frac_ind] = line_2_frac" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "frac_face_tags = __match_face_fractures(cell_info, physnames).astype('int')\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "frac_num = np.ravel(lines_frac_face[1, np.argwhere(lines_frac_face[0] == 3)]) - sum(lines[2])" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": true + }, + "outputs": [], + "source": [ + "real_frac_ind = np.ravel(np.argwhere(lines_frac_face[0] == 3))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "frac_ind" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "lines[2]" + ] + } + ], + "metadata": { + "anaconda-cloud": {}, + "kernelspec": { + "display_name": "Python [conda root]", + "language": "python", + "name": "conda-root-py" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.5.2" + } + }, + "nbformat": 4, + "nbformat_minor": 1 +} diff --git a/src/porepy/grids/tests/__init__.py b/src/porepy/grids/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/porepy/grids/tests/gmsh.py b/src/porepy/grids/tests/gmsh.py new file mode 100644 index 0000000000..df551ae305 --- /dev/null +++ b/src/porepy/grids/tests/gmsh.py @@ -0,0 +1,31 @@ +import numpy as np +import os + +from gridding.gmsh import fractured_domain_2d, mesh_io + +def test_gmsh_2d_crossing_fractures(): + """ Check that no error messages are created in the process of creating a + gmsh geo file, running gmsh, and returning. + + Note that, for now, the path to the gmsh file is hard-coded into + gridding.gmsh.fractured_domain . Any changes here will lead to errors. + """ + p = np.array([[-1, 1, 0, 0], + [0, 0, -1, 1]]) + lines = np.array([[0, 2], + [1, 3]]) + + fracs = {'points': p, 'edges': lines} + + box = {'xmin': -2, 'xmax': 2, 'ymin': -2, 'ymax': 2, 'lcar': 0.7} + + filename = 'test_gmsh_2d_crossing_fractures' + fractured_domain_2d.generate_grid(fracs, box, filename) + geo_file = filename + '.geo' + os.remove(geo_file) + msh_file = filename + '.msh' + point, cells, phys_names, cell_info = mesh_io.read(msh_file) + os.remove(msh_file) + +if __name__ == '__main__': + test_gmsh_2d_crossing_fractures() diff --git a/src/porepy/grids/tests/test_coarsening.py b/src/porepy/grids/tests/test_coarsening.py new file mode 100644 index 0000000000..c37842828a --- /dev/null +++ b/src/porepy/grids/tests/test_coarsening.py @@ -0,0 +1,125 @@ +import numpy as np +import scipy as sps +import unittest + +from core.grids import structured, simplex +from gridding.coarsening import * + +#------------------------------------------------------------------------------# + +class BasicsTest( unittest.TestCase ): + +#------------------------------------------------------------------------------# + + def test_coarse_grid_2d( self ): + g = structured.CartGrid([3, 2]) + g = generate_coarse_grid( g, [5, 2, 2, 5, 2, 2] ) + + assert g.num_cells == 2 + assert g.num_faces == 12 + assert g.num_nodes == 11 + + pt = np.tile(np.array([2,1,0]), (g.nodes.shape[1],1) ).T + find = np.isclose( pt, g.nodes ).all( axis = 0 ) + assert find.any() == False + + faces_cell0, _, orient_cell0 = sps.find( g.cell_faces[:,0] ) + assert np.array_equal( faces_cell0, [1, 2, 4, 5, 7, 8, 10, 11] ) + assert np.array_equal( orient_cell0, [-1, 1, -1, 1, -1, -1, 1, 1] ) + + faces_cell1, _, orient_cell1 = sps.find( g.cell_faces[:,1] ) + assert np.array_equal( faces_cell1, [0, 1, 3, 4, 6, 9] ) + assert np.array_equal( orient_cell1, [-1, 1, -1, 1, -1, 1] ) + + known = np.array( [ [0, 4], [1, 5], [3, 6], [4, 7], [5, 8], [6, 10], + [0, 1], [1, 2], [2, 3], [7, 8], [8, 9], [9, 10] ] ) + + for f in np.arange( g.num_faces ): + assert np.array_equal( sps.find( g.face_nodes[:,f] )[0], known[f,:] ) + +#------------------------------------------------------------------------------# + + def test_coarse_grid_3d( self ): + g = structured.CartGrid([2, 2, 2]) + g = generate_coarse_grid( g, [0, 0, 0, 0, 1, 1, 2, 2] ) + + assert g.num_cells == 3 + assert g.num_faces == 30 + assert g.num_nodes == 27 + + faces_cell0, _, orient_cell0 = sps.find( g.cell_faces[:,0] ) + known = [0, 1, 2, 3, 8, 9, 10, 11, 18, 19, 20, 21, 22, 23, 24, 25] + assert np.array_equal( faces_cell0, known ) + known = [-1, 1, -1, 1, -1, -1, 1, 1, -1, -1, -1, -1, 1, 1, 1, 1] + assert np.array_equal( orient_cell0, known ) + + faces_cell1, _, orient_cell1 = sps.find( g.cell_faces[:,1] ) + known = [4, 5, 12, 13, 14, 15, 22, 23, 26, 27] + assert np.array_equal( faces_cell1, known ) + known = [-1, 1, -1, -1, 1, 1, -1, -1, 1, 1] + assert np.array_equal( orient_cell1, known ) + + faces_cell2, _, orient_cell2 = sps.find( g.cell_faces[:,2] ) + known = [6, 7, 14, 15, 16, 17, 24, 25, 28, 29] + assert np.array_equal( faces_cell2, known ) + known = [-1, 1, -1, -1, 1, 1, -1, -1, 1, 1] + assert np.array_equal( orient_cell2, known ) + + known = np.array( [ [0, 3, 9, 12], [2, 5, 11, 14], [3, 6, 12, 15], + [5, 8, 14, 17], [9, 12, 18, 21], [11, 14, 20, 23], + [12, 15, 21, 24], [14, 17, 23, 26], [0, 1, 9, 10], + [1, 2, 10, 11], [6, 7, 15, 16], [7, 8, 16, 17], + [9, 10, 18, 19], [10, 11, 19, 20], [12, 13, 21, 22], + [13, 14, 22, 23], [15, 16, 24, 25], [16, 17, 25, 26], + [0, 1, 3, 4], [1, 2, 4, 5], [3, 4, 6, 7], + [4, 5, 7, 8], [9, 10, 12, 13], [10, 11, 13, 14], + [12, 13, 15, 16], [13, 14, 16, 17], [18, 19, 21, 22], + [19, 20, 22, 23], [21, 22, 24, 25], + [22, 23, 25, 26] ] ) + + for f in np.arange( g.num_faces ): + assert np.array_equal( sps.find( g.face_nodes[:,f] )[0], known[f,:] ) + +#------------------------------------------------------------------------------# + + def test_create_partition_2d_cart(self): + g = structured.CartGrid([5, 5]) + g.compute_geometry() + part = create_partition(tpfa_matrix(g)) + known = np.array([0,0,0,1,1,0,0,2,1,1,3,2,2,2,1,3,3,2,4,4,3,3,4,4,4]) + assert np.array_equal(part, known) + +#------------------------------------------------------------------------------# + + def test_create_partition_2d_tri(self): + g = simplex.StructuredTriangleGrid([3,2]) + g.compute_geometry() + part = create_partition(tpfa_matrix(g)) + known = np.array([1,1,1,0,0,1,0,2,2,0,2,2]) + known_map = np.array([4,3,7,5,11,8,1,2,10,6,12,9])-1 + assert np.array_equal(part, known[known_map]) + +#------------------------------------------------------------------------------# + + def test_create_partition_2d_cart_cdepth4(self): + g = structured.CartGrid([10, 10]) + g.compute_geometry() + part = create_partition(tpfa_matrix(g), cdepth=4) + known = np.array([1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1, + 1,1,2,1,1,1,1,1,1,1,1,2,2,3,1,1,1,1,1,1,1,2,2,3,3,1,1, + 1,1,1,2,2,2,3,3,3,1,1,1,1,2,2,2,3,3,3,3,1,1,2,2,2,2,3, + 3,3,3,3,2,2,2,2,2,3,3,3,3,3,2,2,2,2,2])-1 + assert np.array_equal(part, known) + +#------------------------------------------------------------------------------# + + def test_create_partition_3d_cart(self): + g = structured.CartGrid([4,4,4]) + g.compute_geometry() + part = create_partition(tpfa_matrix(g)) + known = np.array([1,1,1,1,2,4,1,3,2,2,3,3,2,2,3,3,5,4,1,6,4,4,4,3,2,4,7, + 3,8,8,3,3,5,5,6,6,5,4,7,6,8,7,7,7,8,8,7,9,5,5,6,6,5,5, + 6,6,8,8,7,9,8,8,9,9])-1 + assert np.array_equal(part, known) + +#------------------------------------------------------------------------------# diff --git a/src/porepy/grids/tests/test_face_tags.py b/src/porepy/grids/tests/test_face_tags.py new file mode 100644 index 0000000000..9d94c0c31c --- /dev/null +++ b/src/porepy/grids/tests/test_face_tags.py @@ -0,0 +1,68 @@ +import unittest +import numpy as np + +from gridding.fractured import structured +from gridding.fractured import meshing +from core.grids.grid import FaceTag + + +class TestFaceTags(unittest.TestCase): + def test_x_intersection_2d(self): + """ Check that the faces has correct tags for a 2D grid. + """ + + f_1 = np.array([[0, 2], [1, 1]]) + f_2 = np.array([[1, 1], [0, 2]]) + + f_set = [f_1, f_2] + nx = [3, 3] + + grids = meshing.cart_grid(f_set, nx, physdims=nx) + + # 2D grid: + g_2d = grids.grids_of_dimension(2)[0] + + f_tags_2d = np.array([False, True, False, False, # first row + False, True, False, False, # Second row + False, False, False, False, # third row + False, False, False, # Bottom column + True, True, False, # Second column + False, False, False, # Third column + False, False, False, # Top column + True, True, True, True]) # Added faces + + d_tags_2d = np.array([True, False, False, True, # first row + True, False, False, True, # Second row + True, False, False, True, # third row + True, True, True, # Bottom column + False, False, False, # Second column + False, False, False, # Third column + True, True, True, # Top column + False, False, False, False]) # Added Faces + t_tags_2d = np.zeros(f_tags_2d.size, dtype=bool) + b_tags_1d = np.sum(f_tags_2d + d_tags_2d + + t_tags_2d, axis=0).astype(bool) + b_tags_2d = (f_tags_2d + d_tags_2d + t_tags_2d).astype(bool) + assert np.all(g_2d.has_face_tag(FaceTag.TIP) == t_tags_2d) + assert np.all(g_2d.has_face_tag(FaceTag.FRACTURE) == f_tags_2d) + assert np.all(g_2d.has_face_tag(FaceTag.DOMAIN_BOUNDARY) == d_tags_2d) + assert np.all(g_2d.has_face_tag(FaceTag.BOUNDARY) == b_tags_2d) + + # 1D grids: + for g_1d in grids.grids_of_dimension(1): + f_tags_1d = np.array([False, True, False, True]) + if g_1d.face_centers[0, 0] > 0.1: + t_tags_1d = np.array([True, False, False, False]) + d_tags_1d = np.array([False, False, True, False]) + else: + t_tags_1d = np.array([False, False, True, False]) + d_tags_1d = np.array([True, False, False, False]) + + b_tags_1d = np.sum(f_tags_1d + d_tags_1d + + t_tags_1d, axis=0).astype(bool) + + assert np.all(g_1d.has_face_tag(FaceTag.TIP) == t_tags_1d) + assert np.all(g_1d.has_face_tag(FaceTag.FRACTURE) == f_tags_1d) + assert np.all(g_1d.has_face_tag( + FaceTag.DOMAIN_BOUNDARY) == d_tags_1d) + assert np.all(g_1d.has_face_tag(FaceTag.BOUNDARY) == b_tags_1d) diff --git a/src/porepy/grids/tests/test_fracture_center.py b/src/porepy/grids/tests/test_fracture_center.py new file mode 100644 index 0000000000..8b9b374551 --- /dev/null +++ b/src/porepy/grids/tests/test_fracture_center.py @@ -0,0 +1,47 @@ +import unittest +import numpy as np + +from gridding.fractured.fractures import Fracture + +class TestFractureCenters(unittest.TestCase): + + def test_frac_1(self): + f_1 = Fracture(np.array([[0, 2, 2, 0], + [0, 2, 2, 0], + [-1, -1, 1, 1]])) + c_known = np.array([1, 1, 0]) + assert np.allclose(c_known, f_1.center) + + def test_frac_2(self): + f_1 = Fracture(np.array([[0, 2, 2, 0], + [0, 2, 2, 0], + [0, 0, 1, 1]])) + c_known = np.array([1, 1, 0.5]) + assert np.allclose(c_known, f_1.center) + + def test_frac_3(self): + # Fracture plane defined by x + y + z = 1 + f_1 = Fracture(np.array([[0, 1, 1, 0], + [0, 0, 1, 1], + [1, 0, -1, 0]])) + c_known = np.array([0.5, 0.5, 0]) + assert np.allclose(c_known, f_1.center) + + def test_frac_4(self): + # Fracture plane defined by x + y + z = 4 + f_1 = Fracture(np.array([[0, 1, 1, 0], + [0, 0, 1, 1], + [4, 3, 2, 3]])) + c_known = np.array([0.5, 0.5, 3]) + assert np.allclose(c_known, f_1.center) + + def test_frac_rand(self): + r = np.random.rand(4) + x = np.array([0, 1, 1, 0]) + y = np.array([0, 0, 1, 1]) + z = (r[0] - r[1] * x - r[2] * y) / r[3] + f = Fracture(np.vstack((x, y, z))) + z_cc = (r[0] - 0.5 * r[1] - 0.5 * r[2]) / r[3] + c_known = np.array([0.5, 0.5, z_cc]) + assert np.allclose(c_known, f.center) + diff --git a/src/porepy/grids/tests/test_fracture_intersect_boundary.py b/src/porepy/grids/tests/test_fracture_intersect_boundary.py new file mode 100644 index 0000000000..71cd4ec265 --- /dev/null +++ b/src/porepy/grids/tests/test_fracture_intersect_boundary.py @@ -0,0 +1,110 @@ +""" +Test of algorithm for constraining a fracture a bounding box. + +Since that algorithm uses fracture intersection methods, the tests functions as +partial test for the wider fracture intersection framework as well. Full tests +of the latter are too time consuming to fit into a unit test. + +""" +import unittest +import numpy as np + +from gridding.fractured.fractures import Fracture, FractureNetwork + +class TestFractureBoundaryIntersection(): + + def __init__(self): + self.domain = {'xmin': 0, 'xmax': 1, + 'ymin': 0, 'ymax': 1, + 'zmin': 0, 'zmax': 1} + + def setup(self): + self.f_1 = Fracture(np.array([[0, 1, 1, 0], + [.5, .5, .5, .5], + [0, 0, 1, 1]])) + + def _a_in_b(self, a, b, tol=1e-5): + for i in range(a.shape[1]): + if not np.any(np.abs(a[:, i].reshape((-1, 1))\ + - b).max(axis=0) < tol): + return False + return True + + def _arrays_equal(self, a, b): + return self._a_in_b(a, b) and self._a_in_b(b, a) + + def test_completely_outside_lower(self): + self.setup() + f = self.f_1 + f.p[0] -= 2 + network = FractureNetwork([f]) + network.impose_external_boundary(self.domain) + assert len(network._fractures) == 0 + + def test_outside_west_bottom(self): + self.setup() + f = self.f_1 + f.p[0] -= 0.5 + f.p[2] -= 1.5 + network = FractureNetwork([f]) + network.impose_external_boundary(self.domain) + assert len(network._fractures) == 0 + + def test_intersect_one(self): + self.setup() + f = self.f_1 + f.p[0] -= 0.5 + f.p[2, :] = [0.2, 0.2, 0.8, 0.8] + network = FractureNetwork([f]) + network.impose_external_boundary(self.domain) + p_known = np.array([[0., 0.5, 0.5, 0], + [0.5, 0.5, 0.5, 0.5], + [0.2, 0.2, 0.8, 0.8]]) + assert len(network._fractures) == 1 + p_comp = network._fractures[0].p + assert self._arrays_equal(p_known, p_comp) + + def test_intersect_two_same(self): + self.setup() + f = self.f_1 + f.p[0, :] = [-0.5, 1.5, 1.5, -0.5] + f.p[2, :] = [0.2, 0.2, 0.8, 0.8] + network = FractureNetwork([f]) + network.impose_external_boundary(self.domain) + p_known = np.array([[0., 1, 1, 0], + [0.5, 0.5, 0.5, 0.5], + [0.2, 0.2, 0.8, 0.8]]) + assert len(network._fractures) == 1 + p_comp = network._fractures[0].p + assert self._arrays_equal(p_known, p_comp) + + def test_incline_in_plane(self): + self.setup() + f = self.f_1 + f.p[0] -= 0.5 + f.p[2, :] = [0, -0.5, 0.5, 1] + network = FractureNetwork([f]) + network.impose_external_boundary(self.domain) + p_known = np.array([[0., 0.5, 0.5, 0], + [0.5, 0.5, 0.5, 0.5], + [0., 0., 0.5, 0.75]]) + assert len(network._fractures) == 1 + p_comp = network._fractures[0].p + assert self._arrays_equal(p_known, p_comp) + + def test_full_incline(self): + p = np.array([[-0.5, 0.5, 0.5, -0.5], + [0.5, 0.5, 1.5, 1.5], + [-0.5, -0.5, 1, 1]]) + f = Fracture(p) + network = FractureNetwork([f]) + network.impose_external_boundary(self.domain) + p_known = np.array([[0., 0.5, 0.5, 0], + [5./6, 5./6, 1, 1], + [0., 0., 0.25, 0.25]]) + assert len(network._fractures) == 1 + p_comp = network._fractures[0].p + assert self._arrays_equal(p_known, p_comp) + +if __name__ == '__main__': + unittest.main() diff --git a/src/porepy/grids/tests/test_split_grid.py b/src/porepy/grids/tests/test_split_grid.py new file mode 100644 index 0000000000..e76a5df274 --- /dev/null +++ b/src/porepy/grids/tests/test_split_grid.py @@ -0,0 +1,51 @@ +import unittest +import numpy as np + +from gridding.fractured import meshing + + +class TestMeshing(unittest.TestCase): + def test_x_intersection_3d(self): + """ + Create a x-intersection in 3D + """ + + f_1 = np.array([[1, 3, 3, 1], [1, 1, 1, 1], [1, 1, 3, 3]]) + f_2 = np.array([[1, 1, 1, 1], [1, 3, 3, 1], [1, 1, 3, 3]]) + f_set = [f_1, f_2] + + bucket = meshing.cart_grid(f_set, [3, 3, 3]) + bucket.compute_geometry() + + g_3 = bucket.grids_of_dimension(3) + g_2 = bucket.grids_of_dimension(2) + g_1 = bucket.grids_of_dimension(1) + g_0 = bucket.grids_of_dimension(0) + + assert len(g_3) == 1 + assert len(g_2) == 2 + assert len(g_1) == 1 + assert len(g_0) == 0 + + def test_tripple_intersection_3d(self): + """ + Create a x-intersection in 3D + """ + + f_1 = np.array([[1, 3, 3, 1], [1, 1, 1, 1], [1, 1, 3, 3]]) + f_2 = np.array([[1, 1, 1, 1], [1, 3, 3, 1], [1, 1, 3, 3]]) + f_3 = np.array([[1, 3, 3, 1], [1, 1, 3, 3], [1, 1, 1, 1]]) + f_set = [f_1, f_2, f_3] + + bucket = meshing.cart_grid(f_set, [3, 3, 3]) + bucket.compute_geometry() + + g_3 = bucket.grids_of_dimension(3) + g_2 = bucket.grids_of_dimension(2) + g_1 = bucket.grids_of_dimension(1) + g_0 = bucket.grids_of_dimension(0) + + assert len(g_3) == 1 + assert len(g_2) == 3 + assert len(g_1) == 3 + assert len(g_0) == 1 diff --git a/src/porepy/grids/tests/test_structured.py b/src/porepy/grids/tests/test_structured.py new file mode 100644 index 0000000000..2b98f0716d --- /dev/null +++ b/src/porepy/grids/tests/test_structured.py @@ -0,0 +1,61 @@ +import unittest +import numpy as np + +from gridding.fractured import structured + + +class TestStructured(unittest.TestCase): + def test_x_intersection_2d(self): + """ Check that no error messages are created in the process of creating a + split_fracture. + """ + + f_1 = np.array([[0, 2], [1, 1]]) + f_2 = np.array([[1, 1], [0, 2]]) + + f_set = [f_1, f_2] + nx = [3, 3] + + grids = structured.cart_grid_2d(f_set, nx, physdims=nx) + + num_grids = [1, 2, 1] + for i, g in enumerate(grids): + assert len(g) == num_grids[i] + + g_2d = grids[0][0] + g_1d_1 = grids[1][0] + g_1d_2 = grids[1][1] + g_0d = grids[2][0] + + f_nodes_1 = [4, 5, 6] + f_nodes_2 = [1, 5, 9] + f_nodes_0 = [5] + glob_1 = np.sort(g_1d_1.global_point_ind) + glob_2 = np.sort(g_1d_2.global_point_ind) + glob_0 = np.sort(g_0d.global_point_ind) + assert np.all(f_nodes_1 == glob_1) + assert np.all(f_nodes_2 == glob_2) + assert np.all(f_nodes_0 == glob_0) + + def test_tripple_x_intersection_3d(self): + """ Check that no error messages are created in the process of creating a + split_fracture. + """ + f_1 = np.array([[1, 4, 4, 1], [3, 3, 3, 3], [1, 1, 4, 4]]) + f_2 = np.array([[3, 3, 3, 3], [1, 4, 4, 1], [1, 1, 5, 5]]) + f_3 = np.array([[1, 1, 4, 4], [1, 4, 4, 1], [3, 3, 3, 3]]) + + f_set = [f_1, f_2, f_3] + nx = np.array([6, 6, 6]) + + grids = structured.cart_grid_3d(f_set, nx, physdims=nx) + + num_grids = [1, 3, 6, 1] + + for i, g in enumerate(grids): + assert len(g) == num_grids[i] + + g_3d = grids[0][0] + for g_loc in grids[1:]: + for g in g_loc: + assert np.allclose(g.nodes, g_3d.nodes[:, g.global_point_ind])