Ejemplo n.º 1
0
def readre2(fname):
    """A function for reading .re2 files for nek5000

	Parameters
	----------
	fname : str
		file name
	"""
    #
    try:
        infile = open(fname, 'rb')
    except OSError as e:
        logger.critical(f'I/O error ({e.errno}): {e.strerror}')
        return -1
    # the header for re2 files is 80 ASCII bytes, something like
    # #v002    18669  2    18669 this is the hdr                                      %
    header = infile.read(80).split()
    nel = int(header[1])
    ndim = int(header[2])
    # always double precision
    wdsz = 8
    realtype = 'd'

    # detect endianness
    etagb = infile.read(4)
    etagL = struct.unpack('<f', etagb)[0]
    etagL = int(etagL * 1e5) / 1e5
    etagB = struct.unpack('>f', etagb)[0]
    etagB = int(etagB * 1e5) / 1e5
    if (etagL == 6.54321):
        logger.debug('Reading little-endian file\n')
        emode = '<'
        endian = 'little'
    elif (etagB == 6.54321):
        logger.debug('Reading big-endian file\n')
        emode = '>'
        endian = 'big'
    else:
        logger.error('Could not interpret endianness')
        return -3
    #
    # there are no GLL points here, only quad/hex vertices
    lr1 = [2, 2, ndim - 1]
    npel = 2**ndim
    # the file only contains geometry
    var = [ndim, 0, 0, 0, 0]
    # allocate structure
    data = exdat.exadata(ndim, nel, lr1, var, 1)
    #
    # some metadata
    data.wdsz = wdsz
    data.endian = endian
    #
    # read the whole geometry into a buffer
    # for some reason each element is prefixed with 8 bytes of zeros, it's not clear to me why.
    # This is the reason for the +1 here, then the first number is ignored.
    buf = infile.read((ndim * npel + 1) * wdsz * nel)
    elem_shape = [ndim, ndim - 1, 2, 2]  # nvar, lz, ly, lx
    for (iel, el) in enumerate(data.elem):
        fi = np.frombuffer(buf,
                           dtype=emode + realtype,
                           count=ndim * npel + 1,
                           offset=(ndim * npel + 1) * wdsz * iel)
        # the data is stored in the following order (2D|3D):
        # x1, x2, x4, x3; | x5, x6, x8, x7;
        # y1, y2, y4, y3; | y5, y6, y8, y7;
        # ----------------
        # z1, z2, z4, z3; z5, z6, z8, z7;
        # where 1-8 is the ordering of the points in memory
        for idim in range(ndim):  # x, y, [z]
            for iz in range(
                    ndim - 1
            ):  # this does only one iteration in 2D, and in 3D does one iteration for 1-4 and one for 5-8
                el.pos[idim, iz, 0, 0] = fi[npel * idim + 4 * iz + 1]
                el.pos[idim, iz, 0, 1] = fi[npel * idim + 4 * iz + 2]
                el.pos[idim, iz, 1, 1] = fi[npel * idim + 4 * iz + 3]
                el.pos[idim, iz, 1, 0] = fi[npel * idim + 4 * iz + 4]
    #
    # read curved sides
    # the number of curved sides is stored as a double,
    # then each curved side is 64 bytes:
    # iel iface p1 p2 p3 p4 p5 ctype
    # where p1-5 are f64 parameters and ctype is the type of curvature in ASCII
    ncparam = 8
    buf = infile.read(wdsz)
    ncurv = int(np.frombuffer(buf)[0])
    logger.debug(f'Found {ncurv} curved sides')
    data.ncurv = ncurv
    buf = infile.read(wdsz * (ncparam * ncurv))
    for icurv in range(ncurv):
        # interpret the data
        curv = np.frombuffer(buf,
                             dtype=emode + realtype,
                             count=ncparam,
                             offset=icurv * ncparam * wdsz)
        iel = int(curv[0]) - 1
        iedge = int(curv[1]) - 1
        cparams = curv[2:7]
        # select only the first byte, because it turns out the later bytes may contain garbage.
        # typically, it's b'C\x00\x00\x00\x00\x00\x00\x00' or b'C\x00\x00\x00\x00\x00\xe0?'.
        # AFAIK the curvature types are always one character long anyway.
        ctype = curv[7].tobytes()[:1].decode('utf-8')
        # fill in the data structure
        data.elem[iel].curv[iedge, :] = cparams
        data.elem[iel].ccurv[iedge] = ctype
    #
    # read boundary conditions
    # there can be more than one field, and we won't know until we'vre reached the end
    nbcparam = 8
    buf = infile.read(wdsz)
    ifield = 0
    while buf != b'':
        # the data is initialized with one BC field, we might need to allocate another
        if ifield > 0:
            data.nbc = data.nbc + 1
            for el in data.elem:
                empty_bcs = np.zeros(el.bcs[:1, :].shape, dtype=el.bcs.dtype)
                el.bcs = np.concatenate((el.bcs, empty_bcs))
        nbclines = int(np.frombuffer(buf)[0])
        logger.debug(
            f'Found {nbclines} external boundary conditions for field {ifield}'
        )
        buf = infile.read(wdsz * (nbcparam * nbclines))
        for ibc in range(nbclines):
            # interpret the data
            bc = np.frombuffer(buf,
                               dtype=emode + realtype,
                               count=nbcparam,
                               offset=ibc * nbcparam * wdsz)
            iel = int(bc[0]) - 1
            iface = int(bc[1]) - 1
            bcparams = bc[2:7]
            bctype = bc[7].tobytes().decode(
                'utf-8').rstrip()  # remove trailing spaces
            # fill in the data structure
            data.elem[iel].bcs[ifield, iface][0] = bctype
            data.elem[iel].bcs[ifield, iface][1] = iel + 1
            data.elem[iel].bcs[ifield, iface][2] = iface + 1
            for ipar in range(5):
                data.elem[iel].bcs[ifield, iface][3 + ipar] = bcparams[ipar]
        ifield = ifield + 1
        # try reading the number of conditions in the next field
        buf = infile.read(wdsz)
    infile.close()
    return data
Ejemplo n.º 2
0
def writenek(fname, data):
    """A function for writing binary data in the nek5000 binary format

	Parameters
	----------
	fname : str
		file name
	data : exadata
		data organised as readnek() output
	"""
    #
    try:
        outfile = open(fname, 'wb')
    except OSError as e:
        logger.critical(f'I/O error ({e.errno}): {e.strerror}')
        return -1
    #
    #---------------------------------------------------------------------------
    # WRITE HEADER
    #---------------------------------------------------------------------------
    #
    # multiple files (not implemented)
    fid = 0
    nf = 1
    nelf = data.nel
    #
    # get fields to be written
    vars = ''
    if (data.var[0] > 0): vars += 'X'
    if (data.var[1] > 0): vars += 'U'
    if (data.var[2] > 0): vars += 'P'
    if (data.var[3] > 0): vars += 'T'
    if (data.var[4] > 0):
        # TODO: check if header for scalars are written with zeros filled as S01
        vars += 'S{:02d}'.format(data.var[4])
    #
    # get word size
    if (data.wdsz == 4):
        logger.debug('Writing single-precision file')
    elif (data.wdsz == 8):
        logger.debug('Writing double-precision file')
    else:
        logger.error('Could not interpret real type (wdsz = %i)' % (data.wdsz))
        return -2
    #
    # generate header
    header = '#std %1i %2i %2i %2i %10i %10i %20.13E %9i %6i %6i %s' % (
        data.wdsz, data.lr1[0], data.lr1[1], data.lr1[2], data.nel, nelf,
        data.time, data.istep, fid, nf, vars)
    #
    # write header
    header = header.ljust(132)
    outfile.write(header.encode('utf-8'))
    #
    # decide endianness
    if data.endian in ('big', 'little'):
        byteswap = data.endian != sys.byteorder
        logger.debug(f'Writing {data.endian}-endian file')
    else:
        byteswap = False
        logger.warning(f'Unrecognized endianness {data.endian}, '
                       f'writing native {sys.byteorder}-endian file')
    #
    def correct_endianness(a):
        ''' Return the array with the requested endianness'''
        if byteswap:
            return a.byteswap()
        else:
            return a

    #
    # write tag (to specify endianness)
    endianbytes = np.array([6.54321], dtype=np.float32)
    correct_endianness(endianbytes).tofile(outfile)
    #
    # write element map for the file
    correct_endianness(data.elmap).tofile(outfile)
    #
    #---------------------------------------------------------------------------
    # WRITE DATA
    #---------------------------------------------------------------------------
    #
    # compute total number of points per element
    npel = data.lr1[0] * data.lr1[1] * data.lr1[2]

    #
    def write_ndarray_to_file(a):
        '''Write a data array to the output file in the requested precision and endianness'''
        if data.wdsz == 4:
            correct_endianness(a.astype(np.float32)).tofile(outfile)
        else:
            correct_endianness(a).tofile(outfile)

    #
    # write geometry
    for iel in data.elmap:
        for idim in range(
                data.var[0]):  # if var[0] == 0, geometry is not written
            write_ndarray_to_file(data.elem[iel - 1].pos[idim, :, :, :])
    #
    # write velocity
    for iel in data.elmap:
        for idim in range(
                data.var[1]):  # if var[1] == 0, velocity is not written
            write_ndarray_to_file(data.elem[iel - 1].vel[idim, :, :, :])
    #
    # write pressure
    for iel in data.elmap:
        for ivar in range(
                data.var[2]):  # if var[2] == 0, pressure is not written
            write_ndarray_to_file(data.elem[iel - 1].pres[ivar, :, :, :])
    #
    # write temperature
    for iel in data.elmap:
        for ivar in range(
                data.var[3]):  # if var[3] == 0, temperature is not written
            write_ndarray_to_file(data.elem[iel - 1].temp[ivar, :, :, :])
    #
    # write scalars
    #
    # NOTE: This is not a bug!
    # Unlike other variables, scalars are in the outer loop and elements
    # are in the inner loop
    #
    for ivar in range(data.var[4]):  # if var[4] == 0, scalars are not written
        for iel in data.elmap:
            write_ndarray_to_file(data.elem[iel - 1].scal[ivar, :, :, :])
    #
    # write max and min of every field in every element (forced to single precision)
    if (data.ndim == 3):
        #
        for iel in data.elmap:
            for idim in range(data.var[0]):
                correct_endianness(
                    np.min(data.elem[iel - 1].pos[idim, :, :, :]).astype(
                        np.float32)).tofile(outfile)
                correct_endianness(
                    np.max(data.elem[iel - 1].pos[idim, :, :, :]).astype(
                        np.float32)).tofile(outfile)
        for iel in data.elmap:
            for idim in range(data.var[1]):
                correct_endianness(
                    np.min(data.elem[iel - 1].vel[idim, :, :, :]).astype(
                        np.float32)).tofile(outfile)
                correct_endianness(
                    np.max(data.elem[iel - 1].vel[idim, :, :, :]).astype(
                        np.float32)).tofile(outfile)
        for iel in data.elmap:
            for ivar in range(data.var[2]):
                correct_endianness(
                    np.min(data.elem[iel - 1].pres[ivar, :, :, :]).astype(
                        np.float32)).tofile(outfile)
                correct_endianness(
                    np.max(data.elem[iel - 1].pres[ivar, :, :, :]).astype(
                        np.float32)).tofile(outfile)
        for iel in data.elmap:
            for ivar in range(data.var[3]):
                correct_endianness(
                    np.min(data.elem[iel - 1].temp[ivar, :, :, :]).astype(
                        np.float32)).tofile(outfile)
                correct_endianness(
                    np.max(data.elem[iel - 1].temp[ivar, :, :, :]).astype(
                        np.float32)).tofile(outfile)
        for iel in data.elmap:
            for ivar in range(data.var[4]):
                correct_endianness(
                    np.min(data.elem[iel - 1].scal[ivar, :, :, :]).astype(
                        np.float32)).tofile(outfile)
                correct_endianness(
                    np.max(data.elem[iel - 1].scal[ivar, :, :, :]).astype(
                        np.float32)).tofile(outfile)

    # close file
    outfile.close()
    #
    # output
    return 0
Ejemplo n.º 3
0
def readnek(fname):
    """A function for reading binary data from the nek5000 binary format

	Parameters
	----------
	fname : str
		file name
	"""
    #
    try:
        infile = open(fname, 'rb')
    except OSError as e:
        logger.critical(f'I/O error ({e.errno}): {e.strerror}')
        return -1
    #
    #---------------------------------------------------------------------------
    # READ HEADER
    #---------------------------------------------------------------------------
    #
    # read header
    header = infile.read(132).split()
    logger.debug('Header: {}'.format(b' '.join(header).decode('utf-8')))

    # get word size: single or double precision
    wdsz = int(header[1])
    if (wdsz == 4):
        realtype = 'f'
    elif (wdsz == 8):
        realtype = 'd'
    else:
        logger.error('Could not interpret real type (wdsz = %i)' % (wdsz))
        return -2
    #
    # get polynomial order
    lr1 = [int(header[2]), int(header[3]), int(header[4])]
    #
    # compute total number of points per element
    npel = lr1[0] * lr1[1] * lr1[2]
    #
    # get number of physical dimensions
    ndim = 2 + (lr1[2] > 1)
    #
    # get number of elements
    nel = int(header[5])
    #
    # get number of elements in the file
    nelf = int(header[6])
    #
    # get current time
    time = float(header[7])
    #
    # get current time step
    istep = int(header[8])
    #
    # get file id
    fid = int(header[9])
    #
    # get tot number of files
    nf = int(header[10])
    #
    # get variables [XUPTS[01-99]]
    variables = header[11].decode('utf-8')
    logger.debug(f"Variables: {variables}")
    var = [0 for i in range(5)]
    for v in variables:
        if (v == 'X'):
            var[0] = ndim
        elif (v == 'U'):
            var[1] = ndim
        elif (v == 'P'):
            var[2] = 1
        elif (v == 'T'):
            var[3] = 1
        elif (v == 'S'):
            # For example: variables = 'XS44'
            index_s = variables.index('S')
            nb_scalars = int(variables[index_s + 1:])
            var[4] = nb_scalars
    #
    # identify endian encoding
    etagb = infile.read(4)
    etagL = struct.unpack('<f', etagb)[0]
    etagL = int(etagL * 1e5) / 1e5
    etagB = struct.unpack('>f', etagb)[0]
    etagB = int(etagB * 1e5) / 1e5
    if (etagL == 6.54321):
        logger.debug('Reading little-endian file\n')
        emode = '<'
    elif (etagB == 6.54321):
        logger.debug('Reading big-endian file\n')
        emode = '>'
    else:
        logger.error('Could not interpret endianness')
        return -3
    #
    # read element map for the file
    elmap = infile.read(4 * nelf)
    elmap = list(struct.unpack(emode + nelf * 'i', elmap))
    #
    #---------------------------------------------------------------------------
    # READ DATA
    #---------------------------------------------------------------------------
    #
    # initialize data structure
    data = exdat.exadata(ndim, nel, lr1, var, 0)
    data.time = time
    data.istep = istep
    data.wdsz = wdsz
    data.elmap = np.array(elmap, dtype=np.int32)
    if (emode == '<'):
        data.endian = 'little'
    elif (emode == '>'):
        data.endian = 'big'
    #
    def read_file_into_data(data_var, index_var):
        """Read binary file into an array attribute of ``data.elem``"""
        fi = infile.read(npel * wdsz)
        fi = np.frombuffer(fi, dtype=emode + realtype, count=npel)

        # Replace elem array in-place with
        # array read from file after reshaping as
        elem_shape = lr1[::-1]  # lz, ly, lx
        data_var[index_var, ...] = fi.reshape(elem_shape)

    #
    # read geometry
    for iel in elmap:
        el = data.elem[iel - 1]
        for idim in range(var[0]):  # if var[0] == 0, geometry is not read
            read_file_into_data(el.pos, idim)
    #
    # read velocity
    for iel in elmap:
        el = data.elem[iel - 1]
        for idim in range(var[1]):  # if var[1] == 0, velocity is not read
            read_file_into_data(el.vel, idim)
    #
    # read pressure
    for iel in elmap:
        el = data.elem[iel - 1]
        for ivar in range(var[2]):  # if var[2] == 0, pressure is not read
            read_file_into_data(el.pres, ivar)
    #
    # read temperature
    for iel in elmap:
        el = data.elem[iel - 1]
        for ivar in range(var[3]):  # if var[3] == 0, temperature is not read
            read_file_into_data(el.temp, ivar)
    #
    # read scalar fields
    #
    # NOTE: This is not a bug!
    # Unlike other variables, scalars are in the outer loop and elements
    # are in the inner loop
    #
    for ivar in range(var[4]):  # if var[4] == 0, scalars are not read
        for iel in elmap:
            el = data.elem[iel - 1]
            read_file_into_data(el.scal, ivar)
    #
    #
    # close file
    infile.close()
    #
    # output
    return data
Ejemplo n.º 4
0
def writere2(fname, data):
    """A function for writing binary .re2 files for nek5000

	Parameters
	----------
	fname : str
		file name
	data : exadata
		data organised as in exadata.py
	"""
    #
    #---------------------------------------------------------------------------
    # CHECK INPUT DATA
    #---------------------------------------------------------------------------
    #
    # We could extract the corners, but for now just return an error if lr1 is too large
    if data.lr1 != [2, 2, data.ndim - 1]:
        logger.critical(
            'wrong element dimensions for re2 file! {} != {}'.format(
                data.lr1, [2, 2, data.ndim - 1]))
        return -2
    #
    if data.var[0] != data.ndim:
        logger.critical(
            'wrong number of geometric variables for re2 file! expected {}, found {}'
            .format(data.ndim, data.var[0]))
        return -3
    #
    # Open file
    try:
        outfile = open(fname, 'wb')
    except OSError as e:
        logger.critical(f'I/O error ({e.errno}): {e.strerror}')
        return -1
    #
    #---------------------------------------------------------------------------
    # WRITE HEADER
    #---------------------------------------------------------------------------
    #
    # always double precision
    wdsz = 8
    realtype = 'd'
    nel = data.nel
    ndim = data.ndim
    header = f'#v002{nel:9d}{ndim:3d}{nel:9d} this is the hdr'
    header = header.ljust(80)
    outfile.write(header.encode('utf-8'))
    #
    # decide endianness
    if data.endian in ('big', 'little'):
        byteswap = data.endian != sys.byteorder
        logger.debug(f'Writing {data.endian}-endian file')
    else:
        byteswap = False
        logger.warning(f'Unrecognized endianness {data.endian}, '
                       f'writing native {sys.byteorder}-endian file')
    #
    def correct_endianness(a):
        ''' Return the array with the requested endianness'''
        if byteswap:
            return a.byteswap()
        else:
            return a

    #
    # write tag (to specify endianness)
    endianbytes = np.array([6.54321], dtype=np.float32)
    correct_endianness(endianbytes).tofile(outfile)
    #
    #---------------------------------------------------------------------------
    # WRITE DATA
    #---------------------------------------------------------------------------
    #
    # compute total number of points per element
    npel = 2**ndim

    #
    def write_data_to_file(a):
        '''Write the geometry of an element to the output file in double precision'''
        correct_endianness(a).tofile(outfile)

    #
    # write geometry (adding eight bytes of zeros before each element)
    xyz = np.zeros(
        (npel * ndim + 1, )
    )  # array storing reordered geometry data (with a zero in the first position)
    for el in data.elem:
        # the data is stored in the following order (2D|3D):
        # x1, x2, x4, x3; | x5, x6, x8, x7;
        # y1, y2, y4, y3; | y5, y6, y8, y7;
        # ----------------
        # z1, z2, z4, z3; z5, z6, z8, z7;
        # where 1-8 is the ordering of the points in memory
        for idim in range(ndim):  # x, y, [z]
            for iz in range(
                    ndim - 1
            ):  # this does only one iteration in 2D, and in 3D does one iteration for 1-4 and one for 5-8
                xyz[npel * idim + 4 * iz + 1] = el.pos[idim, iz, 0, 0]
                xyz[npel * idim + 4 * iz + 2] = el.pos[idim, iz, 0, 1]
                xyz[npel * idim + 4 * iz + 3] = el.pos[idim, iz, 1, 1]
                xyz[npel * idim + 4 * iz + 4] = el.pos[idim, iz, 1, 0]
        write_data_to_file(xyz)
    #
    # write curve sides data
    # locate curved edges
    curved_edges = []
    for (iel, el) in enumerate(data.elem):
        for iedge in range(12):
            if el.ccurv[iedge] != '':
                curved_edges.append((iel, iedge))
    # write number of curved edges
    ncurv = len(curved_edges)
    if ncurv != data.ncurv:
        logger.warning(
            f'wrong number of curved edges: expected {data.ncurv}, found {ncurv}'
        )
    ncurvf = np.array([ncurv], dtype=np.float64)
    write_data_to_file(ncurvf)
    # format curve data
    cdata = np.zeros((ncurv, ), dtype='f8, f8, f8, f8, f8, f8, f8, S8')
    for (cdat, (iel, iedge)) in zip(cdata, curved_edges):
        el = data.elem[iel]
        cdat[0] = iel + 1
        cdat[1] = iedge + 1
        # curve parameters
        for j in range(5):
            cdat[2 + j] = el.curv[iedge, j]
        # encode the string as a byte array padded with spaces
        cdat[7] = el.ccurv[iedge].encode('utf-8')
    # write to file
    write_data_to_file(cdata)
    #
    # write boundary conditions for each field
    for ifield in range(data.nbc):
        # locate faces with boundary conditions
        bc_faces = []
        for (iel, el) in enumerate(data.elem):
            for iface in range(2 * ndim):
                bctype = el.bcs[ifield, iface][0]
                # internal boundary conditions are not written to .re2 files by reatore2
                # and are apparently ignored by Nek5000 even in .rea files
                if bctype != '' and bctype != 'E':
                    bc_faces.append((iel, iface))
        nbcs = len(bc_faces)
        nbcsf = np.array([nbcs], dtype=np.float64)
        write_data_to_file(nbcsf)
        # initialize and format data
        bcdata = np.zeros((nbcs, ), dtype='f8, f8, f8, f8, f8, f8, f8, S8')
        for (bc, (iel, iface)) in zip(bcdata, bc_faces):
            el = data.elem[iel]
            bc[0] = iel + 1
            bc[1] = iface + 1
            for j in range(5):
                bc[2 + j] = el.bcs[ifield, iface][3 + j]
            # encode the string as a byte array padded with spaces
            bc[7] = el.bcs[ifield, iface][0].encode('utf-8').ljust(8)
        # write to file
        write_data_to_file(bcdata)
    #
    # close file
    outfile.close()
    # rerurn
    return 0
Ejemplo n.º 5
0
    def merge(self, other, tol=1e-9, ignore_empty=True):
        """
        Merges another exadata into the current one and connects it

        Parameters
        ----------
        other: exadata
                mesh to merge into self

        tol: float
                maximum distance at which points are considered identical

        ignore_empty: bool
                if True, the faces with an empty boundary condition ('') will be treated as internal faces and will not be merged.
                This is useful if internal boundary conditions are not defined and will make the operation *much* faster,
                but requires boundary conditions to be defined on the faces to be merged.

        """

        # perform some consistency checks
        if self.ndim != other.ndim:
            logger.error(
                f"Cannot merge meshes of dimensions {self.ndim} and {other.ndim}!"
            )
            return -1
        if self.lr1[0] != other.lr1[0]:
            logger.error(
                "Cannot merge meshes of different polynomial orders ({} != {})"
                .format(self.lr1[0], other.lr1[0]))
            return -2

        # add the new elements (in an inconsistent state if there are internal boundary conditions)
        nel1 = self.nel
        self.nel = self.nel + other.nel
        self.ncurv = self.ncurv + other.ncurv
        self.elmap = np.concatenate((self.elmap, other.elmap + nel1))
        # the deep copy is required here to avoid leaving the 'other' mesh in an inconsistent state by modifying its elements
        self.elem = self.elem + copy.deepcopy(other.elem)

        # check how many boundary condition fields we have
        nbc = min(self.nbc, other.nbc)

        # correct the boundary condition numbers:
        # the index of the elements and neighbours have changed
        for iel, ibc, iface in product(range(nel1, self.nel), range(other.nbc),
                                       range(6)):
            self.elem[iel].bcs[ibc, iface][1] = iel + 1
            bc = self.elem[iel].bcs[ibc, iface][0]
            if bc == "E" or bc == "P":
                neighbour = self.elem[iel].bcs[ibc, iface][3]
                self.elem[iel].bcs[ibc, iface][3] = neighbour + nel1

        # glue common faces together
        # only look for the neighbour in the first BC field because it should be the same in all fields.
        # FIXME: this will fail to correct periodic conditions if periodic domains are merged together.
        nfaces = 2 * self.ndim
        nchanges = 0  # counter for the boundary conditions connected
        if nbc == 0:
            # Quickly exit the function
            logger.debug("no pairs of faces to merge")
            return nchanges

        for iel0, iface0 in product(range(nel1, self.nel), range(nfaces)):
            elem0 = self.elem[iel0]
            bc0 = elem0.bcs[0, iface0][0]

            if bc0 != "E" and not (ignore_empty and bc0 == ""):
                # boundary element, look if it can be connected to something
                for iel1, iface1 in product(range(nel1), range(nfaces)):
                    elem1 = self.elem[iel1]
                    bc1 = elem1.bcs[0, iface1][0]

                    if bc1 != "E" and not (ignore_empty and bc1 == ""):
                        # if the centers of the faces are close, connect them together
                        x0, y0, z0 = elem0.face_center(iface0)
                        x1, y1, z1 = elem1.face_center(iface1)
                        dist2 = (x1 - x0)**2 + (y1 - y0)**2 + (z1 - z0)**2
                        if dist2 <= tol**2:
                            # reconnect the periodic faces together (assumes that all fields are periodic)
                            if bc0 == bc1 == "P":

                                iel_p0 = int(elem0.bcs[0, iface0][3]) - 1
                                iel_p1 = int(elem1.bcs[0, iface1][3]) - 1
                                iface_p0 = int(elem0.bcs[0, iface0][4]) - 1
                                iface_p1 = int(elem1.bcs[0, iface1][4]) - 1
                                for ibc in range(nbc):
                                    elem_p0_bcs = self.elem[iel_p0].bcs[
                                        ibc, iface_p0]
                                    elem_p1_bcs = self.elem[iel_p1].bcs[
                                        ibc, iface_p1]

                                    elem_p0_bcs[0] = "P"
                                    elem_p1_bcs[0] = "P"
                                    elem_p0_bcs[3] = iel_p1 + 1
                                    elem_p1_bcs[3] = iel_p0 + 1
                                    elem_p0_bcs[4] = iface_p1 + 1
                                    elem_p1_bcs[4] = iface_p0 + 1

                            for ibc in range(nbc):
                                elem0.bcs[ibc, iface0][0] = "E"
                                elem1.bcs[ibc, iface1][0] = "E"
                                elem0.bcs[ibc, iface0][3] = iel1 + 1
                                elem1.bcs[ibc, iface1][3] = iel0 + 1
                                elem0.bcs[ibc, iface0][4] = iface1 + 1
                                elem1.bcs[ibc, iface1][4] = iface0 + 1
                            nchanges = nchanges + 1

        logger.debug(f"merged {nchanges} pairs of faces")
        return nchanges