Rejection details on surface course diagnostics

Valley solver rejections carry the failing measure (required cut, exposed stations, missing head) through the planner diagnostics; the transect probe prints them.
This commit is contained in:
Brian Neumann-Fopiano
2026-09-02 00:05:10 -04:00
parent 8848167c22
commit 13bce7aa42
9 changed files with 77 additions and 39 deletions
@@ -926,7 +926,7 @@ final class HydrologyCaveCourseFilter {
kind,
type,
representative.start(),
rejection
rejection, 0
));
}
@@ -1035,7 +1035,7 @@ final class HydrologyCaveCourseFilter {
kind,
type,
representative.start(),
HydrologyCandidateRejection.CAVE_CONTAINMENT
HydrologyCandidateRejection.CAVE_CONTAINMENT, 0
);
}
@@ -7,7 +7,8 @@ public record HydrologyDiagnosticCandidate(
HydrologyCandidateKind kind,
HydrologyFeatureType projectedType,
HydrologyPoint point,
HydrologyCandidateRejection rejection
HydrologyCandidateRejection rejection,
int detail
) {
public HydrologyDiagnosticCandidate {
Objects.requireNonNull(kind, "kind");
@@ -1925,7 +1925,7 @@ public final class HydrologyPlanner {
HydrologyCandidateKind.OUTLET,
candidate.outlet().type(),
candidate.outlet().landwardPoint(),
HydrologyCandidateRejection.OUTLET_LIMIT
HydrologyCandidateRejection.OUTLET_LIMIT, 0
));
}
}
@@ -2007,7 +2007,10 @@ public final class HydrologyPlanner {
);
}
/** Largest rise a surface drainage edge may take: the cut the valley solver will still accept. */
/**
* Largest rise a surface drainage edge may take. Heads only fall, so a rise along a route becomes a cut
* of at least that rise; the lattice may climb no more than the cut the valley solver still accepts.
*/
private int maximumSurfaceEdgeRise() {
int permitted = settings.surface().maximumIncision() - settings.surface().banks().inset() - settings.surface().minimumDepth();
return Math.max(1, permitted);
@@ -2522,7 +2525,7 @@ public final class HydrologyPlanner {
HydrologyCandidateKind.SOURCE,
surface ? HydrologyFeatureType.SURFACE_POOL : HydrologyFeatureType.UNDERGROUND_POOL,
new HydrologyPoint(node.x(), y, node.z()),
rejection
rejection, 0
));
}
@@ -4134,7 +4137,7 @@ public final class HydrologyPlanner {
.reversed()
.thenComparingLong(SurfaceCourseDraft::courseId));
RiverCourse mainCourse = null;
HashMap<Long, HydrologyCandidateRejection> mainRejections = new HashMap<>();
HashMap<Long, SurfaceCourseBuild> mainRejections = new HashMap<>();
for (SurfaceCourseDraft draft : outletDrafts) {
SurfaceCourseBuild candidate = buildSurfaceCourse(
draft.courseId(),
@@ -4143,7 +4146,7 @@ public final class HydrologyPlanner {
draft.path()
);
if (candidate.course() == null) {
mainRejections.put(draft.courseId(), candidate.rejection());
mainRejections.put(draft.courseId(), candidate);
continue;
}
mainCourse = candidate.course();
@@ -4152,11 +4155,13 @@ public final class HydrologyPlanner {
}
if (mainCourse == null) {
for (SurfaceCourseDraft draft : outletDrafts) {
SurfaceCourseBuild rejected = mainRejections.get(draft.courseId());
addCompiledSourceDiagnostic(
draft.source(),
true,
draft.courseId(),
mainRejections.get(draft.courseId()),
rejected.rejection(),
rejected.rejectionDetail(),
diagnostics
);
}
@@ -4180,7 +4185,7 @@ public final class HydrologyPlanner {
CoursePath path
) {
if (path.points().size() < 2) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT);
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, 0);
}
RiverOutlet outlet = path.outlet();
boolean surfaceSinkhole = path.reachesOutlet() && surfaceSinkhole(outlet);
@@ -4203,7 +4208,7 @@ public final class HydrologyPlanner {
terminalHead
);
if (!result.accepted()) {
return SurfaceCourseBuild.rejected(result.rejection());
return SurfaceCourseBuild.rejected(result.rejection(), result.rejectionDetail());
}
ArrayList<HydraulicSegment> segments = new ArrayList<>(result.segments());
if (path.reachesOutlet()) {
@@ -4226,7 +4231,7 @@ public final class HydrologyPlanner {
);
}
if (path.organicSurfaceRequired() && !surfaceShapeAccepted(path.points())) {
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED);
return SurfaceCourseBuild.rejected(HydrologyCandidateRejection.SURFACE_SHAPE_UNSUPPORTED, 0);
}
int discharge = maximumSurfaceDischarge(path.edges());
return SurfaceCourseBuild.accepted(new RiverCourse(
@@ -4458,6 +4463,17 @@ public final class HydrologyPlanner {
long courseId,
HydrologyCandidateRejection rejection,
List<HydrologyDiagnosticCandidate> diagnostics
) {
addCompiledSourceDiagnostic(source, surface, courseId, rejection, 0, diagnostics);
}
private void addCompiledSourceDiagnostic(
GridNode source,
boolean surface,
long courseId,
HydrologyCandidateRejection rejection,
int detail,
List<HydrologyDiagnosticCandidate> diagnostics
) {
int y = surface ? source.terrain().naturalHeight() : source.terrain().caveFluidY();
diagnostics.add(new HydrologyDiagnosticCandidate(
@@ -4465,7 +4481,8 @@ public final class HydrologyPlanner {
HydrologyCandidateKind.SOURCE,
surface ? HydrologyFeatureType.SURFACE_POOL : HydrologyFeatureType.UNDERGROUND_POOL,
new HydrologyPoint(source.x(), y, source.z()),
rejection
rejection,
detail
));
}
@@ -6244,7 +6261,7 @@ public final class HydrologyPlanner {
HydrologyCandidateKind.DEEP_FLUID,
deepFluid.containedPools() ? HydrologyFeatureType.DEEP_POOL : HydrologyFeatureType.DEEP_CHANNEL,
new HydrologyPoint(site.x(), site.head(), site.z()),
rejection
rejection, 0
));
}
@@ -7259,7 +7276,7 @@ public final class HydrologyPlanner {
HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL,
representative.start(),
HydrologyCandidateRejection.SOURCE_SPACING
HydrologyCandidateRejection.SOURCE_SPACING, 0
);
}
@@ -7385,7 +7402,8 @@ public final class HydrologyPlanner {
private record SurfaceCourseBuild(
RiverCourse course,
HydrologyCandidateRejection rejection
HydrologyCandidateRejection rejection,
int rejectionDetail
) {
private SurfaceCourseBuild {
if ((course == null) == (rejection == null)) {
@@ -7394,11 +7412,11 @@ public final class HydrologyPlanner {
}
private static SurfaceCourseBuild accepted(RiverCourse course) {
return new SurfaceCourseBuild(Objects.requireNonNull(course, "course"), null);
return new SurfaceCourseBuild(Objects.requireNonNull(course, "course"), null, 0);
}
private static SurfaceCourseBuild rejected(HydrologyCandidateRejection rejection) {
return new SurfaceCourseBuild(null, Objects.requireNonNull(rejection, "rejection"));
private static SurfaceCourseBuild rejected(HydrologyCandidateRejection rejection, int detail) {
return new SurfaceCourseBuild(null, Objects.requireNonNull(rejection, "rejection"), detail);
}
}
@@ -46,7 +46,7 @@ public final class SurfaceCourseBuilder {
ValleyProfile valley = new ValleyProfileSolver(surface, sampler, seaLevel, minimumCourseLength)
.solve(centerline, channel, terminal, terminalHead);
if (!valley.accepted()) {
return SurfaceCourseResult.rejected(valley.rejection());
return SurfaceCourseResult.rejected(valley.rejection(), valley.rejectionDetail());
}
int exposed = valley.exposedStations();
boolean coastalDrop = directOcean && valley.head()[exposed - 1] > seaLevel;
@@ -82,7 +82,7 @@ public final class SurfaceCourseBuilder {
List<HydraulicSegment> segments = SurfaceSegmentLabeler.label(
worldSeed, courseId, x, z, head, width, depth, surface.banks());
if (segments.isEmpty()) {
return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT);
return SurfaceCourseResult.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, stations);
}
HydraulicSegment last = segments.getLast();
int lastStation = stations - 1;
@@ -92,7 +92,8 @@ public final class SurfaceCourseBuilder {
last.width(),
last.depth(),
new HydrologyPoint(x[lastStation], head[lastStation], z[lastStation]),
null
null,
0
);
}
}
@@ -13,7 +13,8 @@ public record SurfaceCourseResult(
int lastWidth,
int lastDepth,
HydrologyPoint pathEnd,
HydrologyCandidateRejection rejection
HydrologyCandidateRejection rejection,
int rejectionDetail
) {
public SurfaceCourseResult {
segments = List.copyOf(Objects.requireNonNull(segments, "segments"));
@@ -23,7 +24,7 @@ public record SurfaceCourseResult(
return rejection == null;
}
public static SurfaceCourseResult rejected(HydrologyCandidateRejection rejection) {
return new SurfaceCourseResult(List.of(), 0, 0, 0, null, Objects.requireNonNull(rejection, "rejection"));
public static SurfaceCourseResult rejected(HydrologyCandidateRejection rejection, int detail) {
return new SurfaceCourseResult(List.of(), 0, 0, 0, null, Objects.requireNonNull(rejection, "rejection"), detail);
}
}
@@ -8,17 +8,19 @@ public record ValleyProfile(
int[] crossMax,
int[] centerNatural,
int exposedStations,
HydrologyCandidateRejection rejection
HydrologyCandidateRejection rejection,
int rejectionDetail
) {
public boolean accepted() {
return rejection == null;
}
public static ValleyProfile fromHeads(int[] head, int exposedStations) {
return new ValleyProfile(head, new int[0], new int[0], new int[0], exposedStations, null);
return new ValleyProfile(head, new int[0], new int[0], new int[0], exposedStations, null, 0);
}
public static ValleyProfile rejected(HydrologyCandidateRejection rejection) {
return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection);
/** {@code detail} names the measure that failed: exposed stations, required cut, or missing head. */
public static ValleyProfile rejected(HydrologyCandidateRejection rejection, int detail) {
return new ValleyProfile(new int[0], new int[0], new int[0], new int[0], 0, rejection, detail);
}
}
@@ -79,10 +79,10 @@ public final class ValleyProfileSolver {
crossMax[station] = maximum;
}
if (exposed < 2 || exposed < minimumCourseLength) {
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT);
return ValleyProfile.rejected(HydrologyCandidateRejection.COURSE_TOO_SHORT, exposed);
}
if (exposed < count && terminal != SurfaceTerminal.OCEAN_MOUTH) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE);
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_EXPOSURE, exposed);
}
int inset = surface.banks().inset();
int[] head = new int[count];
@@ -101,19 +101,24 @@ public final class ValleyProfileSolver {
}
}
if (terminal == SurfaceTerminal.SINKHOLE && head[exposed - 1] < terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE);
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_SINKHOLE_CLEARANCE, terminalHead - head[exposed - 1]);
}
if (terminal == SurfaceTerminal.COASTAL_GROTTO && head[exposed - 1] < terminalHead) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE);
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_HEAD_RANGE, terminalHead - head[exposed - 1]);
}
int maximumIncision = surface.maximumIncision();
int deepestCut = 0;
boolean rejected = false;
for (int station = 0; station < exposed; station++) {
int permitted = Math.min(maximumIncision, (int) StrictMath.floor(maximumIncision * incisionMultiplier[station]));
int bed = head[station] - (int) StrictMath.round(channel.depth()[station]);
if (centerNatural[station] - bed > permitted) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED);
}
int cut = centerNatural[station] - bed;
deepestCut = Math.max(deepestCut, cut);
rejected |= cut > permitted;
}
return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null);
if (rejected) {
return ValleyProfile.rejected(HydrologyCandidateRejection.SURFACE_CORRIDOR_UNSUPPORTED, deepestCut);
}
return new ValleyProfile(head, crossMin, crossMax, centerNatural, exposed, null, 0);
}
}
@@ -143,7 +143,7 @@ public class IrisRendererRiverTest {
HydrologyCandidateKind.SOURCE,
HydrologyFeatureType.SURFACE_POOL,
new HydrologyPoint(0, 80, 0),
HydrologyCandidateRejection.NO_LEGAL_OUTLET
HydrologyCandidateRejection.NO_LEGAL_OUTLET, 0
);
when(engine.getComplex()).thenReturn(complex);
when(complex.getHydrologyRuntime()).thenReturn(runtime);
@@ -219,13 +219,23 @@ public final class RiverTransectProbe {
static List<String> rejectionLines(HydrologyTile tile) {
TreeMap<String, Integer> counts = new TreeMap<>();
TreeMap<String, ArrayList<Integer>> details = new TreeMap<>();
for (HydrologyDiagnosticCandidate candidate : tile.diagnosticCandidates()) {
String key = "rejected " + candidate.kind() + " " + candidate.projectedType() + " " + candidate.rejection();
counts.merge(key, 1, Integer::sum);
if (candidate.detail() != 0) {
details.computeIfAbsent(key, (String ignored) -> new ArrayList<>()).add(candidate.detail());
}
}
ArrayList<String> lines = new ArrayList<>(counts.size());
for (Map.Entry<String, Integer> entry : counts.entrySet()) {
lines.add(entry.getKey() + " x" + entry.getValue());
ArrayList<Integer> values = details.get(entry.getKey());
String suffix = "";
if (values != null) {
values.sort(null);
suffix = " detail=" + values.subList(0, Math.min(values.size(), 24));
}
lines.add(entry.getKey() + " x" + entry.getValue() + suffix);
}
return lines;
}