From de601f5f952545c044dacf719f2b0b99e0edc939 Mon Sep 17 00:00:00 2001 From: Bryson Spilman Date: Tue, 13 Oct 2026 09:28:33 -0700 Subject: [PATCH] CDA-122 - Allows for round-tripping ratings --- .../cwms/cda/data/dao/JsonRatingUtils.java | 7 + .../dao/RetrievedRatingsJsonConverter.java | 592 ++++++++++++++++++ .../api/rating/RatingsControllerTestIT.java | 231 +++++++ .../cda/data/dao/JsonRatingUtilsTest.java | 254 ++++++++ .../cwms/cda/api/spk/rating_template.json | 18 + .../cwms/cda/data/dto/rating/rating_post.json | 126 ++++ .../dto/rating/rating_post_transitional.json | 144 +++++ .../cda/data/dto/rating/rating_post_usgs.json | 78 +++ .../data/dto/rating/rating_post_virtual.json | 158 +++++ .../dto/rating/retrieved/mixed_types.json | 35 ++ .../retrieved/multiple_independent.json | 21 + .../retrieved/simple_formula_notes_datum.json | 31 + .../dto/rating/retrieved/transitional.json | 35 ++ .../rating/retrieved/usgs_without_points.json | 26 + .../data/dto/rating/retrieved/virtual.json | 34 + 15 files changed, 1790 insertions(+) create mode 100644 cwms-data-api/src/main/java/cwms/cda/data/dao/RetrievedRatingsJsonConverter.java create mode 100644 cwms-data-api/src/test/resources/cwms/cda/api/spk/rating_template.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_transitional.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_usgs.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_virtual.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/mixed_types.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/multiple_independent.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/simple_formula_notes_datum.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/transitional.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/usgs_without_points.json create mode 100644 cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/virtual.json diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/JsonRatingUtils.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/JsonRatingUtils.java index 7ac3d788c..694059b13 100644 --- a/cwms-data-api/src/main/java/cwms/cda/data/dao/JsonRatingUtils.java +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/JsonRatingUtils.java @@ -73,7 +73,14 @@ public static RatingSet toRatingSet(RatingsDocument ratings) return RatingXmlFactory.ratingSet(writeXml(ratings)); } + /** + * Reads either the json written by writeJson(RatingsDocument) (GET /ratings/{rating-id}) + * or the json returned by GET /ratings so that it can be round-tripped back to POST /ratings. + */ public static RatingsDocument readJson(String json) throws JsonProcessingException { + if (RetrievedRatingsJsonConverter.isRetrievedFormat(JSON_MAPPER, json)) { + return RetrievedRatingsJsonConverter.toRatingsDocument(JSON_MAPPER, json); + } return JSON_MAPPER.readValue(json, RatingsDocument.class); } diff --git a/cwms-data-api/src/main/java/cwms/cda/data/dao/RetrievedRatingsJsonConverter.java b/cwms-data-api/src/main/java/cwms/cda/data/dao/RetrievedRatingsJsonConverter.java new file mode 100644 index 000000000..5e0ff2a93 --- /dev/null +++ b/cwms-data-api/src/main/java/cwms/cda/data/dao/RetrievedRatingsJsonConverter.java @@ -0,0 +1,592 @@ +/* + * MIT License + * + * Copyright (c) 2026 Hydrologic Engineering Center + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package cwms.cda.data.dao; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import cwms.cda.data.dto.rating.document.CaseElement; +import cwms.cda.data.dto.rating.document.HeightOffsetsElement; +import cwms.cda.data.dto.rating.document.HeightShiftsElement; +import cwms.cda.data.dto.rating.document.IndParameterSpecElement; +import cwms.cda.data.dto.rating.document.IndParameterSpecsElement; +import cwms.cda.data.dto.rating.document.IndRoundingSpecsElement; +import cwms.cda.data.dto.rating.document.OtherIndElement; +import cwms.cda.data.dto.rating.document.PointElement; +import cwms.cda.data.dto.rating.document.PositionalValueElement; +import cwms.cda.data.dto.rating.document.RatingPointsElement; +import cwms.cda.data.dto.rating.document.RatingSpecElement; +import cwms.cda.data.dto.rating.document.RatingTemplateElement; +import cwms.cda.data.dto.rating.document.RatingsDocument; +import cwms.cda.data.dto.rating.document.SelectElement; +import cwms.cda.data.dto.rating.document.SimpleRatingElement; +import cwms.cda.data.dto.rating.document.SourceRatingElement; +import cwms.cda.data.dto.rating.document.TransitionalRatingElement; +import cwms.cda.data.dto.rating.document.TransitionalSourceRatingsElement; +import cwms.cda.data.dto.rating.document.UsgsStreamRatingElement; +import cwms.cda.data.dto.rating.document.VirtualRatingElement; +import cwms.cda.data.dto.rating.document.VirtualSourceRatingsElement; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Converts the json returned by GET /ratings (CWMS_RATING.RETRIEVE_RATINGS_F) into a + * RatingsDocument so that it can be sent back to POST /ratings. + */ +final class RetrievedRatingsJsonConverter { + + static final String ROOT = "ratings"; + + private static final String RATING_TEMPLATES = "rating-templates"; + private static final String RATING_SPECS = "rating-specs"; + private static final String RATINGS = "ratings"; + private static final String SIMPLE_RATING = "simple-rating"; + private static final String USGS_STREAM_RATING = "usgs-stream-rating"; + private static final String VIRTUAL_RATING = "virtual-rating"; + private static final String TRANSITIONAL_RATING = "transitional-rating"; + + private static final String OFFICE = "office"; + private static final String NAME = "name"; + private static final String VERSION = "version"; + private static final String DESCRIPTION = "description"; + private static final String EFFECTIVE_DATE = "effective-date"; + private static final String IND_PARAMETERS = "ind-parameters"; + private static final String DEP_PARAMETER = "dep-parameter"; + private static final String VALUES = "values"; + private static final String EXTENSION_VALUES = "extension-values"; + private static final String REFERENCES = "references"; + private static final String TRUE = "true"; + + private RetrievedRatingsJsonConverter() { + throw new AssertionError("Utility class"); + } + + static boolean isRetrievedFormat(ObjectMapper mapper, String json) throws JsonProcessingException { + try (JsonParser parser = mapper.createParser(json)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + return false; + } + while (parser.nextToken() == JsonToken.FIELD_NAME) { + String fieldName = parser.currentName(); + JsonToken value = parser.nextToken(); + if (ROOT.equals(fieldName) && value == JsonToken.START_OBJECT) { + return true; + } + parser.skipChildren(); + } + return false; + } catch (JsonProcessingException e) { + throw e; + } catch (IOException e) { + // Only reading from a String, should not happen. + throw new UncheckedIOException(e); + } + } + + static RatingsDocument toRatingsDocument(ObjectMapper mapper, String json) throws JsonProcessingException { + JsonNode ratings = mapper.readTree(json).get(ROOT); + if (ratings.hasNonNull("error")) { + throw error("The ratings json holds an error instead of ratings: " + ratings.get("error").asText()); + } + + List templates = new ArrayList<>(); + for (JsonNode template : elements(ratings.get(RATING_TEMPLATES))) { + templates.add(toTemplate(template)); + } + + List specs = new ArrayList<>(); + for (JsonNode spec : elements(ratings.get(RATING_SPECS))) { + specs.add(toSpec(spec)); + } + + List simpleRatings = new ArrayList<>(); + List usgsStreamRatings = new ArrayList<>(); + List virtualRatings = new ArrayList<>(); + List transitionalRatings = new ArrayList<>(); + for (JsonNode rating : elements(ratings.get(RATINGS))) { + // Each entry is keyed by its rating type, e.g. {"simple-rating": {...}} + for (Map.Entry entry : rating.properties()) { + switch (entry.getKey()) { + case SIMPLE_RATING: + simpleRatings.add(toSimpleRating(entry.getValue())); + break; + case USGS_STREAM_RATING: + usgsStreamRatings.add(toUsgsStreamRating(entry.getValue())); + break; + case VIRTUAL_RATING: + virtualRatings.add(toVirtualRating(entry.getValue())); + break; + case TRANSITIONAL_RATING: + transitionalRatings.add(toTransitionalRating(entry.getValue())); + break; + default: + throw error("Unknown rating type '" + entry.getKey() + "'."); + } + } + } + + return new RatingsDocument.Builder() + .withRatingTemplates(nullIfEmpty(templates)) + .withRatingSpecs(nullIfEmpty(specs)) + .withSimpleRatings(nullIfEmpty(simpleRatings)) + .withUsgsStreamRatings(nullIfEmpty(usgsStreamRatings)) + .withVirtualRatings(nullIfEmpty(virtualRatings)) + .withTransitionalRatings(nullIfEmpty(transitionalRatings)) + .build(); + } + + private static RatingTemplateElement toTemplate(JsonNode template) { + List indParameterSpecs = new ArrayList<>(); + int position = 1; + for (JsonNode indParameter : elements(template.get(IND_PARAMETERS))) { + indParameterSpecs.add(new IndParameterSpecElement.Builder() + .withPosition(position++) + .withParameter(text(indParameter, NAME)) + .withInRangeMethod(text(indParameter, "value-lookup-in-range")) + .withOutRangeLowMethod(text(indParameter, "value-lookup-below-range")) + .withOutRangeHighMethod(text(indParameter, "value-lookup-above-range")) + .build()); + } + + String version = text(template, VERSION); + String parametersId = text(template, "parameters-string"); + if (parametersId == null) { + parametersId = removeSuffix(text(template, NAME), version); + } + + return new RatingTemplateElement.Builder() + .withOfficeId(text(template, OFFICE)) + .withParametersId(parametersId) + .withVersion(version) + .withIndParameterSpecs(new IndParameterSpecsElement.Builder() + .withIndParameterSpecs(indParameterSpecs) + .build()) + .withDepParameter(text(template, DEP_PARAMETER)) + .withDescription(textOrDefault(template, DESCRIPTION, "")) + .build(); + } + + private static RatingSpecElement toSpec(JsonNode spec) { + String ratingSpecId = text(spec, NAME); + String locationId = text(spec, "location"); + String version = text(spec, VERSION); + + JsonNode rounding = spec.path("rounding"); + List indRoundingSpecs = new ArrayList<>(); + int position = 1; + for (JsonNode indRounding : elements(rounding.get(IND_PARAMETERS))) { + indRoundingSpecs.add(new PositionalValueElement.Builder() + .withPosition(position++) + .withValue(indRounding.asText()) + .build()); + } + + // The retrieved json does not include the active or auto-* flags, so default to an + // active spec with the auto-* behavior turned off when they're missing. + return new RatingSpecElement.Builder() + .withOfficeId(text(spec, OFFICE)) + .withRatingSpecId(ratingSpecId) + .withTemplateId(templateId(ratingSpecId, locationId, version)) + .withLocationId(locationId) + .withVersion(version) + .withSourceAgency(textOrDefault(spec, "source-agency", "")) + .withInRangeMethod(text(spec, "time-lookup-in-range")) + .withOutRangeLowMethod(text(spec, "time-lookup-before-first")) + .withOutRangeHighMethod(text(spec, "time-lookup-after-last")) + .withActive(textOrDefault(spec, "active", TRUE)) + .withAutoUpdate(textOrDefault(spec, "auto-update", "false")) + .withAutoActivate(textOrDefault(spec, "auto-activate", "false")) + .withAutoMigrateExtension(textOrDefault(spec, "auto-migrate-extension", "false")) + .withIndRoundingSpecs(indRoundingSpecs.isEmpty() ? null + : new IndRoundingSpecsElement.Builder() + .withIndRoundingSpecs(indRoundingSpecs) + .build()) + .withDepRoundingSpec(text(rounding, DEP_PARAMETER)) + .withDescription(textOrDefault(spec, DESCRIPTION, "")) + .build(); + } + + private static SimpleRatingElement toSimpleRating(JsonNode rating) throws JsonMappingException { + String ratingSpecId = text(rating, NAME); + // e.g. ["Stage (ft)"] and "Flow (cfs)" + List indParameters = texts(rating.get(IND_PARAMETERS)); + + SimpleRatingElement.Builder builder = new SimpleRatingElement.Builder() + .withOfficeId(text(rating, OFFICE)) + .withRatingSpecId(ratingSpecId) + .withUnitsId(unitsId(indParameters, text(rating, DEP_PARAMETER))) + .withEffectiveDate(text(rating, EFFECTIVE_DATE)) + .withActive(TRUE) + .withDescription(textOrDefault(rating, DESCRIPTION, "")) + .withFormula(text(rating, "formula")); + + JsonNode values = rating.get(VALUES); + if (values != null && !values.isNull()) { + builder.withRatingPoints(nullIfEmpty( + toRatingPoints(ratingSpecId, VALUES, values, Math.max(indParameters.size(), 1)))); + } + builder.withExtensionPoints(extensionPoints(ratingSpecId, rating)); + return builder.build(); + } + + private static UsgsStreamRatingElement toUsgsStreamRating(JsonNode rating) throws JsonMappingException { + String ratingSpecId = text(rating, NAME); + String indParameter = text(rating, "ind-parameter"); + // The xsl also puts the datum on the dep parameter label when the ind parameter is an elevation. + String unitsId = indParameter == null ? null : unitsId(Collections.singletonList(indParameter), + text(rating, DEP_PARAMETER), isElevation(indParameter)); + + List shifts = new ArrayList<>(); + for (JsonNode shift : elements(rating.get("shifts"))) { + shifts.add(new HeightShiftsElement.Builder() + .withEffectiveDate(text(shift, EFFECTIVE_DATE)) + .withActive(TRUE) + .withPoints(nullIfEmpty(toPoints(ratingSpecId, "shifts", shift.get(VALUES)))) + .build()); + } + + // The database always writes and for these, even when + // empty (e.g. a rating that only holds shifts for an existing base curve). + return new UsgsStreamRatingElement.Builder() + .withOfficeId(text(rating, OFFICE)) + .withRatingSpecId(ratingSpecId) + .withUnitsId(unitsId) + .withEffectiveDate(text(rating, EFFECTIVE_DATE)) + .withActive(TRUE) + .withDescription(textOrDefault(rating, DESCRIPTION, "")) + .withHeightShifts(nullIfEmpty(shifts)) + .withHeightOffsets(new HeightOffsetsElement.Builder() + .withPoints(nullIfEmpty(toPoints(ratingSpecId, "offsets", rating.get("offsets")))) + .build()) + .withRatingPoints(Collections.singletonList(new RatingPointsElement.Builder() + .withPoints(nullIfEmpty(toPoints(ratingSpecId, VALUES, rating.get(VALUES)))) + .build())) + .withExtensionPoints(extensionPoints(ratingSpecId, rating)) + .build(); + } + + private static VirtualRatingElement toVirtualRating(JsonNode rating) { + // The xsl splits the connections on ',' into an array. + String connections = String.join(",", texts(rating.get("connections"))); + + // References are written as [{"name": "R1", "reference": "...", "units": "ft;ft"}] + List sourceRatings = new ArrayList<>(); + int index = 1; + for (JsonNode reference : elements(rating.get(REFERENCES))) { + String value = text(reference, "reference"); + String units = text(reference, "units"); + String valueWithUnits = value != null && units != null ? value + " {" + units + "}" : value; + SourceRatingElement.Builder sourceRating = new SourceRatingElement.Builder() + .withPosition(position(text(reference, NAME), index++)); + if (isRatingSpecId(value)) { + sourceRating.withRatingSpecId(valueWithUnits); + } else { + sourceRating.withRatingExpression(valueWithUnits); + } + sourceRatings.add(sourceRating.build()); + } + + return new VirtualRatingElement.Builder() + .withOfficeId(text(rating, OFFICE)) + .withRatingSpecId(text(rating, NAME)) + .withEffectiveDate(text(rating, EFFECTIVE_DATE)) + .withActive(TRUE) + .withDescription(textOrDefault(rating, DESCRIPTION, "")) + .withConnections(connections.isEmpty() ? null : connections) + .withSourceRatings(new VirtualSourceRatingsElement.Builder() + .withSourceRatings(nullIfEmpty(sourceRatings)) + .build()) + .build(); + } + + private static TransitionalRatingElement toTransitionalRating(JsonNode rating) { + List cases = new ArrayList<>(); + int position = 1; + for (JsonNode caseNode : elements(rating.get("cases"))) { + cases.add(new CaseElement.Builder() + .withPosition(position++) + .withWhen(text(caseNode, "when")) + .withThen(text(caseNode, "then")) + .build()); + } + + // References are written as [{"R1": "rating-spec-id"}, {"R2": "rating-spec-id"}] + List sourceRatings = new ArrayList<>(); + int index = 1; + for (JsonNode reference : elements(rating.get(REFERENCES))) { + for (Map.Entry entry : reference.properties()) { + sourceRatings.add(new PositionalValueElement.Builder() + .withPosition(position(entry.getKey(), index++)) + .withValue(entry.getValue().isNull() ? null : entry.getValue().asText()) + .build()); + } + } + + return new TransitionalRatingElement.Builder() + .withOfficeId(text(rating, OFFICE)) + .withRatingSpecId(text(rating, NAME)) + .withUnitsId(unitsId(texts(rating.get(IND_PARAMETERS)), text(rating, DEP_PARAMETER))) + .withEffectiveDate(text(rating, EFFECTIVE_DATE)) + .withActive(TRUE) + .withDescription(textOrDefault(rating, DESCRIPTION, "")) + .withSelect(new SelectElement.Builder() + .withCases(nullIfEmpty(cases)) + .withDefault(text(rating, "default")) + .build()) + .withSourceRatings(new TransitionalSourceRatingsElement.Builder() + .withRatingSpecIds(nullIfEmpty(sourceRatings)) + .build()) + .build(); + } + + private static List extensionPoints(String ratingSpecId, JsonNode rating) + throws JsonMappingException { + return nullIfEmpty(toRatingPoints(ratingSpecId, EXTENSION_VALUES, rating.get(EXTENSION_VALUES), 1)); + } + + private static List toPoints(String ratingSpecId, String field, JsonNode rows) + throws JsonMappingException { + List ratingPoints = toRatingPoints(ratingSpecId, field, rows, 1); + return ratingPoints.isEmpty() ? Collections.emptyList() : ratingPoints.get(0).getPoints(); + } + + /** + * Each row is [ind-1, ..., ind-n, dep] with an optional trailing note. Rows sharing the same + * leading independent values are grouped into one rating-points element with those values as + * other-ind entries, matching the layout of the rating xml. + */ + private static List toRatingPoints(String ratingSpecId, String field, JsonNode rows, + int indCount) throws JsonMappingException { + List retval = new ArrayList<>(); + if (rows == null || rows.isNull()) { + return retval; + } + if (!rows.isArray()) { + throw error("Rating " + ratingSpecId + ": expected '" + field + "' to be an array of rows."); + } + List currentOtherInds = null; + List points = new ArrayList<>(); + for (JsonNode row : rows) { + int size = row.isArray() ? row.size() : -1; + String note = null; + if (size == indCount + 2 && row.get(size - 1).isTextual()) { + note = row.get(size - 1).asText(); + size--; + } + if (size != indCount + 1) { + throw badRow(ratingSpecId, field, row, indCount); + } + List otherInds = new ArrayList<>(); + for (int i = 0; i < indCount - 1; i++) { + otherInds.add(row.get(i).asText()); + } + if (currentOtherInds != null && !currentOtherInds.equals(otherInds)) { + retval.add(ratingPoints(currentOtherInds, points)); + points = new ArrayList<>(); + } + currentOtherInds = otherInds; + points.add(new PointElement.Builder() + .withInd(row.get(indCount - 1).asText()) + .withDep(row.get(indCount).asText()) + .withNote(note) + .build()); + } + if (currentOtherInds != null) { + retval.add(ratingPoints(currentOtherInds, points)); + } + return retval; + } + + private static JsonMappingException badRow(String ratingSpecId, String field, JsonNode row, int indCount) { + boolean onlyLastInd = row.isArray() && (row.size() == 2 || (row.size() == 3 && row.get(2).isTextual())); + if (indCount > 1 && onlyLastInd) { + // Ratings_v1_to_RADAR_json.xsl drops the other-ind values, so the table can't be rebuilt. + return error("Rating " + ratingSpecId + " has " + indCount + " independent parameters but the " + + "GET /ratings json only holds the last independent and the dependent value of each point. " + + "Use the json or xml from GET /ratings/{rating-id} to store this rating."); + } + return error("Rating " + ratingSpecId + ": expected each row of '" + field + "' to hold " + (indCount + 1) + + " numbers (" + indCount + " independent followed by the dependent) and an optional note, " + + "but found: " + row); + } + + private static RatingPointsElement ratingPoints(List otherIndValues, List points) { + List otherInds = new ArrayList<>(); + for (int i = 0; i < otherIndValues.size(); i++) { + otherInds.add(new OtherIndElement.Builder() + .withPosition(i + 1) + .withValue(otherIndValues.get(i)) + .build()); + } + return new RatingPointsElement.Builder() + .withOtherInds(nullIfEmpty(otherInds)) + .withPoints(points) + .build(); + } + + static String unitsId(List indParameters, String depParameter) { + return unitsId(indParameters, depParameter, isElevation(depParameter)); + } + + private static String unitsId(List indParameters, String depParameter, boolean depHasDatum) { + String depUnit = unit(depParameter, depHasDatum); + if (depUnit == null || indParameters.isEmpty()) { + return null; + } + List indUnits = new ArrayList<>(); + for (String indParameter : indParameters) { + String indUnit = unit(indParameter, isElevation(indParameter)); + if (indUnit == null) { + return null; + } + indUnits.add(indUnit); + } + return String.join(",", indUnits) + ";" + depUnit; + } + + // Elevation labels may have the vertical datum after the unit, e.g. "Elev (ft NAVD88)". + private static String unit(String parameter, boolean mayHaveDatum) { + if (parameter == null) { + return null; + } + int open = parameter.lastIndexOf('('); + int close = parameter.lastIndexOf(')'); + if (open < 0 || close < open) { + return null; + } + String unit = parameter.substring(open + 1, close).trim(); + int space = unit.indexOf(' '); + if (mayHaveDatum && space > 0) { + unit = unit.substring(0, space); + } + return unit; + } + + private static boolean isElevation(String parameter) { + return parameter != null && parameter.startsWith("Elev"); + } + + // location.template-parameters.template-version.spec-version, where the template parameters + // hold a ';'. Anything else referenced by a virtual rating is an expression. + static boolean isRatingSpecId(String reference) { + if (reference == null) { + return false; + } + String[] parts = reference.split("\\.", -1); + if (parts.length != 4 || !parts[1].contains(";")) { + return false; + } + for (String part : parts) { + if (part.trim().isEmpty()) { + return false; + } + } + return true; + } + + // Source ratings are named "R1", "R2"... and the number is the position. + private static String position(String name, int defaultPosition) { + if (name != null && name.length() > 1 && Character.toUpperCase(name.charAt(0)) == 'R') { + return name.substring(1); + } + return String.valueOf(defaultPosition); + } + + static String templateId(String ratingSpecId, String locationId, String version) { + if (ratingSpecId == null) { + return null; + } + String retval = ratingSpecId; + if (locationId != null && retval.startsWith(locationId + ".")) { + retval = retval.substring(locationId.length() + 1); + } else if (retval.indexOf('.') >= 0) { + retval = retval.substring(retval.indexOf('.') + 1); + } + if (version != null && retval.endsWith("." + version)) { + retval = removeSuffix(retval, version); + } else if (retval.lastIndexOf('.') >= 0) { + retval = retval.substring(0, retval.lastIndexOf('.')); + } + return retval; + } + + private static String removeSuffix(String value, String suffix) { + if (value == null || suffix == null || !value.endsWith("." + suffix)) { + return value; + } + return value.substring(0, value.length() - suffix.length() - 1); + } + + private static List elements(JsonNode node) { + List retval = new ArrayList<>(); + if (node == null || node.isNull() || node.isMissingNode()) { + return retval; + } + if (node.isArray()) { + node.forEach(retval::add); + } else { + retval.add(node); + } + return retval; + } + + private static List texts(JsonNode node) { + List retval = new ArrayList<>(); + for (JsonNode element : elements(node)) { + if (!element.isNull()) { + retval.add(element.asText()); + } + } + return retval; + } + + private static String text(JsonNode node, String field) { + JsonNode value = node.get(field); + if (value == null || value.isNull()) { + return null; + } + return value.asText(); + } + + private static String textOrDefault(JsonNode node, String field, String defaultValue) { + String value = text(node, field); + return value == null ? defaultValue : value; + } + + private static List nullIfEmpty(List values) { + return values == null || values.isEmpty() ? null : values; + } + + private static JsonMappingException error(String message) { + return new JsonMappingException(null, message); + } +} diff --git a/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java index 7c26e733d..06f2fe34d 100644 --- a/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java +++ b/cwms-data-api/src/test/java/cwms/cda/api/rating/RatingsControllerTestIT.java @@ -44,6 +44,7 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.util.Arrays; import static cwms.cda.api.Controllers.*; import static io.restassured.RestAssured.given; @@ -559,5 +560,235 @@ void test_1206_rating_create_xml() throws IOException { .statusCode(is(HttpServletResponse.SC_CREATED)); } + @Test + void test_1758_rating_post() throws IOException { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String ratingId = "FSMI.Stage;Flow.EXSA.PRODUCTION"; + ratingId = ratingId.replaceAll("FSMI", EXISTING_LOC); + String body = readResourceFile("cwms/cda/data/dto/rating/rating_post.json"); + body = body.replaceAll("FSMI", EXISTING_LOC); + String template = readResourceFile("cwms/cda/api/spk/rating_template.json"); + + //Create Template + given() + .log().ifValidationFails(LogDetail.ALL,true) + .contentType(Formats.JSONV2) + .body(template) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings/template") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + // Create the set + given() + .log().ifValidationFails(LogDetail.ALL,true) + .contentType(Formats.JSONV2) + .accept(Formats.JSONV2) + .body(body) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(Formats.JSONV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .contentType(is(Formats.JSONV2)) + .body("rating-spec.office-id", is(SPK)) + .body("simple-rating.office-id", is(SPK)); + } + + @Test + void test_1758_rating_post_usgs_stream_rating() throws IOException { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String ratingId = "USGSTEST.Stage;Flow.USGS-EXSA.USGS-NWIS".replace("USGSTEST", EXISTING_LOC); + // json as returned by GET /ratings + String body = readResourceFile("cwms/cda/data/dto/rating/rating_post_usgs.json") + .replace("USGSTEST", EXISTING_LOC); + + // Create the set + given() + .log().ifValidationFails(LogDetail.ALL,true) + .contentType(Formats.JSONV2) + .body(body) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(Formats.JSONV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .contentType(is(Formats.JSONV2)) + .body("rating-spec.office-id", is(SPK)) + .body("rating-spec.source-agency", is("USGS")) + .body("usgs-stream-rating.office-id", is(SPK)) + .body("usgs-stream-rating.rating-spec-id", is(ratingId)) + .body("usgs-stream-rating.units-id", is("ft;cfs")) + .body("usgs-stream-rating.height-shifts.point.size()", is(2)) + .body("usgs-stream-rating.rating-points.point.size()", is(4)); + } + + @Test + void test_1758_rating_post_virtual_rating() throws IOException { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String ratingId = "VIRTTEST.Stage;Flow.Virtual.Production".replace("VIRTTEST", EXISTING_LOC); + // json as returned by GET /ratings, including the source ratings the virtual rating references + String body = readResourceFile("cwms/cda/data/dto/rating/rating_post_virtual.json") + .replace("VIRTTEST", EXISTING_LOC); + + // Create the set + given() + .log().ifValidationFails(LogDetail.ALL,true) + .contentType(Formats.JSONV2) + .body(body) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(Formats.JSONV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .contentType(is(Formats.JSONV2)) + .body("virtual-rating.office-id", is(SPK)) + .body("virtual-rating.rating-spec-id", is(ratingId)) + .body("virtual-rating.connections", is("R2I1=R1D")) + .body("virtual-rating.source-ratings.source-rating.size()", is(2)); + } + + @Test + void test_1758_rating_post_transitional_rating() throws IOException { + TestAccounts.KeyUser user = TestAccounts.KeyUser.SPK_NORMAL; + String ratingId = "TRANSTEST.Stage;Flow.Transitional.Production".replace("TRANSTEST", EXISTING_LOC); + // json as returned by GET /ratings, including the source ratings the transitional rating references + String body = readResourceFile("cwms/cda/data/dto/rating/rating_post_transitional.json") + .replace("TRANSTEST", EXISTING_LOC); + + // Create the set + given() + .log().ifValidationFails(LogDetail.ALL,true) + .contentType(Formats.JSONV2) + .body(body) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .post("/ratings") + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_CREATED)); + + String retrieved = given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(Formats.JSONV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .contentType(is(Formats.JSONV2)) + .body("transitional-rating.office-id", is(SPK)) + .body("transitional-rating.rating-spec-id", is(ratingId)) + .body("transitional-rating.units-id", is("ft;cfs")) + // the database normalizes the condition from "I1 GT 20" + .body("transitional-rating.select.case.when", is("I1 gt 20.0")) + .body("transitional-rating.select.default", is("R1")) + .body("transitional-rating.source-ratings.rating-spec-id.size()", is(2)) + .extract() + .asString(); + + // The source rating spec ids in that json keep the whitespace from the rating xml + // (e.g. "\n LOC.Stage;Flow.TransSrc.Low\n "), make sure it can still be stored as is. + given() + .log().ifValidationFails(LogDetail.ALL,true) + .contentType(Formats.JSONV2) + .body(retrieved) + .header("Authorization", user.toHeaderValue()) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .patch("/ratings/" + ratingId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)); + + // The sources still resolve, so the whitespace wasn't stored with the ids + given() + .log().ifValidationFails(LogDetail.ALL,true) + .accept(Formats.JSONV2) + .queryParam(OFFICE, SPK) + .when() + .redirects().follow(true) + .redirects().max(3) + .get("/ratings/" + ratingId) + .then() + .log().ifValidationFails(LogDetail.ALL,true) + .assertThat() + .statusCode(is(HttpServletResponse.SC_OK)) + .body("transitional-rating.source-ratings.rating-spec-id.element-value.collect { it.trim() }", + is(Arrays.asList(EXISTING_LOC + ".Stage;Flow.TransSrc.Low", + EXISTING_LOC + ".Stage;Flow.TransSrc.High"))); + } + } diff --git a/cwms-data-api/src/test/java/cwms/cda/data/dao/JsonRatingUtilsTest.java b/cwms-data-api/src/test/java/cwms/cda/data/dao/JsonRatingUtilsTest.java index ef04331e1..648de4366 100644 --- a/cwms-data-api/src/test/java/cwms/cda/data/dao/JsonRatingUtilsTest.java +++ b/cwms-data-api/src/test/java/cwms/cda/data/dao/JsonRatingUtilsTest.java @@ -5,12 +5,18 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import cwms.cda.data.dto.rating.document.HeightShiftsElement; +import cwms.cda.data.dto.rating.document.IndParameterSpecElement; +import cwms.cda.data.dto.rating.document.PointElement; import cwms.cda.data.dto.rating.document.PositionalValueElement; import cwms.cda.data.dto.rating.document.RatingPointsElement; import cwms.cda.data.dto.rating.document.RatingSpecElement; +import cwms.cda.data.dto.rating.document.RatingTemplateElement; import cwms.cda.data.dto.rating.document.RatingsDocument; import cwms.cda.data.dto.rating.document.SimpleRatingElement; +import cwms.cda.data.dto.rating.document.SourceRatingElement; import cwms.cda.data.dto.rating.document.TransitionalRatingElement; +import cwms.cda.data.dto.rating.document.UsgsStreamRatingElement; import cwms.cda.data.dto.rating.document.VirtualRatingElement; import hec.data.RatingException; import hec.data.cwmsRating.RatingSet; @@ -19,6 +25,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; +import java.util.List; import java.util.zip.GZIPInputStream; import mil.army.usace.hec.cwms.rating.io.xml.RatingXmlFactory; @@ -372,4 +379,251 @@ void test_unknown_field_rejected() { assertThrows(IOException.class, () -> JsonRatingUtils.readJson(json)); } + @Test + void test_read_retrieved_ratings_json() throws IOException, RatingException { + // json as returned by GET /ratings + String json = loadResourceAsString("cwms/cda/data/dto/rating/rating_post.json"); + RatingsDocument ratings = JsonRatingUtils.readJson(json); + + RatingTemplateElement template = ratings.getRatingTemplates().get(0); + assertEquals("SPK", template.getOfficeId()); + assertEquals("Stage;Flow", template.getParametersId()); + assertEquals("EXSA", template.getVersion()); + assertEquals("Flow", template.getDepParameter()); + assertEquals("", template.getDescription()); + IndParameterSpecElement indSpec = template.getIndParameterSpecs().getIndParameterSpecs().get(0); + assertEquals("1", indSpec.getPosition()); + assertEquals("Stage", indSpec.getParameter()); + assertEquals("LINEAR", indSpec.getInRangeMethod()); + assertEquals("NEAREST", indSpec.getOutRangeLowMethod()); + assertEquals("NEAREST", indSpec.getOutRangeHighMethod()); + + RatingSpecElement spec = ratings.getRatingSpecs().get(0); + assertEquals("SPK", spec.getOfficeId()); + assertEquals("FSMI.Stage;Flow.EXSA.PRODUCTION", spec.getRatingSpecId()); + assertEquals("Stage;Flow.EXSA", spec.getTemplateId()); + assertEquals("FSMI", spec.getLocationId()); + assertEquals("PRODUCTION", spec.getVersion()); + assertEquals("", spec.getSourceAgency()); + assertEquals("PREVIOUS", spec.getInRangeMethod()); + assertEquals("NEAREST", spec.getOutRangeLowMethod()); + assertEquals("PREVIOUS", spec.getOutRangeHighMethod()); + assertEquals("true", spec.getActive()); + assertEquals("false", spec.getAutoUpdate()); + PositionalValueElement indRounding = spec.getIndRoundingSpecs().getIndRoundingSpecs().get(0); + assertEquals("1", indRounding.getPosition()); + assertEquals("2223456784", indRounding.getValue()); + assertEquals("2223456784", spec.getDepRoundingSpec()); + + SimpleRatingElement simple = ratings.getSimpleRatings().get(0); + assertEquals("SPK", simple.getOfficeId()); + assertEquals("FSMI.Stage;Flow.EXSA.PRODUCTION", simple.getRatingSpecId()); + assertEquals("ft;cfs", simple.getUnitsId()); + assertEquals("2007-07-11T20:38:00Z", simple.getEffectiveDate()); + assertEquals("true", simple.getActive()); + assertEquals(1, simple.getRatingPoints().size()); + assertNull(simple.getRatingPoints().get(0).getOtherInds()); + List points = simple.getRatingPoints().get(0).getPoints(); + assertEquals(12, points.size()); + assertEquals("17", points.get(0).getInd()); + assertEquals("114000", points.get(0).getDep()); + assertEquals("18.75", points.get(1).getInd()); + assertEquals("520000", points.get(11).getDep()); + + String dbXml = JsonRatingUtils.writeXml(ratings); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("2223456784")); + assertFalse(dbXml.contains("query-info")); + + RatingSet ratingSet = JsonRatingUtils.toRatingSet(ratings); + assertEquals("FSMI.Stage;Flow.EXSA.PRODUCTION", ratingSet.getRatingSpec().getRatingSpecId()); + } + + @Test + void test_read_retrieved_ratings_json_multiple_independent() throws IOException { + String json = loadRetrieved("multiple_independent.json"); + + SimpleRatingElement simple = JsonRatingUtils.readJson(json).getSimpleRatings().get(0); + assertEquals("ft,ft;cfs", simple.getUnitsId()); + assertEquals(2, simple.getRatingPoints().size()); + RatingPointsElement second = simple.getRatingPoints().get(1); + assertEquals("1", second.getOtherInds().get(0).getPosition()); + assertEquals("2", second.getOtherInds().get(0).getValue()); + assertEquals(2, second.getPoints().size()); + assertEquals("101", second.getPoints().get(1).getInd()); + assertEquals("40", second.getPoints().get(1).getDep()); + } + + @Test + void test_read_retrieved_ratings_json_rejects_bad_rows() { + String json = "{\"ratings\": {\"ratings\": [{\"simple-rating\": {" + + "\"name\": \"A.Stage;Flow.Linear.Production\", \"ind-parameters\": [\"Stage (ft)\"]," + + "\"dep-parameter\": \"Flow (cfs)\", \"values\": [[1, 2, 3]]}}]}}"; + assertThrows(IOException.class, () -> JsonRatingUtils.readJson(json)); + } + + @Test + void test_read_retrieved_ratings_json_rejects_lossy_multiple_independent() { + // Ratings_v1_to_RADAR_json.xsl drops the other-ind values, leaving [ind, dep] rows + String json = "{\"ratings\": {\"ratings\": [{\"simple-rating\": {" + + "\"name\": \"A.Opening,Elev;Flow.Standard.Production\"," + + "\"ind-parameters\": [\"Opening (ft)\", \"Elev (ft)\"], \"dep-parameter\": \"Flow (cfs)\"," + + "\"values\": [[100, 10], [101, 20]]}}]}}"; + IOException ex = assertThrows(IOException.class, () -> JsonRatingUtils.readJson(json)); + assertTrue(ex.getMessage().contains("GET /ratings/{rating-id}")); + } + + @Test + void test_read_retrieved_ratings_json_rejects_unknown_rating_type() { + String json = "{\"ratings\": {\"ratings\": [{\"not-a-rating\": " + + "{\"name\": \"A.Stage;Flow.Linear.Production\"}}]}}"; + assertThrows(IOException.class, () -> JsonRatingUtils.readJson(json)); + } + + @Test + void test_read_retrieved_ratings_json_rejects_error() { + String json = "{\"ratings\":{\"error\":\"Query exceeded maximum time of +00 00:00:30\"}}"; + IOException ex = assertThrows(IOException.class, () -> JsonRatingUtils.readJson(json)); + assertTrue(ex.getMessage().contains("Query exceeded maximum time")); + } + + @Test + void test_read_retrieved_simple_rating_formula_notes_and_datum() throws IOException { + String json = loadRetrieved("simple_formula_notes_datum.json"); + + List ratings = JsonRatingUtils.readJson(json).getSimpleRatings(); + SimpleRatingElement table = ratings.get(0); + assertEquals("ft;ac-ft", table.getUnitsId()); + List points = table.getRatingPoints().get(0).getPoints(); + assertNull(points.get(0).getNote()); + assertEquals("510", points.get(1).getInd()); + assertEquals("spillway", points.get(1).getNote()); + + SimpleRatingElement formula = ratings.get(1); + assertEquals("i1 * 2", formula.getFormula()); + assertNull(formula.getRatingPoints()); + } + + @Test + void test_read_retrieved_usgs_stream_rating() throws IOException, RatingException { + String json = loadResourceAsString("cwms/cda/data/dto/rating/rating_post_usgs.json"); + RatingsDocument ratings = JsonRatingUtils.readJson(json); + + assertEquals("LOGARITHMIC", ratings.getRatingTemplates().get(0).getIndParameterSpecs() + .getIndParameterSpecs().get(0).getInRangeMethod()); + assertEquals("USGS", ratings.getRatingSpecs().get(0).getSourceAgency()); + + UsgsStreamRatingElement usgs = ratings.getUsgsStreamRatings().get(0); + assertEquals("SPK", usgs.getOfficeId()); + assertEquals("USGSTEST.Stage;Flow.USGS-EXSA.USGS-NWIS", usgs.getRatingSpecId()); + assertEquals("ft;cfs", usgs.getUnitsId()); + assertEquals("2020-01-01T00:00:00Z", usgs.getEffectiveDate()); + assertEquals("true", usgs.getActive()); + + HeightShiftsElement shift = usgs.getHeightShifts().get(0); + assertEquals("2020-06-01T00:00:00Z", shift.getEffectiveDate()); + assertEquals(2, shift.getPoints().size()); + assertEquals("5", shift.getPoints().get(1).getInd()); + assertEquals("0.2", shift.getPoints().get(1).getDep()); + assertEquals("MANUAL", shift.getPoints().get(1).getNote()); + + assertEquals("0.5", usgs.getHeightOffsets().getPoints().get(0).getDep()); + + List points = usgs.getRatingPoints().get(0).getPoints(); + assertEquals(4, points.size()); + assertEquals("0.6", points.get(0).getInd()); + assertEquals("300", points.get(3).getDep()); + assertEquals("BASE", points.get(3).getNote()); + assertNull(ratings.getSimpleRatings()); + + String dbXml = JsonRatingUtils.writeXml(ratings); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("")); + + RatingSet ratingSet = JsonRatingUtils.toRatingSet(ratings); + assertEquals("USGSTEST.Stage;Flow.USGS-EXSA.USGS-NWIS", ratingSet.getRatingSpec().getRatingSpecId()); + } + + @Test + void test_read_retrieved_usgs_stream_rating_without_points() throws IOException { + // A rating holding only shifts for an existing base curve + String json = loadRetrieved("usgs_without_points.json"); + + RatingsDocument ratings = JsonRatingUtils.readJson(json); + UsgsStreamRatingElement usgs = ratings.getUsgsStreamRatings().get(0); + assertNull(usgs.getHeightOffsets().getPoints()); + assertNull(usgs.getRatingPoints().get(0).getPoints()); + + String dbXml = JsonRatingUtils.writeXml(ratings); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("")); + } + + @Test + void test_read_retrieved_virtual_rating() throws IOException { + String json = loadRetrieved("virtual.json"); + + RatingsDocument ratings = JsonRatingUtils.readJson(json); + VirtualRatingElement virtual = ratings.getVirtualRatings().get(0); + assertEquals("SWT", virtual.getOfficeId()); + assertEquals("A.Elev;Flow.Virtual.Production", virtual.getRatingSpecId()); + assertEquals("R2I1=R1D,R3I1=R2D", virtual.getConnections()); + + List sources = virtual.getSourceRatings().getSourceRatings(); + assertEquals(3, sources.size()); + assertEquals("1", sources.get(0).getPosition()); + assertEquals("A.Elev;Stage.Linear.Production {ft;ft}", sources.get(0).getRatingSpecId()); + assertNull(sources.get(0).getRatingExpression()); + assertEquals("2", sources.get(1).getPosition()); + assertEquals("I1 * 2.5 {ft;cfs}", sources.get(1).getRatingExpression()); + assertNull(sources.get(1).getRatingSpecId()); + assertEquals("A.Flow;Flow.Linear.Production {cfs;cfs}", sources.get(2).getRatingSpecId()); + + String dbXml = JsonRatingUtils.writeXml(ratings); + assertTrue(dbXml.contains("")); + assertTrue(dbXml.contains("I1 * 2.5 {ft;cfs}")); + } + + @Test + void test_read_retrieved_transitional_rating() throws IOException { + String json = loadRetrieved("transitional.json"); + + RatingsDocument ratings = JsonRatingUtils.readJson(json); + TransitionalRatingElement transitional = ratings.getTransitionalRatings().get(0); + assertEquals("SWT", transitional.getOfficeId()); + assertEquals("ft;cfs", transitional.getUnitsId()); + assertEquals("1900-01-01T06:00:00Z", transitional.getEffectiveDate()); + assertEquals(2, transitional.getSelect().getCases().size()); + assertEquals("2", transitional.getSelect().getCases().get(1).getPosition()); + assertEquals("I1 GT 10", transitional.getSelect().getCases().get(1).getWhen()); + assertEquals("R2", transitional.getSelect().getCases().get(1).getThen()); + assertEquals("R2", transitional.getSelect().getDefault()); + List sources = transitional.getSourceRatings().getRatingSpecIds(); + assertEquals("1", sources.get(0).getPosition()); + assertEquals("A.Stage;Flow.Linear.Dummy", sources.get(0).getValue()); + assertEquals("2", sources.get(1).getPosition()); + assertEquals("A.Stage;Flow.Linear.Production", sources.get(1).getValue()); + + String dbXml = JsonRatingUtils.writeXml(ratings); + assertTrue(dbXml.contains("A.Stage;Flow.Linear.Production")); + } + + @Test + void test_read_retrieved_mixed_rating_types() throws IOException { + String json = loadRetrieved("mixed_types.json"); + + RatingsDocument ratings = JsonRatingUtils.readJson(json); + assertEquals(1, ratings.getSimpleRatings().size()); + assertEquals(1, ratings.getTransitionalRatings().size()); + assertNull(ratings.getTransitionalRatings().get(0).getSelect().getCases()); + } + + // json in the format returned by GET /ratings + private static String loadRetrieved(String fileName) throws IOException { + return loadResourceAsString("cwms/cda/data/dto/rating/retrieved/" + fileName); + } + } diff --git a/cwms-data-api/src/test/resources/cwms/cda/api/spk/rating_template.json b/cwms-data-api/src/test/resources/cwms/cda/api/spk/rating_template.json new file mode 100644 index 000000000..eaf9ade0f --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/api/spk/rating_template.json @@ -0,0 +1,18 @@ +{ + "rating-template": { + "office-id": "SPK", + "parameters-id": "Stage;Flow", + "version": "EXSA", + "ind-parameter-specs": { + "ind-parameter-spec": [{ + "position": "1", + "parameter": "Stage", + "in-range-method": "LINEAR", + "out-range-low-method": "NEAREST", + "out-range-high-method": "NEAREST" + }] + }, + "dep-parameter": "Flow", + "description": "" + } +} \ No newline at end of file diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post.json new file mode 100644 index 000000000..cd08eb04f --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post.json @@ -0,0 +1,126 @@ +{ + "ratings": { + "query-info": { + "time-of-query": "2026-06-01T16:52:08Z", + "process-query": "PT3.394S", + "format-output": "PT3.050S", + "requested-format": "JSON", + "requested-office-id": "SPK", + "requested-start-time": "2026-05-31T16:52:08Z", + "requested-end-time": "2026-06-01T16:52:08Z", + "requested-items": [ + { + "name": "FSMI.Stage;Flow.EXSA.PRODUCTION", + "unit": "NATIVE", + "datum": "NATIVE" + } + ], + "templates-retrieved": 1, + "total-specifications-retrieved": 1, + "unique-specifications-retrieved": 1, + "total-ratings-retrieved": 1, + "unique-ratings-retrieved": 1 + }, + "rating-templates": [ + { + "office": "SPK", + "name": "Stage;Flow.EXSA", + "parameters-string": "Stage;Flow", + "version": "EXSA", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LINEAR", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Flow", + "description": null + } + ], + "rating-specs": [ + { + "office": "SPK", + "name": "FSMI.Stage;Flow.EXSA.PRODUCTION", + "location": "FSMI", + "version": "PRODUCTION", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": [ + "2223456784" + ], + "dep-parameter": "2223456784" + }, + "description": "FSMI Expanded, Shift-Adjusted PRODUCTION Stream Rating" + } + ], + "ratings": [ + { + "simple-rating": { + "office": "SPK", + "name": "FSMI.Stage;Flow.EXSA.PRODUCTION", + "ind-parameters": [ + "Stage (ft)" + ], + "dep-parameter": "Flow (cfs)", + "effective-date": "2007-07-11T20:38:00Z", + "description": null, + "values": [ + [ + 17, + 114000 + ], + [ + 18.75, + 140000 + ], + [ + 20.47, + 165558 + ], + [ + 30, + 313800 + ], + [ + 30.4, + 320500 + ], + [ + 30.84, + 328800 + ], + [ + 31, + 332200 + ], + [ + 31.29, + 339300 + ], + [ + 31.4, + 342500 + ], + [ + 31.62, + 349500 + ], + [ + 32.26, + 372000 + ], + [ + 36, + 520000 + ] + ] + } + } + ] + } +} \ No newline at end of file diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_transitional.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_transitional.json new file mode 100644 index 000000000..b70756682 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_transitional.json @@ -0,0 +1,144 @@ +{ + "ratings": { + "rating-templates": [ + { + "office": "SPK", + "name": "Stage;Flow.TransSrc", + "parameters-string": "Stage;Flow", + "version": "TransSrc", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LINEAR", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Flow", + "description": null + }, + { + "office": "SPK", + "name": "Stage;Flow.Transitional", + "parameters-string": "Stage;Flow", + "version": "Transitional", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LINEAR", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Flow", + "description": null + } + ], + "rating-specs": [ + { + "office": "SPK", + "name": "TRANSTEST.Stage;Flow.TransSrc.Low", + "location": "TRANSTEST", + "version": "Low", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": ["2223456782"], + "dep-parameter": "2222233332" + }, + "description": "Low flow rating" + }, + { + "office": "SPK", + "name": "TRANSTEST.Stage;Flow.TransSrc.High", + "location": "TRANSTEST", + "version": "High", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": ["2223456782"], + "dep-parameter": "2222233332" + }, + "description": "High flow rating" + }, + { + "office": "SPK", + "name": "TRANSTEST.Stage;Flow.Transitional.Production", + "location": "TRANSTEST", + "version": "Production", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": ["2223456782"], + "dep-parameter": "2222233332" + }, + "description": "Low or high flow rating by stage" + } + ], + "ratings": [ + { + "simple-rating": { + "office": "SPK", + "name": "TRANSTEST.Stage;Flow.TransSrc.Low", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "values": [ + [0, 0], + [10, 800], + [20, 4000], + [30, 10000] + ] + } + }, + { + "simple-rating": { + "office": "SPK", + "name": "TRANSTEST.Stage;Flow.TransSrc.High", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "values": [ + [0, 0], + [10, 1000], + [20, 5000], + [30, 12000] + ] + } + }, + { + "transitional-rating": { + "office": "SPK", + "name": "TRANSTEST.Stage;Flow.Transitional.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "cases": [ + { + "when": "I1 GT 20", + "then": "R2" + } + ], + "default": "R1", + "references": [ + { + "R1": "TRANSTEST.Stage;Flow.TransSrc.Low" + }, + { + "R2": "TRANSTEST.Stage;Flow.TransSrc.High" + } + ] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_usgs.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_usgs.json new file mode 100644 index 000000000..720e2d02a --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_usgs.json @@ -0,0 +1,78 @@ +{ + "ratings": { + "query-info": { + "time-of-query": "2026-10-13T16:52:08Z", + "requested-format": "JSON", + "requested-office-id": "SPK", + "total-ratings-retrieved": 1, + "unique-ratings-retrieved": 1 + }, + "rating-templates": [ + { + "office": "SPK", + "name": "Stage;Flow.USGS-EXSA", + "parameters-string": "Stage;Flow", + "version": "USGS-EXSA", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LOGARITHMIC", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Flow", + "description": null + } + ], + "rating-specs": [ + { + "office": "SPK", + "name": "USGSTEST.Stage;Flow.USGS-EXSA.USGS-NWIS", + "location": "USGSTEST", + "version": "USGS-NWIS", + "source-agency": "USGS", + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": [ + "2223456782" + ], + "dep-parameter": "2222233332" + }, + "description": null + } + ], + "ratings": [ + { + "usgs-stream-rating": { + "office": "SPK", + "name": "USGSTEST.Stage;Flow.USGS-EXSA.USGS-NWIS", + "ind-parameter": "Stage (ft)", + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "shifts": [ + { + "effective-date": "2020-06-01T00:00:00Z", + "values": [ + [1, 0.1], + [5, 0.2, "MANUAL"] + ] + } + ], + "offsets": [ + [0, 0.5] + ], + "values": [ + [0.6, 1], + [1, 10], + [2, 40], + [5, 300, "BASE"] + ] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_virtual.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_virtual.json new file mode 100644 index 000000000..382620746 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/rating_post_virtual.json @@ -0,0 +1,158 @@ +{ + "ratings": { + "rating-templates": [ + { + "office": "SPK", + "name": "Stage;Stage.VirtSrc", + "parameters-string": "Stage;Stage", + "version": "VirtSrc", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LINEAR", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Stage", + "description": null + }, + { + "office": "SPK", + "name": "Stage;Flow.VirtSrc", + "parameters-string": "Stage;Flow", + "version": "VirtSrc", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LINEAR", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Flow", + "description": null + }, + { + "office": "SPK", + "name": "Stage;Flow.Virtual", + "parameters-string": "Stage;Flow", + "version": "Virtual", + "ind-parameters": [ + { + "name": "Stage", + "value-lookup-in-range": "LINEAR", + "value-lookup-below-range": "NEAREST", + "value-lookup-above-range": "NEAREST" + } + ], + "dep-parameter": "Flow", + "description": null + } + ], + "rating-specs": [ + { + "office": "SPK", + "name": "VIRTTEST.Stage;Stage.VirtSrc.Production", + "location": "VIRTTEST", + "version": "Production", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": ["2223456782"], + "dep-parameter": "2223456782" + }, + "description": "Stage correction" + }, + { + "office": "SPK", + "name": "VIRTTEST.Stage;Flow.VirtSrc.Production", + "location": "VIRTTEST", + "version": "Production", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": ["2223456782"], + "dep-parameter": "2222233332" + }, + "description": "Stream rating" + }, + { + "office": "SPK", + "name": "VIRTTEST.Stage;Flow.Virtual.Production", + "location": "VIRTTEST", + "version": "Production", + "source-agency": null, + "time-lookup-in-range": "PREVIOUS", + "time-lookup-before-first": "NEAREST", + "time-lookup-after-last": "PREVIOUS", + "rounding": { + "ind-parameters": ["2223456782"], + "dep-parameter": "2222233332" + }, + "description": "Corrected stage to flow" + } + ], + "ratings": [ + { + "simple-rating": { + "office": "SPK", + "name": "VIRTTEST.Stage;Stage.VirtSrc.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Stage (ft)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "values": [ + [0, 0.5], + [10, 10.5], + [20, 20.5], + [30, 30.5] + ] + } + }, + { + "simple-rating": { + "office": "SPK", + "name": "VIRTTEST.Stage;Flow.VirtSrc.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "values": [ + [0, 0], + [10, 1000], + [20, 5000], + [30, 12000] + ] + } + }, + { + "virtual-rating": { + "office": "SPK", + "name": "VIRTTEST.Stage;Flow.Virtual.Production", + "ind-parameters": ["Stage"], + "dep-parameter": "Flow", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "connections": ["R2I1=R1D"], + "references": [ + { + "name": "R1", + "reference": "VIRTTEST.Stage;Stage.VirtSrc.Production", + "units": "ft;ft" + }, + { + "name": "R2", + "reference": "VIRTTEST.Stage;Flow.VirtSrc.Production", + "units": "ft;cfs" + } + ] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/mixed_types.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/mixed_types.json new file mode 100644 index 000000000..6c07b3bf1 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/mixed_types.json @@ -0,0 +1,35 @@ +{ + "ratings": { + "ratings": [ + { + "simple-rating": { + "office": "SWT", + "name": "A.Stage;Flow.Linear.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "values": [ + [1, 10], + [2, 20] + ] + } + }, + { + "transitional-rating": { + "office": "SWT", + "name": "A.Stage;Flow.Transitional.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "cases": [], + "default": "R1", + "references": [ + { + "R1": "A.Stage;Flow.Linear.Production" + } + ] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/multiple_independent.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/multiple_independent.json new file mode 100644 index 000000000..2704364c1 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/multiple_independent.json @@ -0,0 +1,21 @@ +{ + "ratings": { + "ratings": [ + { + "simple-rating": { + "office": "SPK", + "name": "A.Opening,Elev;Flow.Standard.Production", + "ind-parameters": ["Opening (ft)", "Elev (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "values": [ + [1, 100, 10], + [1, 101, 20], + [2, 100, 30], + [2, 101, 40] + ] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/simple_formula_notes_datum.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/simple_formula_notes_datum.json new file mode 100644 index 000000000..29e6eddd5 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/simple_formula_notes_datum.json @@ -0,0 +1,31 @@ +{ + "ratings": { + "ratings": [ + { + "simple-rating": { + "office": "SWT", + "name": "A.Elev;Stor.Linear.Production", + "ind-parameters": ["Elev (ft NAVD88)"], + "dep-parameter": "Stor (ac-ft)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "values": [ + [500, 0], + [510, 100, "spillway"] + ] + } + }, + { + "simple-rating": { + "office": "SWT", + "name": "A.Stage;Flow.Formula.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "2021-01-01T00:00:00Z", + "description": null, + "formula": "i1 * 2" + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/transitional.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/transitional.json new file mode 100644 index 000000000..4e73f0876 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/transitional.json @@ -0,0 +1,35 @@ +{ + "ratings": { + "ratings": [ + { + "transitional-rating": { + "office": "SWT", + "name": "A.Stage;Flow.Transitional.Production", + "ind-parameters": ["Stage (ft)"], + "dep-parameter": "Flow (cfs)", + "effective-date": "1900-01-01T06:00:00Z", + "description": null, + "cases": [ + { + "when": "I1 GT 25", + "then": "R1" + }, + { + "when": "I1 GT 10", + "then": "R2" + } + ], + "default": "R2", + "references": [ + { + "R1": "A.Stage;Flow.Linear.Dummy" + }, + { + "R2": "A.Stage;Flow.Linear.Production" + } + ] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/usgs_without_points.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/usgs_without_points.json new file mode 100644 index 000000000..13b9fac29 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/usgs_without_points.json @@ -0,0 +1,26 @@ +{ + "ratings": { + "ratings": [ + { + "usgs-stream-rating": { + "office": "SWT", + "name": "A.Stage;Flow.USGS-EXSA.USGS-NWIS", + "ind-parameter": "Stage (ft)", + "dep-parameter": "Flow (cfs)", + "effective-date": "2020-01-01T00:00:00Z", + "description": null, + "shifts": [ + { + "effective-date": "2020-06-01T00:00:00Z", + "values": [ + [1, 0.1] + ] + } + ], + "offsets": [], + "values": [] + } + } + ] + } +} diff --git a/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/virtual.json b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/virtual.json new file mode 100644 index 000000000..856597675 --- /dev/null +++ b/cwms-data-api/src/test/resources/cwms/cda/data/dto/rating/retrieved/virtual.json @@ -0,0 +1,34 @@ +{ + "ratings": { + "ratings": [ + { + "virtual-rating": { + "office": "SWT", + "name": "A.Elev;Flow.Virtual.Production", + "ind-parameters": ["Elev"], + "dep-parameter": "Flow", + "effective-date": "2021-01-01T00:00:00Z", + "description": null, + "connections": ["R2I1=R1D", "R3I1=R2D"], + "references": [ + { + "name": "R1", + "reference": "A.Elev;Stage.Linear.Production", + "units": "ft;ft" + }, + { + "name": "R2", + "reference": "I1 * 2.5", + "units": "ft;cfs" + }, + { + "name": "R3", + "reference": "A.Flow;Flow.Linear.Production", + "units": "cfs;cfs" + } + ] + } + } + ] + } +}