diff --git a/classy_blocks.code-workspace b/classy_blocks.code-workspace index ab23eca..5e2fd71 100644 --- a/classy_blocks.code-workspace +++ b/classy_blocks.code-workspace @@ -12,7 +12,10 @@ "Pylance", "scipy" ], - "editor.autoClosingBrackets": "never" + "editor.autoClosingBrackets": "never", + "isort.importStrategy": "fromEnvironment", + "editor.defaultFormatter": null, + "ruff.organizeImports": false }, "extensions": { "recommendations": [ diff --git a/examples/advanced/autograding_highre.py b/examples/advanced/smooth_grader.py similarity index 79% rename from examples/advanced/autograding_highre.py rename to examples/advanced/smooth_grader.py index 828c0cc..4d3ef49 100644 --- a/examples/advanced/autograding_highre.py +++ b/examples/advanced/smooth_grader.py @@ -1,5 +1,7 @@ import os +import numpy as np + import classy_blocks as cb from classy_blocks.grading.autograding.grader import SmoothGrader @@ -8,7 +10,13 @@ base = cb.Grid([0, 0, 0], [3, 2, 0], 3, 2) shape = cb.ExtrudedShape(base, 1) + +# turn one block around to test grader's skillz +shape.grid[1][0].rotate(np.pi, [0, 0, 1]) + mesh.add(shape) + +# move some points to get a mesh with uneven blocks mesh.assemble() finder = cb.GeometricFinder(mesh) @@ -19,10 +27,7 @@ vertex.translate([0, 0.8, 0]) mesh.set_default_patch("walls", "wall") - -# TODO: Hack! mesh.assemble() won't work here but wires et. al. must be updated mesh.block_list.update() - grader = SmoothGrader(mesh, 0.05) grader.grade() diff --git a/src/classy_blocks/base/exceptions.py b/src/classy_blocks/base/exceptions.py index a8f4d15..17d8d3e 100644 --- a/src/classy_blocks/base/exceptions.py +++ b/src/classy_blocks/base/exceptions.py @@ -95,6 +95,10 @@ class CornerPairError(Exception): """Raised when given pair of corners is not valid (for example, edge between 0 and 2)""" +class PatchNotFoundError(Exception): + """Raised when searching for a non-existing Patch""" + + ### Grading class UndefinedGradingsError(Exception): """Raised when the user hasn't supplied enough grading data to diff --git a/src/classy_blocks/grading/autograding/catalogue.py b/src/classy_blocks/grading/autograding/catalogue.py new file mode 100644 index 0000000..948c999 --- /dev/null +++ b/src/classy_blocks/grading/autograding/catalogue.py @@ -0,0 +1,90 @@ +import functools +from typing import Dict, List, get_args + +from classy_blocks.base.exceptions import BlockNotFoundError, NoInstructionError +from classy_blocks.grading.autograding.row import Row +from classy_blocks.items.block import Block +from classy_blocks.items.wires.axis import Axis +from classy_blocks.mesh import Mesh +from classy_blocks.types import DirectionType + + +@functools.lru_cache(maxsize=3000) # that's for 1000 blocks +def get_block_from_axis(mesh: Mesh, axis: Axis) -> Block: + for block in mesh.blocks: + if axis in block.axes: + return block + + raise RuntimeError("Block for Axis not found!") + + +class Instruction: + """A descriptor that tells in which direction the specific block can be chopped.""" + + def __init__(self, block: Block): + self.block = block + self.directions: List[bool] = [False] * 3 + + @property + def is_defined(self): + return all(self.directions) + + def __hash__(self) -> int: + return id(self) + + +class Catalogue: + """A collection of rows on a specified axis""" + + def __init__(self, mesh: Mesh): + self.mesh = mesh + + self.rows: Dict[DirectionType, List[Row]] = {0: [], 1: [], 2: []} + self.instructions = [Instruction(block) for block in mesh.blocks] + + for i in get_args(DirectionType): + self._populate(i) + + def _get_undefined_instructions(self, direction: DirectionType) -> List[Instruction]: + return [i for i in self.instructions if not i.directions[direction]] + + def _find_instruction(self, block: Block): + # TODO: perform dedumbing on this exquisite piece of code + for instruction in self.instructions: + if instruction.block == block: + return instruction + + raise NoInstructionError(f"No instruction found for block {block}") + + def _add_block_to_row(self, row: Row, instruction: Instruction, direction: DirectionType) -> None: + row.add_block(instruction.block, direction) + instruction.directions[direction] = True + + block = instruction.block + + for neighbour_axis in block.axes[direction].neighbours: + neighbour_block = get_block_from_axis(self.mesh, neighbour_axis) + + if neighbour_block in row.blocks: + continue + + instruction = self._find_instruction(neighbour_block) + + self._add_block_to_row(row, instruction, neighbour_block.get_axis_direction(neighbour_axis)) + + def _populate(self, direction: DirectionType) -> None: + while True: + undefined_instructions = self._get_undefined_instructions(direction) + if len(undefined_instructions) == 0: + break + + row = Row() + self._add_block_to_row(row, undefined_instructions[0], direction) + self.rows[direction].append(row) + + def get_row_blocks(self, block: Block, direction: DirectionType) -> List[Block]: + for row in self.rows[direction]: + if block in row.blocks: + return row.blocks + + raise BlockNotFoundError(f"Direction {direction} of {block} not in catalogue") diff --git a/src/classy_blocks/grading/autograding/grader.py b/src/classy_blocks/grading/autograding/grader.py index ddc415f..e1ede0b 100644 --- a/src/classy_blocks/grading/autograding/grader.py +++ b/src/classy_blocks/grading/autograding/grader.py @@ -1,4 +1,3 @@ -import abc from typing import get_args from classy_blocks.grading.autograding.params import ( @@ -8,13 +7,29 @@ SimpleGraderParams, SmoothGraderParams, ) -from classy_blocks.grading.autograding.probe import Probe, Row +from classy_blocks.grading.autograding.probe import Probe +from classy_blocks.grading.autograding.row import Row +from classy_blocks.grading.chop import Chop from classy_blocks.mesh import Mesh from classy_blocks.types import ChopTakeType, DirectionType -class GraderBase(abc.ABC): - stages: int +class GraderBase: + """One grader to rule them all. Grading procedure depends on given GraderParams object + that decides whether to grade a specific block (wire) or to let it pass to + + Behold the most general procedure for grading _anything_. + For each row in every direction: + 1. Set count + If there's a wire on the wall - determine 'wall' count (low-re grading etc) + If not, determine 'bulk' count + + That involves the 'take' keyword so that appropriate block is taken as a reference; + 2. Chop 'cramped' blocks + Where there's not enough space to fit graded cells, use a simple grading + 3. Chop other blocks + optionally use multigrading to match neighbours' cell sizes + """ def __init__(self, mesh: Mesh, params: ChopParams): self.mesh = mesh @@ -23,37 +38,61 @@ def __init__(self, mesh: Mesh, params: ChopParams): self.mesh.assemble() self.probe = Probe(self.mesh) - def get_count(self, row: Row, take: ChopTakeType) -> int: - count = row.get_count() - - if count is None: - # take length from a row, as requested by 'take' - length = row.get_length(take) - # and set count from it - count = self.params.get_count(length) - - return count - - def grade_axis(self, axis: DirectionType, take: ChopTakeType, stage: int) -> None: - handled_wires = set() - - for row in self.probe.get_rows(axis): - count = self.get_count(row, take) - - for wire in row.get_wires(): - if wire in handled_wires: + def set_counts(self, row: Row, take: ChopTakeType) -> None: + if row.count > 0: + # stuff, pre-defined by the user + return + + # at_wall: List[Entry] = [] + + # Check if there are blocks at the wall; + # for entry in row.entries: + # for wire in entry.wires: + # # TODO: cache WireInfo + # info = self.probe.get_wire_info(wire, entry.block) + # if info.starts_at_wall or info.ends_at_wall: + # at_wall.append(entry) + + length = row.get_length(take) + + # if len(at_wall) > 0: + # # find out whether one or two sides are to be counted + # pass + + row.count = self.params.get_count(length) + + def grade_squeezed(self, row: Row) -> None: + for entry in row.entries: + # TODO! don't touch wires, defined by USER + # if wire.is_defined: + # # TODO: test + # continue + for wire in entry.wires: + if wire.is_defined: continue - # don't touch defined wires - # TODO! don't touch wires, defined by USER - # if wire.is_defined: - # # TODO: test - # continue + info = self.probe.get_wire_info(wire, entry.block) + if self.params.is_squeezed(row.count, info): + wire.grading.clear() + wire.grading.add_chop(Chop(count=row.count)) + wire.copy_to_coincidents() + + def finalize(self, row: Row) -> None: + count = row.count + + for entry in row.entries: + # TODO! don't touch wires, defined by USER + # if wire.is_defined: + # # TODO: test + # continue + for wire in entry.wires: + if wire.is_defined: + continue - size_before = wire.size_before - size_after = wire.size_after + # TODO: cache wire info + info = self.probe.get_wire_info(wire, entry.block) - chops = self.params.get_chops(stage, count, wire.length, size_before, size_after) + chops = self.params.get_chops(count, info) wire.grading.clear() for chop in chops: @@ -61,21 +100,23 @@ def grade_axis(self, axis: DirectionType, take: ChopTakeType, stage: int) -> Non wire.copy_to_coincidents() - handled_wires.add(wire) - handled_wires.update(wire.coincidents) - def grade(self, take: ChopTakeType = "avg") -> None: - for axis in get_args(DirectionType): - for stage in range(self.stages): - self.grade_axis(axis, take, stage) + for direction in get_args(DirectionType): + rows = self.probe.get_rows(direction) + for row in rows: + self.set_counts(row, take) + for row in rows: + self.grade_squeezed(row) + for row in rows: + self.finalize(row) + + self.mesh.block_list.check_consistency() class FixedCountGrader(GraderBase): """The simplest possible mesh grading: use a constant cell count for all axes on all blocks; useful during mesh building and some tutorial cases""" - stages = 1 - def __init__(self, mesh: Mesh, count: int = 8): super().__init__(mesh, FixedCountParams(count)) @@ -85,8 +126,6 @@ class SimpleGrader(GraderBase): A single chop is used that sets cell count based on size. Cell sizes between blocks differ as blocks' sizes change.""" - stages = 1 - def __init__(self, mesh: Mesh, cell_size: float): super().__init__(mesh, SimpleGraderParams(cell_size)) @@ -97,8 +136,6 @@ class SmoothGrader(GraderBase): are utilized to keep cell sizes between blocks consistent (as much as possible)""" - stages = 3 - def __init__(self, mesh: Mesh, cell_size: float): super().__init__(mesh, SmoothGraderParams(cell_size)) @@ -134,8 +171,6 @@ class InflationGrader(GraderBase): The finest grid will be obtained with 'max', the coarsest with 'min'. """ - stages = 3 - def __init__( self, mesh: Mesh, diff --git a/src/classy_blocks/grading/autograding/params.py b/src/classy_blocks/grading/autograding/params.py index 7c7cf35..9c60489 100644 --- a/src/classy_blocks/grading/autograding/params.py +++ b/src/classy_blocks/grading/autograding/params.py @@ -6,6 +6,7 @@ import scipy.optimize import classy_blocks.grading.relations as gr +from classy_blocks.grading.autograding.probe import WireInfo from classy_blocks.grading.chop import Chop CellSizeType = Optional[float] @@ -26,14 +27,15 @@ def sum_length(start_size: float, count: int, c2c_expansion: float) -> float: class ChopParams(abc.ABC): @abc.abstractmethod def get_count(self, length: float) -> int: - """Calculates count based on given length - used once only""" + """Calculates count based on given length and position""" @abc.abstractmethod - def get_chops( - self, stage: int, count: int, length: float, size_before: CellSizeType, size_after: CellSizeType - ) -> List[Chop]: + def is_squeezed(self, count: int, info: WireInfo) -> bool: + """Returns True if cells have to be 'squished' together (thinner than prescribed in params)""" + + @abc.abstractmethod + def get_chops(self, count: int, info: WireInfo) -> List[Chop]: """Fixes cell count but modifies chops so that proper cell sizing will be obeyed""" - # That depends on inherited classes' philosophy @dataclasses.dataclass @@ -43,7 +45,10 @@ class FixedCountParams(ChopParams): def get_count(self, _length): return self.count - def get_chops(self, _stage, count, _length, _size_before=0, _size_after=0) -> List[Chop]: + def is_squeezed(self, _count, _info) -> bool: + return True # grade everything in first pass + + def get_chops(self, count, _info) -> List[Chop]: return [Chop(count=count)] @@ -54,7 +59,10 @@ class SimpleGraderParams(ChopParams): def get_count(self, length: float): return int(length / self.cell_size) - def get_chops(self, _stage, count, _length, _size_before=0, _size_after=0): + def is_squeezed(self, _count, _info) -> bool: + return True + + def get_chops(self, count, _info: WireInfo): return [Chop(count=count)] @@ -71,9 +79,10 @@ def get_count(self, length: float): return count - def define_sizes( - self, count: int, length: float, size_before: CellSizeType, size_after: CellSizeType - ) -> Tuple[float, float]: + def is_squeezed(self, count, info) -> bool: + return info.length <= self.cell_size * count + + def define_sizes(self, size_before: CellSizeType, size_after: CellSizeType) -> Tuple[float, float]: """Defines start and end cell size. size_before and size_after are taken from preceding/following wires; when a size is None, this is the last/first wire.""" @@ -83,13 +92,6 @@ def define_sizes( # there's no point in doing anything raise RuntimeError("Undefined grading encountered!") - # not enough room for all cells? - cramped = self.cell_size * count > length - base_size = length / count - - if cramped: - return base_size, base_size - if size_before is None: size_before = self.cell_size @@ -98,35 +100,27 @@ def define_sizes( return size_before, size_after - def get_chops(self, stage, count, length, size_before, size_after): + def get_chops(self, count, info): halfcount = count // 2 - uniform_chops = [ - Chop(length_ratio=0.5, count=halfcount), - Chop(length_ratio=0.5, count=halfcount), - ] - - if stage == 0: - # start with simple uniformly graded chops first - return uniform_chops - - size_before, size_after = self.define_sizes(count, length, size_before, size_after) + size_before, size_after = self.define_sizes(info.size_before, info.size_after) # choose length ratio so that cells at the middle of blocks # (between the two chops) have the same size def fobj(lratio): chop_1 = Chop(length_ratio=lratio, count=halfcount, start_size=size_before) - data_1 = chop_1.calculate(length) + data_1 = chop_1.calculate(info.length) chop_2 = Chop(length_ratio=1 - lratio, count=halfcount, end_size=size_after) - data_2 = chop_2.calculate(length) + data_2 = chop_2.calculate(info.length) - ratio = abs(data_1.end_size - data_2.start_size) + ratio = (data_1.end_size - data_2.start_size) ** 2 return ratio, [chop_1, chop_2] # it's not terribly important to minimize until the last dx - results = scipy.optimize.minimize_scalar(lambda r: fobj(r)[0], bounds=[0.1, 0.9], options={"xatol": 0.1}) + tol = min(size_before, size_after, self.cell_size) * 0.1 + results = scipy.optimize.minimize_scalar(lambda r: fobj(r)[0], bounds=[0.1, 0.9], options={"xatol": tol}) if not results.success: # type:ignore warnings.warn("Could not determine optimal grading", stacklevel=1) @@ -145,6 +139,9 @@ class InflationGraderParams(ChopParams): bl_thickness_factor: int = 30 buffer_expansion: float = 2 + def is_squeezed(self, _count: int, _info: WireInfo) -> bool: + return False + @property def inflation_layer_thickness(self) -> float: return self.first_cell_size * self.bl_thickness_factor @@ -208,5 +205,5 @@ def get_count(self, length: float): # return chops return 1 - def get_chops(self, stage, count, length, size_before=0, size_after=0) -> List[Chop]: + def get_chops(self, count, length, size_before=0, size_after=0) -> List[Chop]: raise NotImplementedError("TODO!") diff --git a/src/classy_blocks/grading/autograding/probe.py b/src/classy_blocks/grading/autograding/probe.py index b1979f9..230bd38 100644 --- a/src/classy_blocks/grading/autograding/probe.py +++ b/src/classy_blocks/grading/autograding/probe.py @@ -1,26 +1,19 @@ +import dataclasses import functools -from typing import Dict, List, Optional, Set, get_args +from typing import List, Optional, Set -from classy_blocks.base.exceptions import BlockNotFoundError, NoInstructionError +from classy_blocks.base.exceptions import PatchNotFoundError +from classy_blocks.grading.autograding.catalogue import Catalogue +from classy_blocks.grading.autograding.row import Row from classy_blocks.items.block import Block from classy_blocks.items.vertex import Vertex -from classy_blocks.items.wires.axis import Axis from classy_blocks.items.wires.wire import Wire from classy_blocks.mesh import Mesh from classy_blocks.optimize.grid import HexGrid -from classy_blocks.types import ChopTakeType, DirectionType, OrientType +from classy_blocks.types import DirectionType, OrientType from classy_blocks.util.constants import FACE_MAP -@functools.lru_cache(maxsize=3000) # that's for 1000 blocks -def get_block_from_axis(mesh: Mesh, axis: Axis) -> Block: - for block in mesh.blocks: - if axis in block.axes: - return block - - raise RuntimeError("Block for Axis not found!") - - @functools.lru_cache(maxsize=2) def get_defined_wall_vertices(mesh: Mesh) -> Set[Vertex]: """Returns vertices that are on the 'wall' patches""" @@ -35,132 +28,61 @@ def get_defined_wall_vertices(mesh: Mesh) -> Set[Vertex]: return wall_vertices -class Instruction: - """A descriptor that tells in which direction the specific block can be chopped.""" +@dataclasses.dataclass +class WireInfo: + """Gathers data about a wire; its location, cell sizes, neighbours and wires before/after""" - def __init__(self, block: Block): - self.block = block - self.directions: List[bool] = [False] * 3 + wire: Wire + starts_at_wall: bool + ends_at_wall: bool @property - def is_defined(self): - return all(self.directions) - - def __hash__(self) -> int: - return id(self) - - -class Row: - """A string of blocks that must share the same count - because they sit next to each other. - - This needs not be an actual 'string' of blocks because, - depending on blocking, a whole 'layer' of blocks can be - chopped by specifying a single block only (for example, direction 2 in a cylinder)""" - - def __init__(self, direction: DirectionType): - self.direction = direction - - # blocks of different orientations can belong to the same row; - # remember how they are oriented - self.blocks: List[Block] = [] - self.headings: List[DirectionType] = [] - - def add_block(self, block: Block, row_direction: DirectionType) -> None: - self.blocks.append(block) - self.headings.append(row_direction) - - def get_length(self, take: ChopTakeType = "avg"): - lengths = [wire.length for wire in self.get_wires()] - - if take == "min": - return min(lengths) - - if take == "max": - return max(lengths) - - return sum(lengths) / len(self.blocks) / 4 # "avg" - - def get_axes(self) -> List[Axis]: - axes: List[Axis] = [] - - for i, block in enumerate(self.blocks): - direction = self.headings[i] - axes.append(block.axes[direction]) - - return axes - - def get_wires(self) -> List[Wire]: - wires: List[Wire] = [] - - for axis in self.get_axes(): - wires += axis.wires + def length(self) -> float: + return self.wire.length - return wires - - def get_count(self) -> Optional[int]: - for wire in self.get_wires(): - if wire.is_defined: - return wire.grading.count - - return None - - -class Catalogue: - """A collection of rows on a specified axis""" - - def __init__(self, mesh: Mesh): - self.mesh = mesh - - self.rows: Dict[DirectionType, List[Row]] = {0: [], 1: [], 2: []} - self.instructions = [Instruction(block) for block in mesh.blocks] - - for i in get_args(DirectionType): - self._populate(i) - - def _get_undefined_instructions(self, direction: DirectionType) -> List[Instruction]: - return [i for i in self.instructions if not i.directions[direction]] - - def _find_instruction(self, block: Block): - # TODO: perform dedumbing on this exquisite piece of code - for instruction in self.instructions: - if instruction.block == block: - return instruction - - raise NoInstructionError(f"No instruction found for block {block}") + @property + def size_after(self) -> Optional[float]: + """Returns average cell size in wires that come after this one (in series/inline); + None if this is the last wire""" + # TODO: merge this with size_before somehow + sum_size: float = 0 + defined_count: int = 0 - def _add_block_to_row(self, row: Row, instruction: Instruction, direction: DirectionType) -> None: - row.add_block(instruction.block, direction) - instruction.directions[direction] = True + for joint in self.wire.after: + if joint.wire.grading.is_defined: + defined_count += 1 - block = instruction.block + if joint.same_dir: + sum_size += joint.wire.grading.start_size + else: + sum_size += joint.wire.grading.end_size - for neighbour_axis in block.axes[direction].neighbours: - neighbour_block = get_block_from_axis(self.mesh, neighbour_axis) + if defined_count == 0: + return None - if neighbour_block in row.blocks: - continue + return sum_size / defined_count - instruction = self._find_instruction(neighbour_block) + @property + def size_before(self) -> Optional[float]: + """Returns average cell size in wires that come before this one (in series/inline); + None if this is the first wire""" + # TODO: merge this with size_after somehow + sum_size: float = 0 + defined_count: int = 0 - self._add_block_to_row(row, instruction, neighbour_block.get_axis_direction(neighbour_axis)) + for joint in self.wire.before: + if joint.wire.grading.is_defined: + defined_count += 1 - def _populate(self, direction: DirectionType) -> None: - while True: - undefined_instructions = self._get_undefined_instructions(direction) - if len(undefined_instructions) == 0: - break + if joint.same_dir: + sum_size += joint.wire.grading.end_size + else: + sum_size += joint.wire.grading.start_size - row = Row(direction) - self._add_block_to_row(row, undefined_instructions[0], direction) - self.rows[direction].append(row) + if defined_count == 0: + return None - def get_row_blocks(self, block: Block, direction: DirectionType) -> List[Block]: - for row in self.rows[direction]: - if block in row.blocks: - return row.blocks - - raise BlockNotFoundError(f"Direction {direction} of {block} not in catalogue") + return sum_size / defined_count class Probe: @@ -192,6 +114,10 @@ def get_default_wall_vertices(self, block: Block) -> Set[Vertex]: """Returns vertices that lie on default 'wall' patch""" wall_vertices: Set[Vertex] = set() + if "kind" not in self.mesh.patch_list.default: + # the mesh has no default wall patch + return wall_vertices + # other sides when mesh has a default wall patch if self.mesh.patch_list.default["kind"] == "wall": # find block boundaries @@ -204,10 +130,23 @@ def get_default_wall_vertices(self, block: Block) -> Set[Vertex]: ] for orient in boundaries: - wall_vertices.union({block.vertices[i] for i in FACE_MAP[orient]}) + side_vertices = {block.vertices[i] for i in FACE_MAP[orient]} + # check if they are defined elsewhere + try: + self.mesh.patch_list.find(side_vertices) + # the patch is defined elsewhere and is not included here among default ones + continue + except PatchNotFoundError: + wall_vertices.update(side_vertices) return wall_vertices def get_wall_vertices(self, block: Block) -> Set[Vertex]: """Returns vertices that are on the 'wall' patches""" return self.get_explicit_wall_vertices(block).union(self.get_default_wall_vertices(block)) + + def get_wire_info(self, wire: Wire, block: Block) -> WireInfo: + # TODO: test + wall_vertices = self.get_wall_vertices(block) + + return WireInfo(wire, wire.vertices[0] in wall_vertices, wire.vertices[1] in wall_vertices) diff --git a/src/classy_blocks/grading/autograding/row.py b/src/classy_blocks/grading/autograding/row.py new file mode 100644 index 0000000..a4211d7 --- /dev/null +++ b/src/classy_blocks/grading/autograding/row.py @@ -0,0 +1,114 @@ +import dataclasses +from typing import List, Set + +from classy_blocks.items.block import Block +from classy_blocks.items.wires.axis import Axis +from classy_blocks.items.wires.wire import Wire +from classy_blocks.types import ChopTakeType, DirectionType + + +@dataclasses.dataclass +class Entry: + block: Block + # blocks of different orientations can belong to the same row; + # remember how they are oriented + heading: DirectionType + # also keep track of blocks that are upside-down; + # 'True' means the block is overturned + flipped: bool + + @property + def axis(self) -> Axis: + return self.block.axes[self.heading] + + @property + def wires(self) -> List[Wire]: + return self.axis.wires.wires + + @property + def neighbours(self) -> Set[Axis]: + return self.axis.neighbours + + @property + def lengths(self) -> List[float]: + return [wire.length for wire in self.wires] + + +class Row: + """A string of blocks that must share the same count + because they sit next to each other. + + This may not be an actual 'string' of blocks because, + depending on blocking, a whole 'layer' of blocks can be + chopped by specifying a single block only (for example, direction 2 in a cylinder)""" + + def __init__(self): + self.entries: List[Entry] = [] + + # the whole row must share the same cell count; + # it's determined if it's greater than 0 + self.count = 0 + + def add_block(self, block: Block, heading: DirectionType) -> None: + axis = block.axes[heading] + axis.grade() + + # check neighbours for alignment + if len(self.entries) == 0: + flipped = False + else: + # find a block's neighbour among existing entries + # and determine its relative alignment + for entry in self.entries: + if axis in entry.neighbours: + flipped = not entry.axis.is_aligned(axis) + if entry.flipped: + flipped = not flipped + + break + else: + # TODO: nicer message + raise RuntimeError("No neighbour found!") + + self.entries.append(Entry(block, heading, flipped)) + + # take count from block, if it's manually defined + # TODO: make this a separate method + # TODO: un-ififif by handling axis' chops separately + for wire in axis.wires: + if wire.is_defined: + if self.count != 0: + if self.count != wire.grading.count: + # TODO! Custom exception + raise RuntimeError( + f"Inconsistent counts (existing {self.count}, replaced with {wire.grading.count}" + ) + self.count = wire.grading.count + + def get_length(self, take: ChopTakeType = "avg"): + lengths: List[float] = [] + for entry in self.entries: + lengths += entry.lengths + + if take == "min": + return min(lengths) + + if take == "max": + return max(lengths) + + return sum(lengths) / len(self.entries) / 4 # "avg" + + def get_axes(self) -> List[Axis]: + return [entry.axis for entry in self.entries] + + def get_wires(self) -> List[Wire]: + wires: List[Wire] = [] + + for axis in self.get_axes(): + wires += axis.wires + + return wires + + @property + def blocks(self) -> List[Block]: + return [entry.block for entry in self.entries] diff --git a/src/classy_blocks/grading/grading.py b/src/classy_blocks/grading/grading.py index 89084b5..194fbe1 100644 --- a/src/classy_blocks/grading/grading.py +++ b/src/classy_blocks/grading/grading.py @@ -131,8 +131,9 @@ def copy(self, length: float, invert: bool = False) -> "Grading": } new_grading.add_chop(Chop(**new_args)) - if invert: - new_grading.inverted = not new_grading.inverted + + if invert: + new_grading.inverted = not new_grading.inverted return new_grading diff --git a/src/classy_blocks/items/wires/wire.py b/src/classy_blocks/items/wires/wire.py index e4168a4..068cbde 100644 --- a/src/classy_blocks/items/wires/wire.py +++ b/src/classy_blocks/items/wires/wire.py @@ -1,5 +1,5 @@ import dataclasses -from typing import List, Optional, Set +from typing import List, Set from classy_blocks.base.exceptions import InconsistentGradingsError from classy_blocks.construct.edges import Line @@ -83,6 +83,7 @@ def add_inline(self, candidate: "Wire") -> None: in the same direction""" # this assumes the lines are inline and in the same axis # TODO: Test + # TODO: one-liner, bitte if candidate == self: return @@ -115,49 +116,5 @@ def check_consistency(self) -> None: def is_defined(self) -> bool: return self.grading.is_defined - @property - def size_after(self) -> Optional[float]: - """Returns average cell size in wires that come after this one (in series/inline); - None if this is the last wire""" - # TODO: merge this with size_before somehow - sum_size: float = 0 - defined_count: int = 0 - - for joint in self.after: - if joint.wire.grading.is_defined: - defined_count += 1 - - if joint.same_dir: - sum_size += joint.wire.grading.start_size - else: - sum_size += joint.wire.grading.end_size - - if defined_count == 0: - return None - - return sum_size / defined_count - - @property - def size_before(self) -> Optional[float]: - """Returns average cell size in wires that come before this one (in series/inline); - None if this is the first wire""" - # TODO: merge this with size_after somehow - sum_size: float = 0 - defined_count: int = 0 - - for joint in self.before: - if joint.wire.grading.is_defined: - defined_count += 1 - - if joint.same_dir: - sum_size += joint.wire.grading.end_size - else: - sum_size += joint.wire.grading.start_size - - if defined_count == 0: - return None - - return sum_size / defined_count - def __repr__(self): return f"Wire {self.corners[0]}-{self.corners[1]} ({self.vertices[0].index}-{self.vertices[1].index})" diff --git a/src/classy_blocks/lists/patch_list.py b/src/classy_blocks/lists/patch_list.py index 017b55b..2ce38fc 100644 --- a/src/classy_blocks/lists/patch_list.py +++ b/src/classy_blocks/lists/patch_list.py @@ -1,6 +1,7 @@ from collections import OrderedDict from typing import Dict, List, Optional, Set +from classy_blocks.base.exceptions import PatchNotFoundError from classy_blocks.construct.operations.operation import Operation from classy_blocks.items.patch import Patch from classy_blocks.items.side import Side @@ -28,6 +29,14 @@ def get(self, name: str) -> Patch: return self.patches[name] + def find(self, vertices: Set[Vertex]) -> Patch: + for patch in self.patches.values(): + for side in patch.sides: + if set(side.vertices) == vertices: + return patch + + raise PatchNotFoundError + def add_side(self, patch_name: str, orient: OrientType, vertices: List[Vertex]) -> None: """Adds a quad to an existing patch or creates a new one""" self.get(patch_name).add_side(Side(orient, vertices)) diff --git a/src/classy_blocks/mesh.py b/src/classy_blocks/mesh.py index 489a2ab..df6aaaf 100644 --- a/src/classy_blocks/mesh.py +++ b/src/classy_blocks/mesh.py @@ -125,7 +125,8 @@ def assemble(self) -> None: blockMeshDict. After this has been done, the above objects cease to have any function or influence on mesh.""" if self.is_assembled: - # don't assemble twice + # don't assemble twice but do update wire lengths + self.block_list.update() return operations = self._operations diff --git a/tests/test_grading/test_autograde.py b/tests/test_grading/test_autograde.py index e902b98..cd7604e 100644 --- a/tests/test_grading/test_autograde.py +++ b/tests/test_grading/test_autograde.py @@ -1,5 +1,7 @@ import unittest +import numpy as np + from classy_blocks.construct.flat.sketches.grid import Grid from classy_blocks.construct.shapes.cylinder import Cylinder from classy_blocks.construct.shapes.frustum import Frustum @@ -10,9 +12,17 @@ class AutogradeTestsBase(unittest.TestCase): def get_stack(self) -> ExtrudedStack: - # create a simple 3x3 grid for easy navigation - base = Grid([0, 0, 0], [1, 1, 0], 3, 3) - return ExtrudedStack(base, 1, 3) + # create a simple 4x4 grid for easy navigation + base = Grid([0, 0, 0], [1, 1, 0], 4, 4) + return ExtrudedStack(base, 1, 4) + + def get_flipped_stack(self) -> ExtrudedStack: + stack = self.get_stack() + + for i in (5, 6, 8, 9): + stack.operations[i].rotate(np.pi, [0, 1, 0]) + + return stack def get_cylinder(self) -> Cylinder: return Cylinder([0, 0, 0], [1, 0, 0], [0, 1, 0]) @@ -42,12 +52,12 @@ def test_simple_grader_stack(self): self.mesh.add(stack) self.mesh.assemble() - grader = SimpleGrader(self.mesh, 0.1) + grader = SimpleGrader(self.mesh, 0.05) grader.grade() for block in self.mesh.blocks: for axis in block.axes: - self.assertEqual(axis.count, 3) + self.assertEqual(axis.count, 5) def test_highre_cylinder(self): self.mesh.add(self.get_cylinder()) diff --git a/tests/test_grading/test_probe.py b/tests/test_grading/test_probe.py index 55fdc6b..be8d90a 100644 --- a/tests/test_grading/test_probe.py +++ b/tests/test_grading/test_probe.py @@ -1,8 +1,10 @@ from typing import Set, get_args +import numpy as np from parameterized import parameterized -from classy_blocks.grading.autograding.probe import Probe, get_block_from_axis +from classy_blocks.grading.autograding.catalogue import get_block_from_axis +from classy_blocks.grading.autograding.probe import Probe, get_defined_wall_vertices from classy_blocks.items.vertex import Vertex from classy_blocks.mesh import Mesh from classy_blocks.modify.find.shape import RoundSolidFinder @@ -41,24 +43,9 @@ def test_get_blocks_on_layer(self, block, axis): probe = Probe(self.mesh) blocks = probe.get_row_blocks(self.mesh.blocks[block], axis) - self.assertEqual(len(blocks), 9) + self.assertEqual(len(blocks), 16) - @parameterized.expand( - ( - (0,), - (1,), - (2,), - (3,), - (4,), - (5,), - (6,), - (7,), - (8,), - (9,), - (10,), - (11,), - ) - ) + @parameterized.expand(((0,), (1,), (2,), (3,), (4,), (5,), (6,), (7,), (8,), (9,), (10,), (11,))) def test_block_from_axis(self, index): self.mesh.add(self.get_cylinder()) self.mesh.assemble() @@ -76,7 +63,7 @@ def test_get_layers(self, axis): probe = Probe(self.mesh) layers = probe.get_rows(axis) - self.assertEqual(len(layers), 3) + self.assertEqual(len(layers), 4) @parameterized.expand( ( @@ -101,6 +88,28 @@ def test_get_blocks_cylinder(self, axis, row, blocks): self.assertSetEqual(indexes, blocks) + @parameterized.expand( + ( + # axis, layer, block indexes + (1, 0, {0, 1, 2, 3}), + (1, 1, {4, 5, 6, 7}), + (1, 2, {8, 9, 10, 11}), + (1, 3, {12, 13, 14, 15}), + ) + ) + def test_get_blocks_inverted(self, axis, row, blocks): + shape = self.get_flipped_stack().shapes[1] + self.mesh.add(shape) + self.mesh.assemble() + + probe = Probe(self.mesh) + indexes = set() + + for block in probe.catalogue.rows[axis][row].blocks: + indexes.add(block.index) + + self.assertSetEqual(indexes, blocks) + def test_wall_vertices_defined(self) -> None: """Catch wall vertices from explicitly defined wall patches""" cylinder = self.get_cylinder() @@ -110,14 +119,11 @@ def test_wall_vertices_defined(self) -> None: self.mesh.modify_patch("outer", "wall") self.mesh.assemble() - probe = Probe(self.mesh) - finder = RoundSolidFinder(self.mesh, cylinder) shell_vertices = finder.find_shell(True).union(finder.find_shell(False)) wall_vertices: Set[Vertex] = set() - for block in self.mesh.blocks: - wall_vertices.update(probe.get_explicit_wall_vertices(block)) + wall_vertices.update(get_defined_wall_vertices(self.mesh)) self.assertSetEqual(shell_vertices, wall_vertices) @@ -126,6 +132,7 @@ def test_wall_vertices_default(self) -> None: cylinder = self.get_cylinder() cylinder.set_start_patch("inlet") cylinder.set_end_patch("outlet") + self.mesh.add(cylinder) self.mesh.set_default_patch("outer", "wall") self.mesh.assemble() @@ -137,7 +144,7 @@ def test_wall_vertices_default(self) -> None: wall_vertices: Set[Vertex] = set() for block in self.mesh.blocks: - wall_vertices.update(probe.get_default_wall_vertices(block)) + wall_vertices.update(probe.get_wall_vertices(block)) self.assertSetEqual(shell_vertices, wall_vertices) @@ -146,6 +153,8 @@ def test_wall_vertices_combined(self) -> None: cylinder.set_end_patch("outlet") cylinder.set_start_patch("bottom") + self.mesh.add(cylinder) + self.mesh.modify_patch("bottom", "wall") self.mesh.set_default_patch("outer", "wall") @@ -158,6 +167,41 @@ def test_wall_vertices_combined(self) -> None: wall_vertices: Set[Vertex] = set() for block in self.mesh.blocks: - wall_vertices.update(probe.get_default_wall_vertices(block)) + wall_vertices.update(probe.get_wall_vertices(block)) self.assertSetEqual(shell_vertices, wall_vertices) + + def test_flipped_simple(self): + shape = self.get_stack().shapes[0] + shape.grid[0][1].rotate(np.pi, [0, 0, 1]) + + self.mesh.add(shape) + self.mesh.assemble() + + probe = Probe(self.mesh) + row = probe.get_rows(1)[0] + + self.assertListEqual([entry.flipped for entry in row.entries], [False, True, False, False]) + + @parameterized.expand( + ( + ((1,), 0, 1), + ((1, 2), 0, 1), + ((1, 2), 0, 2), + ((1, 2, 5), 1, 1), + ((0,), 0, 1), + ((0,), 0, 2), + ) + ) + def test_flipped_shape(self, flip_indexes, check_row, check_index): + stack = self.get_stack().shapes[0] + + for i in flip_indexes: + stack.operations[i].rotate(np.pi, [0, 0, 1]) + + self.mesh.add(stack) + self.mesh.assemble() + + probe = Probe(self.mesh) + + self.assertTrue(probe.get_rows(1)[check_row].entries[check_index].flipped)