diff --git a/src/arcade/core/util/Vector.java b/src/arcade/core/util/Vector.java index d71a16caa..efdcafc8d 100644 --- a/src/arcade/core/util/Vector.java +++ b/src/arcade/core/util/Vector.java @@ -33,7 +33,7 @@ public Vector(Double3D vector) { * @return the x component */ public double getX() { - return vector.x; + return vector.getX(); } /** @@ -42,7 +42,7 @@ public double getX() { * @return the y component */ public double getY() { - return vector.y; + return vector.getY(); } /** @@ -51,7 +51,7 @@ public double getY() { * @return the z component */ public double getZ() { - return vector.z; + return vector.getZ(); } /** diff --git a/src/arcade/potts/agent/cell/PottsCell.java b/src/arcade/potts/agent/cell/PottsCell.java index ab3996071..b32f29600 100644 --- a/src/arcade/potts/agent/cell/PottsCell.java +++ b/src/arcade/potts/agent/cell/PottsCell.java @@ -79,7 +79,7 @@ public abstract class PottsCell implements Cell { private final EnumMap targetRegionSurfaces; /** Critical volume for cell [voxels]. */ - final double criticalVolume; + double criticalVolume; /** Critical volumes for cell by region [voxels]. */ final EnumMap criticalRegionVolumes; @@ -295,6 +295,10 @@ public double getCriticalVolume() { return criticalVolume; } + public void setCriticalVolume(double newCriticaVolume) { + criticalVolume = newCriticaVolume; + } + /** * Gets the critical volume for a region. * diff --git a/src/arcade/potts/agent/cell/PottsCellContainer.java b/src/arcade/potts/agent/cell/PottsCellContainer.java index 6bc855314..10e3f170b 100644 --- a/src/arcade/potts/agent/cell/PottsCellContainer.java +++ b/src/arcade/potts/agent/cell/PottsCellContainer.java @@ -176,6 +176,10 @@ public Cell convert( return new PottsCellFlyNeuron(this, location, parameters, links); case "fly-gmc": return new PottsCellFlyGMC(this, location, parameters, links); + case "fly-stem-wt": + return new PottsCellFlyStem(this, location, parameters, links); + case "fly-stem-mudmut": + return new PottsCellFlyStem(this, location, parameters, links); default: case "stem": return new PottsCellStem(this, location, parameters, links); diff --git a/src/arcade/potts/agent/cell/PottsCellFlyStem.java b/src/arcade/potts/agent/cell/PottsCellFlyStem.java new file mode 100644 index 000000000..b15e940b0 --- /dev/null +++ b/src/arcade/potts/agent/cell/PottsCellFlyStem.java @@ -0,0 +1,165 @@ +package arcade.potts.agent.cell; + +import ec.util.MersenneTwisterFast; +import arcade.core.agent.cell.CellState; +import arcade.core.env.location.Location; +import arcade.core.util.GrabBag; +import arcade.core.util.Parameters; +import arcade.core.util.Vector; +import arcade.potts.agent.module.PottsModule; +import arcade.potts.agent.module.PottsModuleProliferationFlyStem; +import arcade.potts.util.PottsEnums.Phase; +import arcade.potts.util.PottsEnums.State; + +/** Extension of {@link PottsCell} for fly stem cells. */ +public class PottsCellFlyStem extends PottsCell { + + /** Enum outlining parameters for each cell type. */ + public enum StemType { + /** Wild type stem cell. */ + WT(50, 75, 0, 0.25), + + /** mud Mutant stem cell. */ + MUDMUT(50, 50, -90, 0.5); + + /** Percentage x offset from cell edge where division will occur. */ + public final int splitOffsetPercentX; + + /** Percentage y offset from cell edge where division will occur. */ + public final int splitOffsetPercentY; + + /** Default direction of division is rotated this much off the apical vector. */ + public final double splitDirectionRotation; + + /** + * The proportion of the stem cell's critical volume that will be the daughter cell's + * critical volume. + */ + public final double daughterCellCriticalVolumeProportion; + + /** + * Constructor for StemType. + * + * @param splitOffsetPercentX percentage x offset from cell edge where division will occur + * @param splitOffsetPercentY percentage y offset from cell edge where division will occur + * @param splitDirectionRotation the plane of division's rotation off the apical vector + * @param daughterCellCriticalVolumeProportion proportion of the stem cell's critical volume + * that will be the daughter cell's critical volume + */ + StemType( + int splitOffsetPercentX, + int splitOffsetPercentY, + double splitDirectionRotation, + double daughterCellCriticalVolumeProportion) { + this.splitOffsetPercentX = splitOffsetPercentX; + this.splitOffsetPercentY = splitOffsetPercentY; + this.splitDirectionRotation = splitDirectionRotation; + this.daughterCellCriticalVolumeProportion = daughterCellCriticalVolumeProportion; + } + } + + /** The type of stem cell. */ + public final StemType stemType; + + private Vector apicalAxis; + + /** + * Constructor for PottsCellFlyStem. + * + * @param container the container for the cell + * @param location the location of the cell + * @param parameters the parameters for the cell + * @param links the links for the cell + * @throws IllegalArgumentException if the stem type is not recognized + */ + public PottsCellFlyStem( + PottsCellContainer container, Location location, Parameters parameters, GrabBag links) { + super(container, location, parameters, links); + + if (module != null) { + ((PottsModule) module).setPhase(Phase.UNDEFINED); + } + + String stemTypeString = parameters.getString("CLASS"); + switch (stemTypeString) { + case "fly-stem-wt": + stemType = StemType.WT; + break; + case "fly-stem-mudmut": + stemType = StemType.MUDMUT; + break; + default: + throw new IllegalArgumentException("Unknown StemType: " + stemTypeString); + } + } + + public void setApicalAxis(Vector apicalAxis) { + this.apicalAxis = apicalAxis; + } + + /** + * Gets the apical axis of the cell. If no apical axis is set, it returns a vector along the y + * axis as a default vector + * + * @return the apical axis of the cell + */ + public Vector getApicalAxis() { + if (apicalAxis != null) { + return apicalAxis; + } else { + return new Vector(0, 1, 0); + } + } + + @Override + public PottsCellContainer make(int newID, CellState newState, MersenneTwisterFast random) { + throw new UnsupportedOperationException( + "make(int, CellState, MersenneTwisterFast) not supported. Please use make(int, CellState, MersenneTwisterFast, int, double) instead."); + } + + public PottsCellContainer make( + int newID, + CellState newState, + MersenneTwisterFast random, + int newPop, + double daughterCellCriticalVolume) { + + divisions++; + + return new PottsCellContainer( + newID, + id, + newPop, + age, + divisions, + newState, + Phase.UNDEFINED, + 0, + null, + daughterCellCriticalVolume, + criticalHeight, + criticalRegionVolumes, + criticalRegionHeights); + } + + @Override + void setStateModule(CellState newState) { + switch ((State) newState) { + case PROLIFERATIVE: + module = new PottsModuleProliferationFlyStem(this); + break; + default: + module = null; + break; + } + } + + /** + * Gets the stem type of the cell. + * + * @return the stem type of the cell + */ + public final StemType getStemType() { + return stemType; + } +} diff --git a/src/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiation.java b/src/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiation.java index ec7cdba9c..30e59e9f0 100644 --- a/src/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiation.java +++ b/src/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiation.java @@ -11,6 +11,7 @@ import arcade.potts.env.location.PottsLocation2D; import arcade.potts.sim.Potts; import arcade.potts.sim.PottsSimulation; +import arcade.potts.util.PottsEnums.Phase; import arcade.potts.util.PottsEnums.State; /** @@ -20,6 +21,27 @@ */ public class PottsModuleFlyGMCDifferentiation extends PottsModuleProliferationSimple { + void stepVolOnly(MersenneTwisterFast random, Simulation sim) { + // Increase size of cell. + cell.updateTarget(cellGrowthRate, sizeTarget); + boolean sizeCheck = cell.getVolume() >= sizeTarget * cell.getCriticalVolume(); + if (sizeCheck) { + addCell(random, sim); + setPhase(Phase.UNDEFINED); + } + } + + @Override + public void step(MersenneTwisterFast random, Simulation sim) { + switch (phase) { + case UNDEFINED: + stepVolOnly(random, sim); + break; + default: + break; + } + } + /** * Creates a fly GMC proliferation module. * @@ -65,7 +87,7 @@ void addCell(MersenneTwisterFast random, Simulation sim) { newPop, oldCell.getAge(), oldCell.getDivisions(), - State.QUIESCENT, + State.UNDEFINED, null, 0, null, diff --git a/src/arcade/potts/agent/module/PottsModuleProliferationFlyStem.java b/src/arcade/potts/agent/module/PottsModuleProliferationFlyStem.java new file mode 100644 index 000000000..e6ad1173a --- /dev/null +++ b/src/arcade/potts/agent/module/PottsModuleProliferationFlyStem.java @@ -0,0 +1,462 @@ +package arcade.potts.agent.module; + +import java.util.ArrayList; +import sim.util.Double3D; +import ec.util.MersenneTwisterFast; +import arcade.core.env.location.Location; +import arcade.core.sim.Simulation; +import arcade.core.util.Parameters; +import arcade.core.util.Plane; +import arcade.core.util.Vector; +import arcade.core.util.distributions.Distribution; +import arcade.core.util.distributions.NormalDistribution; +import arcade.core.util.distributions.UniformDistribution; +import arcade.potts.agent.cell.PottsCell; +import arcade.potts.agent.cell.PottsCellContainer; +import arcade.potts.agent.cell.PottsCellFlyStem; +import arcade.potts.env.location.PottsLocation; +import arcade.potts.env.location.PottsLocation2D; +import arcade.potts.env.location.Voxel; +import arcade.potts.sim.Potts; +import arcade.potts.sim.PottsSimulation; +import arcade.potts.util.PottsEnums.Direction; +import arcade.potts.util.PottsEnums.Phase; +import arcade.potts.util.PottsEnums.State; +import static arcade.potts.agent.cell.PottsCellFlyStem.StemType; + +/** Extension of {@link PottsModule} */ +public class PottsModuleProliferationFlyStem extends PottsModule { + + /** Threshold for critical volume size checkpoint. */ + static final double SIZE_CHECKPOINT = 0.95; + + /** + * Target ratio of critical volume for division size checkpoint (cell must reach CRITICAL_VOLUME + * * SIZE_TARGET * SIZE_CHECKPOINT to divide). + */ + double sizeTarget; + + /** + * Overall growth rate for cell (voxels/tick) when growth rate is not dynamic. Max growth rate + * when growth rate is dynamic + */ + final double cellGrowthRateMax; + + double cellGrowthRate; + + /** Basal rate of apoptosis (ticks^-1). */ + final double basalApoptosisRate; + + /** Fraction of nuclear volume when condensed. */ + double nucleusCondFraction; + + public static final double EPSILON = 1e-8; + + /** Distribution that determines rotational offset of cell's division plane. */ + final NormalDistribution splitDirectionDistribution; + + /** Ruleset for determining which daughter cell is the GMC. Can be `volume` or `location`. */ + final String differentiationRuleset; + + final String apicalAxisRuleset; + + final Distribution apicalAxisRotationDistribution; + + final boolean dynamicGrowthRateVolume; + + final double dynamicGrowthRateMultiplier; + + final boolean volumeBasedCriticalVolume; + + final double volumeBasedCriticalVolumeMultiplier; + + /** + * Range of values considered equal when determining daughter cell identity. ex. if ruleset is + * location, range determines the distance between centroid y values that is considered equal. + */ + final double range; + + /** + * Creates a simple proliferation {@code Module} for the given {@link PottsCellFlyStem}. + * + * @param cell the {@link PottsCellFlyStem} the module is associated with + */ + public PottsModuleProliferationFlyStem(PottsCellFlyStem cell) { + super(cell); + + if (cell.hasRegions()) { + throw new UnsupportedOperationException( + "Regions are not yet implemented for fly cells"); + } + + Parameters parameters = cell.getParameters(); + + sizeTarget = parameters.getDouble("proliferation/SIZE_TARGET"); + cellGrowthRateMax = parameters.getDouble("proliferation/CELL_GROWTH_RATE"); + basalApoptosisRate = parameters.getDouble("proliferation/BASAL_APOPTOSIS_RATE"); + nucleusCondFraction = parameters.getDouble("proliferation/NUCLEUS_CONDENSATION_FRACTION"); + + splitDirectionDistribution = + (NormalDistribution) + parameters.getDistribution("proliferation/DIV_ROTATION_DISTRIBUTION"); + differentiationRuleset = parameters.getString("proliferation/DIFFERENTIATION_RULESET"); + range = parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE"); + apicalAxisRuleset = parameters.getString("proliferation/APICAL_AXIS_RULESET"); + apicalAxisRotationDistribution = + (Distribution) + parameters.getDistribution( + "proliferation/APICAL_AXIS_ROTATION_DISTRIBUTION"); + + dynamicGrowthRateVolume = + (parameters.getInt("proliferation/DYNAMIC_GROWTH_RATE_VOLUME") != 0); + dynamicGrowthRateMultiplier = parameters.getDouble("proliferation/GROWTH_RATE_MULTIPLIER"); + updateVolumeBasedGrowthRate(); + + volumeBasedCriticalVolume = + (parameters.getInt("proliferation/VOLUME_BASED_CRITICAL_VOLUME") != 0); + volumeBasedCriticalVolumeMultiplier = + parameters.getDouble("proliferation/VOLUME_BASED_CRITICAL_VOLUME_MULTIPLIER"); + + setPhase(Phase.UNDEFINED); + } + + void updateVolumeBasedGrowthRate() { + if (dynamicGrowthRateVolume == false) { + cellGrowthRate = cellGrowthRateMax; + } else { + cellGrowthRate = + cellGrowthRateMax + * dynamicGrowthRateMultiplier + * (cell.getLocation().getVolume() / cell.getCriticalVolume()); + } + } + + void stepVolOnly(MersenneTwisterFast random, Simulation sim) { + // Update growth rate. + updateVolumeBasedGrowthRate(); + // Increase size of cell. + cell.updateTarget(cellGrowthRate, sizeTarget); + boolean sizeCheck = cell.getVolume() >= sizeTarget * cell.getCriticalVolume(); + if (sizeCheck) { + addCell(random, sim); + setPhase(Phase.UNDEFINED); + } + } + + @Override + public void step(MersenneTwisterFast random, Simulation sim) { + switch (phase) { + case UNDEFINED: + stepVolOnly(random, sim); + break; + default: + throw new UnsupportedOperationException( + "Fly Stem Proliferation Module must be in undefined state"); + } + } + + void addCell(MersenneTwisterFast random, Simulation sim) { + Potts potts = ((PottsSimulation) sim).getPotts(); + PottsCellFlyStem flyStemCell = (PottsCellFlyStem) cell; + + Plane divisionPlane = chooseDivisionPlane(flyStemCell); + PottsLocation2D parentLoc = (PottsLocation2D) cell.getLocation(); + PottsLocation daughterLoc = (PottsLocation) parentLoc.split(random, divisionPlane); + + boolean isDaughterStem = daughterStem(parentLoc, daughterLoc); + + if (isDaughterStem) { + makeDaughterStemCell(daughterLoc, sim, potts, random); + } else { + makeDaughterGMC( + parentLoc, + daughterLoc, + sim, + potts, + random, + divisionPlane.getUnitNormalVector()); + } + } + + protected Plane chooseDivisionPlane(PottsCellFlyStem flyStemCell) { + double offset = sampleDivisionPlaneOffset(); + + if (flyStemCell.getStemType() == StemType.WT + || (flyStemCell.getStemType() == StemType.MUDMUT && Math.abs(offset) < 45)) { + return getWTDivisionPlaneWithRotationalVariance(flyStemCell, offset); + } else { + return getMUDDivisionPlane(flyStemCell); + } + } + + private void makeDaughterStemCell( + PottsLocation daughterLoc, Simulation sim, Potts potts, MersenneTwisterFast random) { + cell.reset(potts.ids, potts.regions); + int newID = sim.getID(); + double criticalVol; + if (volumeBasedCriticalVolume) { + criticalVol = daughterLoc.getVolume() * volumeBasedCriticalVolumeMultiplier; + cell.setCriticalVolume( + cell.getLocation().getVolume() * volumeBasedCriticalVolumeMultiplier); + // System.out.println("Stem Daughter critical volume = " + criticalVol); + // System.out.println("Stem Parent critical volume = " + cell.getCriticalVolume()); + } else { + criticalVol = cell.getCriticalVolume(); + } + PottsCellContainer container = + ((PottsCellFlyStem) cell) + .make(newID, State.PROLIFERATIVE, random, cell.getPop(), criticalVol); + scheduleNewCell(container, daughterLoc, sim, potts, random); + } + + private void makeDaughterGMC( + PottsLocation parentLoc, + PottsLocation daughterLoc, + Simulation sim, + Potts potts, + MersenneTwisterFast random, + Vector divisionPlaneNormal) { + Location gmcLoc = determineGMCLocation(parentLoc, daughterLoc, divisionPlaneNormal); + + if (parentLoc == gmcLoc) { + PottsLocation.swapVoxels(parentLoc, daughterLoc); + } + // System.out.println("---------- NEW DIVISION ----------"); + // System.out.println("Stem cell curr volume = " + parentLoc.getVolume()); + cell.reset(potts.ids, potts.regions); + int newID = sim.getID(); + int newPop = ((PottsCellFlyStem) cell).getLinks().next(random); + double criticalVolume = + calculateGMCDaughterCellCriticalVolume((PottsLocation) daughterLoc, sim, newPop); + PottsCellContainer container = + ((PottsCellFlyStem) cell) + .make(newID, State.PROLIFERATIVE, random, newPop, criticalVolume); + scheduleNewCell(container, daughterLoc, sim, potts, random); + } + + private Location determineGMCLocation( + PottsLocation parentLoc, PottsLocation daughterLoc, Vector divisionPlaneNormal) { + switch (differentiationRuleset) { + case "volume": + return getSmallerLocation(parentLoc, daughterLoc); + case "location": + return getBasalLocation(parentLoc, daughterLoc, divisionPlaneNormal); + default: + throw new IllegalArgumentException( + "Invalid differentiation ruleset: " + differentiationRuleset); + } + } + + private void scheduleNewCell( + PottsCellContainer container, + PottsLocation daughterLoc, + Simulation sim, + Potts potts, + MersenneTwisterFast random) { + PottsCell newCell = + (PottsCell) container.convert(sim.getCellFactory(), daughterLoc, random); + if (newCell.getClass() == PottsCellFlyStem.class) { + ((PottsCellFlyStem) newCell).setApicalAxis(getDaughterCellApicalAxis(random)); + } + sim.getGrid().addObject(newCell, null); + potts.register(newCell); + newCell.reset(potts.ids, potts.regions); + newCell.schedule(sim.getSchedule()); + } + + public boolean daughterStem(PottsLocation loc1, PottsLocation loc2) { + if (((PottsCellFlyStem) cell).getStemType() == StemType.WT) { + return false; + } else if (((PottsCellFlyStem) cell).getStemType() == StemType.MUDMUT) { + if (differentiationRuleset.equals("volume")) { + double vol1 = loc1.getVolume(); + double vol2 = loc2.getVolume(); + if (Math.abs(vol1 - vol2) < range) { + return true; + } else { + return false; + } + } else if (differentiationRuleset.equals("location")) { + double[] centroid1 = loc1.getCentroid(); + double[] centroid2 = loc2.getCentroid(); + return (centroidsWithinRangeAlongApicalAxis( + centroid1, centroid2, ((PottsCellFlyStem) cell).getApicalAxis(), range)); + } + } + throw new IllegalArgumentException( + "Invalid differentiation ruleset: " + differentiationRuleset); + } + + /** + * Determines if the distance between two centroids, projected along the apical axis, is less + * than or equal to the given range. + * + * @param centroid1 First centroid position. + * @param centroid2 Second centroid position. + * @param apicalAxis Unit {@link Vector} defining the apical-basal direction. + * @param range Maximum allowed distance along the apical axis. + * @return true if the centroids are within the given range along the apical axis. + */ + static boolean centroidsWithinRangeAlongApicalAxis( + double[] centroid1, double[] centroid2, Vector apicalAxis, double range) { + + Vector c1 = new Vector(centroid1[0], centroid1[1], centroid1.length > 2 ? centroid1[2] : 0); + Vector c2 = new Vector(centroid2[0], centroid2[1], centroid2.length > 2 ? centroid2[2] : 0); + + double proj1 = Vector.dotProduct(c1, apicalAxis); + double proj2 = Vector.dotProduct(c2, apicalAxis); + + double distanceAlongAxis = Math.abs(proj1 - proj2); + + return distanceAlongAxis - range <= EPSILON; + } + + protected double calculateGMCDaughterCellCriticalVolume( + PottsLocation gmcLoc, Simulation sim, int newpop) { + double max_crit_vol = + ((PottsCellFlyStem) cell).getCriticalVolume() + * sizeTarget + * ((PottsCellFlyStem) cell) + .getStemType() + .daughterCellCriticalVolumeProportion; + if (volumeBasedCriticalVolume) { + System.out.println("gmc Daughter current volume: " + (gmcLoc.getVolume())); + System.out.println( + "gmc Daughter critical volume: " + + (gmcLoc.getVolume() * volumeBasedCriticalVolumeMultiplier)); + System.out.println("Otherwise critical volume would have been: " + (max_crit_vol)); + System.out.println( + "Parent stem cell critical volume = " + + ((PottsCellFlyStem) cell).getCriticalVolume()); + return gmcLoc.getVolume() * volumeBasedCriticalVolumeMultiplier; + } else { + return max_crit_vol; + } + } + + /** + * Gets the division plane for the cell after rotating the plane according to + * splitDirectionDistribution. This follows WT division rules. The plane is rotated around the + * XY plane. + * + * @param cell the {@link PottsCellFlyStem} to get the division plane for + * @param rotationOffset the angle to rotate the plane + * @return the division plane for the cell + */ + public Plane getWTDivisionPlaneWithRotationalVariance( + PottsCellFlyStem cell, double rotationOffset) { + // System.out.println("Rotation Offset: " + rotationOffset); + Vector apical_axis = cell.getApicalAxis(); + Vector rotatedNormalVector = + Vector.rotateVectorAroundAxis( + apical_axis, Direction.XY_PLANE.vector, rotationOffset); + Voxel splitVoxel = getCellSplitVoxel(StemType.WT, cell, rotatedNormalVector); + return new Plane( + new Double3D(splitVoxel.x, splitVoxel.y, splitVoxel.z), rotatedNormalVector); + } + + /** + * Gets the division plane for the cell. This follows MUDMUT division rules. The division plane + * is not rotated. + * + * @param cell the {@link PottsCellFlyStem} to get the division plane for + * @return the division plane for the cell + */ + public Plane getMUDDivisionPlane(PottsCellFlyStem cell) { + Vector defaultNormal = + Vector.rotateVectorAroundAxis( + cell.getApicalAxis(), + Direction.XY_PLANE.vector, + StemType.MUDMUT.splitDirectionRotation); + Voxel splitVoxel = getCellSplitVoxel(StemType.MUDMUT, cell, defaultNormal); + return new Plane(new Double3D(splitVoxel.x, splitVoxel.y, splitVoxel.z), defaultNormal); + } + + /** + * Gets the rotation offset for the division plane according to splitDirectionDistribution. + * + * @return the rotation offset for the division plane + */ + double sampleDivisionPlaneOffset() { + return splitDirectionDistribution.nextDouble(); + } + + public Vector getDaughterCellApicalAxis(MersenneTwisterFast random) { + switch (apicalAxisRuleset) { + case "uniform": + if (!(apicalAxisRotationDistribution instanceof UniformDistribution)) { + throw new IllegalArgumentException( + "apicalAxisRotationDistribution must be a UniformDistribution under the uniform apical axis ruleset."); + } + Vector newRandomApicalAxis = + Vector.rotateVectorAroundAxis( + ((PottsCellFlyStem) cell).getApicalAxis(), + Direction.XY_PLANE.vector, + apicalAxisRotationDistribution.nextDouble()); + return newRandomApicalAxis; + case "global": + return ((PottsCellFlyStem) cell).getApicalAxis(); + case "rotation": + if (!(apicalAxisRotationDistribution instanceof NormalDistribution)) { + throw new IllegalArgumentException( + "apicalAxisRotationDistribution must be a NormalDistribution under the rotation apical axis ruleset."); + } + Vector newRotatedApicalAxis = + Vector.rotateVectorAroundAxis( + ((PottsCellFlyStem) cell).getApicalAxis(), + Direction.XY_PLANE.vector, + apicalAxisRotationDistribution.nextDouble()); + return newRotatedApicalAxis; + default: + throw new IllegalArgumentException( + "Invalid apical axis ruleset: " + apicalAxisRuleset); + } + } + + /** + * Gets the voxel location the cell's plane of division will pass through. + * + * @param cell the {@link PottsCellFlyStem} to get the division location for + * @return the voxel location where the cell will split + */ + public static Voxel getCellSplitVoxel( + StemType stemType, PottsCellFlyStem cell, Vector rotatedNormalVector) { + ArrayList splitOffsetPercent = new ArrayList<>(); + splitOffsetPercent.add(stemType.splitOffsetPercentX); + splitOffsetPercent.add(stemType.splitOffsetPercentY); + return ((PottsLocation2D) cell.getLocation()) + .getOffsetInApicalFrame2D(splitOffsetPercent, rotatedNormalVector); + } + + /** + * Gets the smaller location with fewer voxels and returns it. + * + * @param loc1 the {@link PottsLocation} to compare to location2. + * @param loc2 {@link PottsLocation} to compare to location1. + * @return the smaller location. + */ + public static PottsLocation getSmallerLocation(PottsLocation loc1, PottsLocation loc2) { + return (loc1.getVolume() < loc2.getVolume()) ? loc1 : loc2; + } + + /** + * Gets the location that is lower along the apical axis. + * + * @param loc1 {@link PottsLocation} to compare. + * @param loc2 {@link PottsLocation} to compare. + * @param apicalAxis Unit {@link Vector} defining the apical-basal direction. + * @return the basal location (lower along the apical axis). + */ + public static PottsLocation getBasalLocation( + PottsLocation loc1, PottsLocation loc2, Vector apicalAxis) { + double[] centroid1 = loc1.getCentroid(); + double[] centroid2 = loc2.getCentroid(); + Vector c1 = new Vector(centroid1[0], centroid1[1], centroid1.length > 2 ? centroid1[2] : 0); + Vector c2 = new Vector(centroid2[0], centroid2[1], centroid2.length > 2 ? centroid2[2] : 0); + + double proj1 = Vector.dotProduct(c1, apicalAxis); + double proj2 = Vector.dotProduct(c2, apicalAxis); + + return (proj1 < proj2) ? loc2 : loc1; // higher projection = more basal + } +} diff --git a/src/arcade/potts/env/location/PottsLocation2D.java b/src/arcade/potts/env/location/PottsLocation2D.java index 0a8fcc986..5d3de7c8a 100644 --- a/src/arcade/potts/env/location/PottsLocation2D.java +++ b/src/arcade/potts/env/location/PottsLocation2D.java @@ -1,7 +1,11 @@ package arcade.potts.env.location; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; import java.util.HashMap; +import arcade.core.util.Vector; +import arcade.potts.util.PottsEnums.Direction; import static arcade.potts.util.PottsEnums.Direction; /** Concrete implementation of {@link PottsLocation} for 2D. */ @@ -64,4 +68,43 @@ Direction getSlice(Direction direction, HashMap diameters) { ArrayList getSelected(Voxel focus, double n) { return Location2D.getSelected(voxels, focus, n); } + + public Voxel getOffsetInApicalFrame2D(ArrayList offsets, Vector apicalAxis) { + if (voxels.isEmpty()) return null; + if (offsets == null || offsets.size() != 2) + throw new IllegalArgumentException("Offsets must be 2 integers."); + + // Normalize axes + Vector yAxis = Vector.normalizeVector(apicalAxis); + Vector xAxis = Vector.normalizeVector(new Vector(apicalAxis.getY(), -apicalAxis.getX(), 0)); + + // Project voxels onto apical axis and group by rounded projection + HashMap> apicalBands = new HashMap<>(); + ArrayList apicalKeys = new ArrayList<>(); + + for (Voxel v : voxels) { + Vector pos = new Vector(v.x, v.y, 0); + double apicalProj = Vector.dotProduct(pos, yAxis); + int roundedProj = (int) Math.round(apicalProj); + apicalBands.computeIfAbsent(roundedProj, k -> new ArrayList<>()).add(v); + apicalKeys.add(roundedProj); + } + + // Sort apical keys and choose percentile + Collections.sort(apicalKeys); + int yIndex = + Math.min( + apicalKeys.size() - 1, + (int) ((offsets.get(1) / 100.0) * apicalKeys.size())); + int targetApicalKey = apicalKeys.get(yIndex); + + ArrayList band = apicalBands.get(targetApicalKey); + if (band == null || band.isEmpty()) return null; + + // Project to orthogonal axis within the band and sort + band.sort( + Comparator.comparingDouble(v -> Vector.dotProduct(new Vector(v.x, v.y, 0), xAxis))); + int xIndex = Math.min(band.size() - 1, (int) ((offsets.get(0) / 100.0) * band.size())); + return band.get(xIndex); + } } diff --git a/src/arcade/potts/parameter.potts.xml b/src/arcade/potts/parameter.potts.xml index ebc46e0da..5a07b7d24 100644 --- a/src/arcade/potts/parameter.potts.xml +++ b/src/arcade/potts/parameter.potts.xml @@ -63,6 +63,16 @@ + + + + + + + + + + diff --git a/test/arcade/potts/agent/cell/PottsCellFlyStemTest.java b/test/arcade/potts/agent/cell/PottsCellFlyStemTest.java new file mode 100644 index 000000000..263a5aee0 --- /dev/null +++ b/test/arcade/potts/agent/cell/PottsCellFlyStemTest.java @@ -0,0 +1,196 @@ +package arcade.potts.agent.cell; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import ec.util.MersenneTwisterFast; +import arcade.core.util.GrabBag; +import arcade.core.util.MiniBox; +import arcade.core.util.Parameters; +import arcade.core.util.Vector; +import arcade.potts.env.location.PottsLocation; +import arcade.potts.util.PottsEnums.Phase; +import arcade.potts.util.PottsEnums.State; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static arcade.core.ARCADETestUtilities.*; + +public class PottsCellFlyStemTest { + static final double EPSILON = 1e-6; + + static MersenneTwisterFast random = new MersenneTwisterFast(); + + static PottsLocation locationMock; + + static Parameters parametersMock; + + static GrabBag links; + + static int cellID = randomIntBetween(1, 10); + + static int cellParent = randomIntBetween(1, 10); + + static int cellPop = randomIntBetween(1, 10); + + static int cellAge = randomIntBetween(1, 1000); + + static int cellDivisions = randomIntBetween(1, 100); + + static double cellCriticalVolume = randomDoubleBetween(10, 100); + + static double cellCriticalHeight = randomDoubleBetween(10, 100); + + static State cellState = State.UNDEFINED; + + static PottsCellContainer baseContainer; + + @BeforeEach + public final void setupMocks() { + locationMock = mock(PottsLocation.class); + parametersMock = spy(new Parameters(new MiniBox(), null, null)); + links = new GrabBag(); + links.add(1, 1); + + doReturn(0.0).when(parametersMock).getDouble(any()); + doReturn(0).when(parametersMock).getInt(any()); + + baseContainer = + new PottsCellContainer( + cellID, + cellParent, + cellPop, + cellAge, + cellDivisions, + cellState, + null, + 0, + cellCriticalVolume, + cellCriticalHeight); + } + + @Test + public void constructor_validWTStemType_createsInstance() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + assertNotNull(cell); + assertEquals(PottsCellFlyStem.StemType.WT, cell.stemType); + } + + @Test + public void constructor_validMUDMUTStemType_createsInstance() { + doReturn("fly-stem-mudmut").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + assertNotNull(cell); + assertEquals(PottsCellFlyStem.StemType.MUDMUT, cell.stemType); + } + + @Test + public void constructor_invalidStemType_throwsException() { + doReturn("invalid-class").when(parametersMock).getString("CLASS"); + assertThrows( + IllegalArgumentException.class, + () -> new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links)); + } + + @Test + public void make_calledWT_returnsCorrectNewContainer() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + PottsCellContainer container = + cell.make(cellID, State.PROLIFERATIVE, random, cellPop, cellCriticalVolume); + + assertAll( + () -> assertNotNull(container), + () -> assertEquals(cellID, container.parent), + () -> assertEquals(cellPop, container.pop), + () -> assertEquals(cellAge, container.age), + () -> assertEquals(cellDivisions + 1, container.divisions), + () -> assertEquals(State.PROLIFERATIVE, container.state), + () -> assertEquals(container.phase, Phase.UNDEFINED), + () -> assertEquals(0, container.voxels), + () -> assertNull(container.regionVoxels), + () -> assertEquals(cellCriticalVolume, container.criticalVolume, EPSILON), + () -> assertEquals(cellCriticalHeight, container.criticalHeight, EPSILON), + () -> assertNull(container.criticalRegionVolumes), + () -> assertNull(container.criticalRegionHeights)); + } + + @Test + public void make_calledMUDMUT_returnsCorrectNewContainer() { + doReturn("fly-stem-mudmut").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + PottsCellContainer container = + cell.make(cellID, State.PROLIFERATIVE, random, cellPop, cellCriticalVolume); + + assertAll( + () -> assertNotNull(container), + () -> assertEquals(cellID, container.parent), + () -> assertEquals(cellPop, container.pop), + () -> assertEquals(cellAge, container.age), + () -> assertEquals(cellDivisions + 1, container.divisions), + () -> assertEquals(State.PROLIFERATIVE, container.state), + () -> assertEquals(container.phase, Phase.UNDEFINED), + () -> assertEquals(0, container.voxels), + () -> assertNull(container.regionVoxels), + () -> assertEquals(cellCriticalVolume, container.criticalVolume), + () -> assertEquals(cellCriticalHeight, container.criticalHeight, EPSILON), + () -> assertNull(container.criticalRegionVolumes), + () -> assertNull(container.criticalRegionHeights)); + } + + @Test + void make_noDaughterCellCriticalVolume_throwsUnsupportedOperationException() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + assertThrows( + UnsupportedOperationException.class, + () -> cell.make(cellID, State.PROLIFERATIVE, random)); + } + + @Test + void setStateModule_called_createsProliferationModuleOrSetsNull() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + for (State state : State.values()) { + if (state != State.PROLIFERATIVE) { + cell.setStateModule(state); + assertNull(cell.getModule()); + } + } + } + + @Test + void getStemType_called_returnsCorrectStemType() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + assertEquals(PottsCellFlyStem.StemType.WT, cell.getStemType()); + doReturn("fly-stem-mudmut").when(parametersMock).getString("CLASS"); + cell = new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + assertEquals(PottsCellFlyStem.StemType.MUDMUT, cell.getStemType()); + } + + @Test + void getApicalAxis_notSet_returnsDefault() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + assertEquals(new Vector(0, 1, 0), cell.getApicalAxis()); + } + + @Test + void getApicalAxis_set_returnsStoredAxis() { + doReturn("fly-stem-wt").when(parametersMock).getString("CLASS"); + PottsCellFlyStem cell = + new PottsCellFlyStem(baseContainer, locationMock, parametersMock, links); + Vector custom = new Vector(1, 2, 3); + cell.setApicalAxis(custom); + assertEquals(custom, cell.getApicalAxis()); + } +} diff --git a/test/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiationTest.java b/test/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiationTest.java index 2cb4df20c..1af85d299 100644 --- a/test/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiationTest.java +++ b/test/arcade/potts/agent/module/PottsModuleFlyGMCDifferentiationTest.java @@ -20,8 +20,6 @@ import arcade.potts.env.location.PottsLocation2D; import arcade.potts.sim.Potts; import arcade.potts.sim.PottsSimulation; -import arcade.potts.util.PottsEnums.Region; -import arcade.potts.util.PottsEnums.State; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; diff --git a/test/arcade/potts/agent/module/PottsModuleProliferationFlyStemTest.java b/test/arcade/potts/agent/module/PottsModuleProliferationFlyStemTest.java new file mode 100644 index 000000000..b6923359f --- /dev/null +++ b/test/arcade/potts/agent/module/PottsModuleProliferationFlyStemTest.java @@ -0,0 +1,856 @@ +package arcade.potts.agent.module; + +import java.util.ArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import sim.util.Double3D; +import ec.util.MersenneTwisterFast; +import arcade.core.env.grid.Grid; +import arcade.core.util.GrabBag; +import arcade.core.util.MiniBox; +import arcade.core.util.Parameters; +import arcade.core.util.Plane; +import arcade.core.util.Vector; +import arcade.core.util.distributions.NormalDistribution; +import arcade.core.util.distributions.UniformDistribution; +import arcade.potts.agent.cell.PottsCellContainer; +import arcade.potts.agent.cell.PottsCellFactory; +import arcade.potts.agent.cell.PottsCellFlyStem; +import arcade.potts.env.location.PottsLocation; +import arcade.potts.env.location.PottsLocation2D; +import arcade.potts.env.location.Voxel; +import arcade.potts.sim.Potts; +import arcade.potts.sim.PottsSimulation; +import arcade.potts.util.PottsEnums.Phase; +import arcade.potts.util.PottsEnums.State; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.*; +import static arcade.potts.util.PottsEnums.State; + +public class PottsModuleProliferationFlyStemTest { + PottsCellFlyStem stemCell; + + PottsModuleProliferationFlyStem module; + + PottsLocation2D stemLoc; + + PottsLocation daughterLoc; + + Parameters parameters; + + PottsSimulation sim; + + Potts potts; + + Grid grid; + + PottsCellFactory factory; + + MersenneTwisterFast random; + + NormalDistribution dist; + + float EPSILON = 1e-6f; + + @BeforeEach + public final void setup() { + // Core mocks + stemCell = mock(PottsCellFlyStem.class); + parameters = mock(Parameters.class); + dist = mock(NormalDistribution.class); + sim = mock(PottsSimulation.class); + potts = mock(Potts.class); + grid = mock(Grid.class); + factory = mock(PottsCellFactory.class); + random = mock(MersenneTwisterFast.class); + + // Location mocks + stemLoc = mock(PottsLocation2D.class); + daughterLoc = mock(PottsLocation.class); + + // Wire simulation + when(((PottsSimulation) sim).getPotts()).thenReturn(potts); + potts.ids = new int[1][1][1]; + potts.regions = new int[1][1][1]; + when(sim.getGrid()).thenReturn(grid); + when(sim.getCellFactory()).thenReturn(factory); + when(sim.getSchedule()).thenReturn(mock(sim.engine.Schedule.class)); + when(sim.getID()).thenReturn(42); + + // Wire cell + when(stemCell.getLocation()).thenReturn(stemLoc); + when(stemCell.getParameters()).thenReturn(parameters); + when(stemLoc.split(eq(random), any(Plane.class))).thenReturn(daughterLoc); + + // Default centroid and volume values (sometimes overridden in tests) + when(stemLoc.getVolume()).thenReturn(10.0); + when(daughterLoc.getVolume()).thenReturn(5.0); + when(stemLoc.getCentroid()).thenReturn(new double[] {0, 1.0, 0}); + when(daughterLoc.getCentroid()).thenReturn(new double[] {0, 1.6, 0}); + + // Parameter stubs (sometimes overridden in tests) + when(parameters.getDistribution("proliferation/DIV_ROTATION_DISTRIBUTION")) + .thenReturn(dist); + when(dist.nextDouble()).thenReturn(0.1); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.5); + + // Link selection + GrabBag links = mock(GrabBag.class); + when(stemCell.getLinks()).thenReturn(links); + when(links.next(random)).thenReturn(2); + + // Other defaults + when(stemCell.getPop()).thenReturn(3); + when(stemCell.getCriticalVolume()).thenReturn(100.0); + } + + @AfterEach + final void tearDown() { + Mockito.framework().clearInlineMocks(); + } + + // Constructor tests + + @Test + public void constructor_volumeRuleset_setsExpectedFields() { + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.42); + module = new PottsModuleProliferationFlyStem(stemCell); + + assertNotNull(module.splitDirectionDistribution); + assertEquals("volume", module.differentiationRuleset); + assertEquals(0.42, module.range, EPSILON); + assertEquals(arcade.potts.util.PottsEnums.Phase.UNDEFINED, module.phase); + } + + @Test + public void constructor_locationRuleset_setsExpectedFields() { + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("location"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.99); + module = new PottsModuleProliferationFlyStem(stemCell); + + assertNotNull(module.splitDirectionDistribution); + assertEquals("location", module.differentiationRuleset); + assertEquals(0.99, module.range, EPSILON); + assertEquals(arcade.potts.util.PottsEnums.Phase.UNDEFINED, module.phase); + } + + // Static method tests + + @Test + public void getSmallerLocation_locationsDifferentSizes_returnsSmallerLocation() { + PottsLocation loc1 = mock(PottsLocation.class); + PottsLocation loc2 = mock(PottsLocation.class); + when(loc1.getVolume()).thenReturn(5.0); + when(loc2.getVolume()).thenReturn(10.0); + + PottsLocation result = PottsModuleProliferationFlyStem.getSmallerLocation(loc1, loc2); + assertEquals(loc1, result); + } + + @Test + public void getSmallerLocation_locationsSameSize_returnsSecondLocation() { + PottsLocation loc1 = mock(PottsLocation.class); + PottsLocation loc2 = mock(PottsLocation.class); + when(loc1.getVolume()).thenReturn(10.0); + when(loc2.getVolume()).thenReturn(10.0); + + PottsLocation result = PottsModuleProliferationFlyStem.getSmallerLocation(loc1, loc2); + assertEquals(loc2, result); + } + + @Test + public void getBasalLocation_centroidsDifferent_returnsBasalCentroid() { + PottsLocation loc1 = mock(PottsLocation.class); + PottsLocation loc2 = mock(PottsLocation.class); + when(loc1.getCentroid()).thenReturn(new double[] {0, 2, 0}); + when(loc2.getCentroid()).thenReturn(new double[] {0, 1, 0}); + Vector apicalAxis = new Vector(0, 1, 0); + + PottsLocation result = + PottsModuleProliferationFlyStem.getBasalLocation(loc1, loc2, apicalAxis); + assertEquals(loc1, result); + } + + @Test + public void getBasalLocation_centroidsSame_returnsFirstLocation() { + PottsLocation loc1 = mock(PottsLocation.class); + PottsLocation loc2 = mock(PottsLocation.class); + when(loc1.getCentroid()).thenReturn(new double[] {0, 2, 0}); + when(loc2.getCentroid()).thenReturn(new double[] {0, 2, 0}); + Vector apicalAxis = new Vector(0, 1, 0); + + PottsLocation result = + PottsModuleProliferationFlyStem.getBasalLocation(loc1, loc2, apicalAxis); + assertEquals(loc1, result); + } + + @Test + public void centroidsWithinRangeAlongApicalAxis_withinRange_returnsTrue() { + double[] centroid1 = new double[] {0, 1.0, 0}; + double[] centroid2 = new double[] {0, 1.3, 0}; + Vector apicalAxis = new Vector(0, 1, 0); // projecting along y-axis + double range = 0.5; + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = + PottsModuleProliferationFlyStem.centroidsWithinRangeAlongApicalAxis( + centroid1, centroid2, apicalAxis, range); + + assertTrue(result); + } + + @Test + public void centroidsWithinRangeAlongApicalAxis_equalToRange_returnsTrue() { + double[] centroid1 = new double[] {0, 1.0, 0}; + double[] centroid2 = new double[] {0, 1.5, 0}; + Vector apicalAxis = new Vector(0, 1, 0); + double range = 0.5; + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = + PottsModuleProliferationFlyStem.centroidsWithinRangeAlongApicalAxis( + centroid1, centroid2, apicalAxis, range); + + assertTrue(result); + } + + @Test + public void centroidsWithinRangeAlongApicalAxis_outsideRange_returnsFalse() { + double[] centroid1 = new double[] {0, 1.0, 0}; + double[] centroid2 = new double[] {0, 1.6, 0}; + Vector apicalAxis = new Vector(0, 1, 0); + double range = 0.5; + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = + PottsModuleProliferationFlyStem.centroidsWithinRangeAlongApicalAxis( + centroid1, centroid2, apicalAxis, range); + + assertFalse(result); + } + + @Test + public void centroidsWithinRangeAlongApicalAxis_nonYAxis_returnsCorrectly() { + double[] centroid1 = new double[] {1.0, 0.0, 0.0}; + double[] centroid2 = new double[] {1.6, 0.0, 0.0}; + Vector apicalAxis = new Vector(1, 0, 0); // projecting along x-axis + double range = 0.6; + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = + PottsModuleProliferationFlyStem.centroidsWithinRangeAlongApicalAxis( + centroid1, centroid2, apicalAxis, range); + + assertTrue(result); + } + + // Split location tests + + @Test + public void getCellSplitVoxel_WT_callsLocationOffsetWithCorrectParams() { + ArrayList expectedOffset = new ArrayList<>(); + expectedOffset.add(50); // WT.splitOffsetPercentX + expectedOffset.add(75); // WT.splitOffsetPercentY + + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(stemCell.getLocation()).thenReturn(stemLoc); + when(stemLoc.getOffsetInApicalFrame2D(eq(expectedOffset), any(Vector.class))) + .thenReturn(new Voxel(0, 0, 0)); + + PottsModuleProliferationFlyStem.getCellSplitVoxel( + PottsCellFlyStem.StemType.WT, stemCell, stemCell.getApicalAxis()); + verify(stemLoc).getOffsetInApicalFrame2D(eq(expectedOffset), any(Vector.class)); + } + + @Test + public void getCellSplitVoxel_MUDMUT_callsLocationOffsetWithCorrectParams() { + ArrayList expectedOffset = new ArrayList<>(); + expectedOffset.add(50); // MUDMUT.splitOffsetPercentX + expectedOffset.add(50); // MUDMUT.splitOffsetPercentY + + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(stemCell.getLocation()).thenReturn(stemLoc); + when(stemLoc.getOffsetInApicalFrame2D(eq(expectedOffset), any(Vector.class))) + .thenReturn(new Voxel(0, 0, 0)); + + PottsModuleProliferationFlyStem.getCellSplitVoxel( + PottsCellFlyStem.StemType.MUDMUT, stemCell, stemCell.getApicalAxis()); + verify(stemLoc).getOffsetInApicalFrame2D(eq(expectedOffset), any(Vector.class)); + } + + // Division plane tests + + @Test + public void getWTDivisionPlaneWithRotationalVariance_rotatesCorrectlyAndReturnsPlane() { + Vector apicalAxis = new Vector(0, 1, 0); + when(stemCell.getApicalAxis()).thenReturn(apicalAxis); + + double baseRotation = PottsCellFlyStem.StemType.WT.splitDirectionRotation; // 90 + double offsetRotation = -5.0; + + Voxel splitVoxel = new Voxel(3, 4, 5); + ArrayList expectedOffset = new ArrayList<>(); + expectedOffset.add(50); // WT x offset percent + expectedOffset.add(80); // WT y offset percent + + module = new PottsModuleProliferationFlyStem(stemCell); + + // Apply both rotations manually to get expected result + Vector afterBaseRotation = + Vector.rotateVectorAroundAxis(apicalAxis, new Vector(0, 0, 1), baseRotation); + Vector expectedNormal = + Vector.rotateVectorAroundAxis( + afterBaseRotation, new Vector(0, 0, 1), offsetRotation); + + when(stemLoc.getOffsetInApicalFrame2D(any(), eq(expectedNormal))).thenReturn(splitVoxel); + + Plane result = module.getWTDivisionPlaneWithRotationalVariance(stemCell, offsetRotation); + + Double3D refPoint = result.getReferencePoint(); + assertEquals(3.0, refPoint.x, EPSILON); + assertEquals(4.0, refPoint.y, EPSILON); + assertEquals(5.0, refPoint.z, EPSILON); + + Vector resultNormal = result.getUnitNormalVector(); + assertEquals(expectedNormal.getX(), resultNormal.getX(), EPSILON); + assertEquals(expectedNormal.getY(), resultNormal.getY(), EPSILON); + assertEquals(expectedNormal.getZ(), resultNormal.getZ(), EPSILON); + } + + @Test + public void getMUDDivisionPlane_returnsRotatedPlaneWithCorrectNormal() { + Vector apicalAxis = new Vector(0, 1, 0); + when(stemCell.getApicalAxis()).thenReturn(apicalAxis); + + Vector expectedNormal = new Vector(1.0, 0.0, 0.0); + + Voxel splitVoxel = new Voxel(7, 8, 9); + ArrayList expectedOffset = new ArrayList<>(); + expectedOffset.add(50); // MUDMUT x offset percent + expectedOffset.add(50); // MUDMUT y offset percent + when(stemLoc.getOffsetInApicalFrame2D(any(), any())).thenReturn(splitVoxel); + + module = new PottsModuleProliferationFlyStem(stemCell); + Plane result = module.getMUDDivisionPlane(stemCell); + + assertEquals(new Double3D(7, 8, 9), result.getReferencePoint()); + Vector resultNormal = result.getUnitNormalVector(); + assertEquals(expectedNormal.getX(), resultNormal.getX(), EPSILON); + assertEquals(expectedNormal.getY(), resultNormal.getY(), EPSILON); + assertEquals(expectedNormal.getZ(), resultNormal.getZ(), EPSILON); + } + + @Test + public void sampleDivisionPlaneOffset_callsNextDoubleOnDistribution() { + when(dist.nextDouble()).thenReturn(12.34); + + module = new PottsModuleProliferationFlyStem(stemCell); + double offset = module.sampleDivisionPlaneOffset(); + + assertEquals(12.34, offset, EPSILON); + } + + @Test + public void chooseDivisionPlane_WT_callsWTVariant() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + when(dist.nextDouble()).thenReturn(12.0); // this can be any value + + module = spy(new PottsModuleProliferationFlyStem(stemCell)); + + Plane expectedPlane = mock(Plane.class); + doReturn(expectedPlane) + .when(module) + .getWTDivisionPlaneWithRotationalVariance(stemCell, 12.0); + + Plane result = module.chooseDivisionPlane(stemCell); + + assertEquals(expectedPlane, result); + verify(module).getWTDivisionPlaneWithRotationalVariance(stemCell, 12.0); + verify(module, never()).getMUDDivisionPlane(any()); + } + + @Test + public void chooseDivisionPlane_MUDMUT_withLowOffset_callsWTVariant() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(dist.nextDouble()).thenReturn(10.0); // abs(offset) < 45 → WT logic + + module = spy(new PottsModuleProliferationFlyStem(stemCell)); + + Plane expectedPlane = mock(Plane.class); + doReturn(expectedPlane) + .when(module) + .getWTDivisionPlaneWithRotationalVariance(stemCell, 10.0); + + Plane result = module.chooseDivisionPlane(stemCell); + + assertEquals(expectedPlane, result); + verify(module).getWTDivisionPlaneWithRotationalVariance(stemCell, 10.0); + verify(module, never()).getMUDDivisionPlane(any()); + } + + @Test + public void chooseDivisionPlane_MUDMUT_withHighOffset_callsMUDVariant() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(dist.nextDouble()).thenReturn(60.0); // abs(offset) ≥ 45 → MUD logic + + module = spy(new PottsModuleProliferationFlyStem(stemCell)); + + Plane expectedPlane = mock(Plane.class); + doReturn(expectedPlane).when(module).getMUDDivisionPlane(stemCell); + + Plane result = module.chooseDivisionPlane(stemCell); + + assertEquals(expectedPlane, result); + verify(module).getMUDDivisionPlane(stemCell); + verify(module, never()).getWTDivisionPlaneWithRotationalVariance(any(), anyDouble()); + } + + // Step tests + @Test + public void step_volumeBelowCheckpoint_updatesTargetdoesNotDividePhaseStaysUndefined() { + when(parameters.getInt("proliferation/DYNAMIC_GROWTH_RATE_VOLUME")).thenReturn(0); + when(parameters.getDouble("proliferation/CELL_GROWTH_RATE")).thenReturn(4.0); + when(parameters.getDouble("proliferation/SIZE_TARGET")).thenReturn(1.2); + when(stemCell.getCriticalVolume()).thenReturn(100.0); + when(stemLoc.getVolume()).thenReturn(50.0); // 50 < 1.2 * 100 → below checkpoint + + module = new PottsModuleProliferationFlyStem(stemCell); + + module.step(random, sim); + + verify(stemCell).updateTarget(eq(4.0), anyDouble()); + // Checking functions within addCell are never called + // (checking addCell directly would require making module a mock) + verify(sim, never()).getPotts(); + verify(grid, never()).addObject(any(), any()); + verify(potts, never()).register(any()); + assertEquals(Phase.UNDEFINED, module.phase); + } + + @Test + public void step_volumeAtCheckpoint_callsAddCellPhaseStaysUndefined() { + // Trigger division + when(parameters.getInt("proliferation/DYNAMIC_GROWTH_RATE_VOLUME")).thenReturn(0); + when(parameters.getDouble("proliferation/CELL_GROWTH_RATE")).thenReturn(4.0); + when(parameters.getDouble("proliferation/SIZE_TARGET")).thenReturn(1.2); + when(stemCell.getCriticalVolume()).thenReturn(100.0); + when(stemCell.getVolume()).thenReturn(120.0); // ≥ 1.2 * 100 + + // Needed by calculateGMCDaughterCellCriticalVolume(...) + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + + // Plane/voxel path (chooseDivisionPlane -> WT -> getWTDivisionPlaneWithRotationalVariance) + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("global"); + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(stemLoc.getOffsetInApicalFrame2D(any(), any(Vector.class))) + .thenReturn(new Voxel(1, 2, 3)); + + // Differentiation rule + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.5); + + // Cell creation path used by scheduleNewCell(...) + PottsCellContainer container = mock(PottsCellContainer.class); + PottsCellFlyStem newCell = mock(PottsCellFlyStem.class); + when(stemCell.make(anyInt(), eq(State.PROLIFERATIVE), eq(random), anyInt(), anyDouble())) + .thenReturn(container); + when(container.convert(eq(factory), eq(daughterLoc), eq(random))).thenReturn(newCell); + + // split(...) inside addCell + when(stemLoc.split(eq(random), any(Plane.class))).thenReturn(daughterLoc); + + module = new PottsModuleProliferationFlyStem(stemCell); + module.step(random, sim); + + verify(stemCell).updateTarget(eq(4.0), anyDouble()); + verify(stemLoc).split(eq(random), any(Plane.class)); // addCell ran + verify(grid).addObject(any(), isNull()); // scheduled new cell + verify(potts).register(any()); // registered new cell + assertEquals(Phase.UNDEFINED, module.phase); // remains UNDEFINED + } + + // Differentiation rule tests + + @Test + public void daughterStem_stemTypeWT_returnsFalse() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = module.daughterStem(stemLoc, daughterLoc); + + assertFalse(result); + } + + @Test + public void daughterStem_volumeRule_differenceWithinRange_returnsTrue() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(1.0); + when(stemLoc.getVolume()).thenReturn(10.0); + when(daughterLoc.getVolume()).thenReturn(10.5); // difference = 0.5 < 1.0 + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = module.daughterStem(stemLoc, daughterLoc); + + assertTrue(result); + } + + @Test + public void daughterStem_volumeRule_differenceOutsideRange_returnsFalse() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.5); + when(stemLoc.getVolume()).thenReturn(10.0); + when(daughterLoc.getVolume()).thenReturn(11.0); // difference = 1.0 > 0.5 + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = module.daughterStem(stemLoc, daughterLoc); + + assertFalse(result); + } + + @Test + public void daughterStem_locationRule_differenceWithinRange_returnsTrue() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("location"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.5); + when(stemLoc.getCentroid()).thenReturn(new double[] {0, 1.0, 0}); + when(daughterLoc.getCentroid()).thenReturn(new double[] {0, 1.3, 0}); // difference = 0.3 + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = module.daughterStem(stemLoc, daughterLoc); + + assertTrue(result); + } + + @Test + public void daughterStem_locationRule_differenceOutsideRange_returnsFalse() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("location"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.5); + when(stemLoc.getCentroid()).thenReturn(new double[] {0, 1.0, 0}); + when(daughterLoc.getCentroid()).thenReturn(new double[] {0, 1.7, 0}); // difference = 0.7 + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + + module = new PottsModuleProliferationFlyStem(stemCell); + boolean result = module.daughterStem(stemLoc, daughterLoc); + + assertFalse(result); + } + + @Test + public void daughterStem_invalidRule_throwsException() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("banana"); + when(parameters.getDouble("proliferation/DIFFERENTIATION_RULESET_EQUALITY_RANGE")) + .thenReturn(0.5); + when(stemLoc.getCentroid()).thenReturn(new double[] {0, 1.0, 0}); + when(daughterLoc.getCentroid()).thenReturn(new double[] {0, 1.2, 0}); + + module = new PottsModuleProliferationFlyStem(stemCell); + assertThrows( + IllegalArgumentException.class, () -> module.daughterStem(stemLoc, daughterLoc)); + } + + // Apical axis rule tests + + @Test + public void getDaughterCellApicalAxis_global_returnsApicalAxis() { + Vector expectedAxis = new Vector(1.0, 2.0, 3.0); + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("global"); + when(stemCell.getApicalAxis()).thenReturn(expectedAxis); + + module = new PottsModuleProliferationFlyStem(stemCell); + Vector result = module.getDaughterCellApicalAxis(random); + + assertEquals(expectedAxis.getX(), result.getX(), EPSILON); + assertEquals(expectedAxis.getY(), result.getY(), EPSILON); + assertEquals(expectedAxis.getZ(), result.getZ(), EPSILON); + } + + @Test + public void getDaughterCellApicalAxis_rotation_returnsRotatedAxis() { + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("rotation"); + + NormalDistribution rotDist = mock(NormalDistribution.class); + when(rotDist.nextDouble()).thenReturn(30.0); // rotation angle + when(parameters.getDistribution("proliferation/APICAL_AXIS_ROTATION_DISTRIBUTION")) + .thenReturn(rotDist); + + Vector originalAxis = new Vector(0, 1, 0); + when(stemCell.getApicalAxis()).thenReturn(originalAxis); + + module = new PottsModuleProliferationFlyStem(stemCell); + Vector result = module.getDaughterCellApicalAxis(random); + + Vector expected = Vector.rotateVectorAroundAxis(originalAxis, new Vector(0, 0, 1), 30.0); + assertEquals(expected.getX(), result.getX(), EPSILON); + assertEquals(expected.getY(), result.getY(), EPSILON); + assertEquals(expected.getZ(), result.getZ(), EPSILON); + } + + @Test + public void getDaughterCellApicalAxis_rotationwithInvalidDistribution_throwsException() { + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("rotation"); + when(parameters.getDistribution("proliferation/APICAL_AXIS_ROTATION_DISTRIBUTION")) + .thenReturn(mock(UniformDistribution.class)); + + module = new PottsModuleProliferationFlyStem(stemCell); + assertThrows( + IllegalArgumentException.class, () -> module.getDaughterCellApicalAxis(random)); + } + + @Test + public void getDaughterCellApicalAxis_uniform_returnsRotatedAxis() { + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("uniform"); + + UniformDistribution rotDist = mock(UniformDistribution.class); + when(rotDist.nextDouble()).thenReturn(200.0); // rotation angle + when(parameters.getDistribution("proliferation/APICAL_AXIS_ROTATION_DISTRIBUTION")) + .thenReturn(rotDist); + + Vector originalAxis = new Vector(0, 1, 0); + when(stemCell.getApicalAxis()).thenReturn(originalAxis); + + module = new PottsModuleProliferationFlyStem(stemCell); + Vector result = module.getDaughterCellApicalAxis(random); + + Vector expected = Vector.rotateVectorAroundAxis(originalAxis, new Vector(0, 0, 1), 200.0); + assertEquals(expected.getX(), result.getX(), EPSILON); + assertEquals(expected.getY(), result.getY(), EPSILON); + assertEquals(expected.getZ(), result.getZ(), EPSILON); + } + + @Test + public void getDaughterCellApicalAxis_uniformwithInvalidDistribution_throwsException() { + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("uniform"); + when(parameters.getDistribution("proliferation/APICAL_AXIS_ROTATION_DISTRIBUTION")) + .thenReturn(mock(NormalDistribution.class)); + + module = new PottsModuleProliferationFlyStem(stemCell); + assertThrows( + IllegalArgumentException.class, () -> module.getDaughterCellApicalAxis(random)); + } + + // Critical volume calculation tests + + @Test + public void calculateGMCDaughterCellCriticalVolume_volumeBasedOff_returnsMaxCritVol() { + when(stemCell.getCriticalVolume()).thenReturn(100.0); + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + when(parameters.getDouble("proliferation/SIZE_TARGET")).thenReturn(1.2); + // WT has proportion = 0.2 + + module = new PottsModuleProliferationFlyStem(stemCell); + when(parameters.getInt("proliferation/VOLUME_BASED_CRITVOL")).thenReturn(0); + + double result = module.calculateGMCDaughterCellCriticalVolume(daughterLoc, sim, 3); + assertEquals((100 * .25 * 1.2), result, EPSILON); // 100 * 0.25 * 1.2 + } + + @Test + public void calculateGMCDaughterCellCriticalVolume_volumeBasedOn_returnsScaledValue() { + PottsLocation gmcLoc = mock(PottsLocation.class); + when(gmcLoc.getVolume()).thenReturn(50.0); + when(stemCell.getCriticalVolume()).thenReturn(100.0); + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + + MiniBox popParametersMiniBox = mock(MiniBox.class); + when(popParametersMiniBox.getDouble("proliferation/SIZE_TARGET")).thenReturn(2.0); + + when(sim.getCellFactory()).thenReturn(factory); + when(factory.getParameters(3)).thenReturn(popParametersMiniBox); + + when(parameters.getInt("proliferation/VOLUME_BASED_CRITICAL_VOLUME")).thenReturn(1); + when(parameters.getDouble("proliferation/VOLUME_BASED_CRITICAL_VOLUME_MULTIPLIER")) + .thenReturn(1.5); + + module = new PottsModuleProliferationFlyStem(stemCell); + + double result = module.calculateGMCDaughterCellCriticalVolume(gmcLoc, sim, 3); + assertEquals(75.0, result, EPSILON); // 50 * 1.5 + } + + // addCell integration tests + + @Test + public void addCell_WTVolumeSwap_swapsVoxelsAndCreatesNewCell() { + // Arrange: WT stem cell, using volume-based differentiation + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("global"); + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(parameters.getDouble("proliferation/SIZE_TARGET")) + .thenReturn(1.0); // default for volume + when(parameters.getInt("proliferation/VOLUME_BASED_CRITICAL_VOLUME")) + .thenReturn(0); // use classic mode + + // Set up the condition that parent volume < daughter volume → stem/daughter swap required + when(stemLoc.getVolume()).thenReturn(5.0); + when(daughterLoc.getVolume()).thenReturn(10.0); + + // Stub division plane + Plane dummyPlane = mock(Plane.class); + when(dummyPlane.getUnitNormalVector()).thenReturn(new Vector(1, 0, 0)); + when(stemLoc.split(eq(random), eq(dummyPlane))).thenReturn(daughterLoc); + + // Stub cell creation + PottsCellContainer container = mock(PottsCellContainer.class); + PottsCellFlyStem newStemCell = mock(PottsCellFlyStem.class); + when(stemCell.make(eq(42), eq(State.PROLIFERATIVE), eq(random), eq(2), eq(25.0))) + .thenReturn(container); + when(container.convert(eq(factory), eq(daughterLoc), eq(random))).thenReturn(newStemCell); + + // Spy on the module so we can override plane selection + PottsModuleProliferationFlyStem module = spy(new PottsModuleProliferationFlyStem(stemCell)); + doReturn(dummyPlane) + .when(module) + .getWTDivisionPlaneWithRotationalVariance(eq(stemCell), anyDouble()); + + // Act: call addCell + try (MockedStatic mocked = mockStatic(PottsLocation.class)) { + module.addCell(random, sim); + + // Assert: verify voxels were swapped and new cell scheduled + mocked.verify(() -> PottsLocation.swapVoxels(stemLoc, daughterLoc)); + } + + // Assert: new stem cell was scheduled + verify(newStemCell).schedule(any()); + } + + @Test + public void addCell_WTVolumeNoSwap_doesNotSwapVoxelsAndCreatesNewCell() { + // Arrange: WT stem cell, using volume-based differentiation + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.WT); + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("global"); + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(parameters.getDouble("proliferation/SIZE_TARGET")).thenReturn(1.0); + when(parameters.getInt("proliferation/VOLUME_BASED_CRITICAL_VOLUME")).thenReturn(0); + + // Set up the condition that parent volume > daughter volume → no swap + when(stemLoc.getVolume()).thenReturn(10.0); + when(daughterLoc.getVolume()).thenReturn(5.0); + + // Stub division plane + Plane dummyPlane = mock(Plane.class); + when(dummyPlane.getUnitNormalVector()).thenReturn(new Vector(1, 0, 0)); + when(stemLoc.split(eq(random), eq(dummyPlane))).thenReturn(daughterLoc); + + // Stub cell creation + PottsCellContainer container = mock(PottsCellContainer.class); + PottsCellFlyStem newStemCell = mock(PottsCellFlyStem.class); + when(stemCell.make(eq(42), eq(State.PROLIFERATIVE), eq(random), eq(2), eq(25.0))) + .thenReturn(container); + when(container.convert(eq(factory), eq(daughterLoc), eq(random))).thenReturn(newStemCell); + + // Spy and override division plane logic + PottsModuleProliferationFlyStem module = spy(new PottsModuleProliferationFlyStem(stemCell)); + doReturn(dummyPlane) + .when(module) + .getWTDivisionPlaneWithRotationalVariance(eq(stemCell), anyDouble()); + + // Act + try (MockedStatic mocked = mockStatic(PottsLocation.class)) { + module.addCell(random, sim); + + // Assert: swapVoxels should NOT be called + mocked.verify(() -> PottsLocation.swapVoxels(any(), any()), never()); + } + + // Assert: new stem cell was created and scheduled + verify(newStemCell).schedule(any()); + } + + @Test + public void addCell_MUDMUTOffsetAboveThreshold_createsStemCell() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("global"); + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(dist.nextDouble()).thenReturn(60.0); // triggers MUD plane + + sim = mock(PottsSimulation.class); + potts = mock(Potts.class); + factory = mock(PottsCellFactory.class); + grid = mock(Grid.class); + when(sim.getPotts()).thenReturn(potts); + when(sim.getGrid()).thenReturn(grid); + when(sim.getCellFactory()).thenReturn(factory); + when(sim.getSchedule()).thenReturn(mock(sim.engine.Schedule.class)); + when(sim.getID()).thenReturn(42); + potts.ids = new int[1][1][1]; + potts.regions = new int[1][1][1]; + + PottsCellContainer container = mock(PottsCellContainer.class); + PottsCellFlyStem newCell = mock(PottsCellFlyStem.class); + when(stemCell.make(eq(42), eq(State.PROLIFERATIVE), eq(random), eq(3), eq(100.0))) + .thenReturn(container); + when(container.convert(eq(factory), eq(daughterLoc), eq(random))).thenReturn(newCell); + when(stemCell.getCriticalVolume()).thenReturn(100.0); + when(stemCell.getPop()).thenReturn(3); + + module = spy(new PottsModuleProliferationFlyStem(stemCell)); + Plane dummyPlane = mock(Plane.class); + doReturn(dummyPlane).when(module).getMUDDivisionPlane(eq(stemCell)); + when(stemLoc.split(eq(random), eq(dummyPlane))).thenReturn(daughterLoc); + doReturn(true).when(module).daughterStem(any(), any()); + + module.addCell(random, sim); + + verify(newCell).schedule(any()); + } + + @Test + public void addCell_MUDMUTOffsetBelowThreshold_createsGMCWithVolumeSwap() { + when(stemCell.getStemType()).thenReturn(PottsCellFlyStem.StemType.MUDMUT); + when(parameters.getString("proliferation/DIFFERENTIATION_RULESET")).thenReturn("volume"); + when(parameters.getString("proliferation/APICAL_AXIS_RULESET")).thenReturn("global"); + when(stemCell.getApicalAxis()).thenReturn(new Vector(0, 1, 0)); + when(dist.nextDouble()).thenReturn(10.0); // below 45 threshold + + when(stemLoc.getVolume()).thenReturn(5.0); + when(daughterLoc.getVolume()).thenReturn(10.0); // triggers swap + + PottsCellContainer container = mock(PottsCellContainer.class); + PottsCellFlyStem newCell = mock(PottsCellFlyStem.class); + when(stemCell.make(eq(42), eq(State.PROLIFERATIVE), eq(random), anyInt(), anyDouble())) + .thenReturn(container); + when(container.convert(eq(factory), eq(daughterLoc), eq(random))).thenReturn(newCell); + when(stemCell.getCriticalVolume()).thenReturn(100.0); + when(stemCell.getPop()).thenReturn(3); + + module = spy(new PottsModuleProliferationFlyStem(stemCell)); + Plane dummyPlane = mock(Plane.class); + doReturn(dummyPlane) + .when(module) + .getWTDivisionPlaneWithRotationalVariance(eq(stemCell), anyDouble()); + when(stemLoc.split(eq(random), eq(dummyPlane))).thenReturn(daughterLoc); + doReturn(false).when(module).daughterStem(any(), any()); + + try (MockedStatic mocked = mockStatic(PottsLocation.class)) { + module.addCell(random, sim); + mocked.verify(() -> PottsLocation.swapVoxels(stemLoc, daughterLoc)); + } + + verify(newCell).schedule(any()); + } +} diff --git a/test/arcade/potts/env/location/Location2DTest.java b/test/arcade/potts/env/location/Location2DTest.java index cf1763146..d3cbf366f 100644 --- a/test/arcade/potts/env/location/Location2DTest.java +++ b/test/arcade/potts/env/location/Location2DTest.java @@ -4,6 +4,9 @@ import java.util.HashMap; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; +import arcade.core.util.Vector; +import arcade.potts.util.PottsEnums.Direction; +import arcade.potts.util.PottsEnums.Region; import static org.junit.jupiter.api.Assertions.*; import static arcade.core.ARCADETestUtilities.*; import static arcade.potts.env.location.Voxel.VOXEL_COMPARATOR; @@ -461,4 +464,92 @@ public void getSelected_minSizeLocations_returnsList() { assertEquals(selected.size(), 0); } + + @Test + public void getVolumeInformedOffsetInApicalFrame2D_returnsExpectedVoxel_atCenter() { + ArrayList voxels = new ArrayList<>(); + // 3x3 grid centered at (0,0) + for (int x = -1; x <= 1; x++) { + for (int y = -1; y <= 1; y++) { + voxels.add(new Voxel(x, y, 0)); + } + } + PottsLocation2D loc = new PottsLocation2D(voxels); + + Vector apicalAxis = new Vector(0, 1, 0); // Y-axis + ArrayList offsets = new ArrayList<>(); + offsets.add(50); // middle of X axis + offsets.add(50); // middle of Y axis + + Voxel result = loc.getOffsetInApicalFrame2D(offsets, apicalAxis); + assertEquals(new Voxel(0, 0, 0), result); + } + + @Test + public void getVolumeInformedOffsetInApicalFrame2D_returnsExpectedVoxel_upperRight() { + ArrayList voxels = new ArrayList<>(); + for (int x = 0; x <= 4; x++) { + for (int y = 0; y <= 4; y++) { + voxels.add(new Voxel(x, y, 0)); + } + } + PottsLocation2D loc = new PottsLocation2D(voxels); + + Vector apicalAxis = new Vector(0, 1, 0); // Y-axis + ArrayList offsets = new ArrayList<>(); + offsets.add(100); // far right of X axis + offsets.add(100); // top of Y axis + + Voxel result = loc.getOffsetInApicalFrame2D(offsets, apicalAxis); + assertEquals(new Voxel(4, 4, 0), result); + } + + @Test + public void getVolumeInformedOffsetInApicalFrame2D_emptyVoxels_returnsNull() { + PottsLocation2D loc = new PottsLocation2D(new ArrayList<>()); + + Vector apicalAxis = new Vector(1, 0, 0); + ArrayList offsets = new ArrayList<>(); + offsets.add(50); + offsets.add(50); + + Voxel result = loc.getOffsetInApicalFrame2D(offsets, apicalAxis); + assertNull(result); + } + + @Test + public void getVolumeInformedOffsetInApicalFrame2D_invalidOffset_throwsException() { + ArrayList voxels = new ArrayList<>(); + voxels.add(new Voxel(0, 0, 0)); + PottsLocation2D loc = new PottsLocation2D(voxels); + + Vector apicalAxis = new Vector(1, 0, 0); + + ArrayList badOffset = new ArrayList<>(); + badOffset.add(50); // only one element + + assertThrows( + IllegalArgumentException.class, + () -> { + loc.getOffsetInApicalFrame2D(badOffset, apicalAxis); + }); + } + + @Test + public void getVolumeInformedOffsetInApicalFrame2D_nonOrthogonalAxis_returnsExpected() { + ArrayList voxels = new ArrayList<>(); + voxels.add(new Voxel(0, 0, 0)); + voxels.add(new Voxel(1, 1, 0)); + voxels.add(new Voxel(2, 2, 0)); + voxels.add(new Voxel(3, 3, 0)); + PottsLocation2D loc = new PottsLocation2D(voxels); + + Vector apicalAxis = new Vector(1, 1, 0); // diagonal + ArrayList offsets = new ArrayList<>(); + offsets.add(0); // lowest orthogonal axis + offsets.add(100); // farthest along apical + + Voxel result = loc.getOffsetInApicalFrame2D(offsets, apicalAxis); + assertEquals(new Voxel(3, 3, 0), result); + } } diff --git a/test/arcade/potts/env/location/PottsLocationTest.java b/test/arcade/potts/env/location/PottsLocationTest.java index ef48c8f5b..ef20355f2 100644 --- a/test/arcade/potts/env/location/PottsLocationTest.java +++ b/test/arcade/potts/env/location/PottsLocationTest.java @@ -9,8 +9,6 @@ import ec.util.MersenneTwisterFast; import arcade.core.util.Plane; import arcade.core.util.Vector; -import arcade.potts.util.PottsEnums.Direction; -import arcade.potts.util.PottsEnums.Region; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import static arcade.core.ARCADETestUtilities.*;