package org.opensha2.calc; import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkState; import static java.nio.charset.StandardCharsets.UTF_8; import static org.opensha2.geo.BorderType.MERCATOR_LINEAR; import static org.opensha2.internal.GeoJson.validateProperty; import static org.opensha2.internal.Parsing.splitToList; import static org.opensha2.internal.TextUtils.LOG_INDENT; import static org.opensha2.internal.TextUtils.NEWLINE; import static org.opensha2.internal.TextUtils.NULL; import org.opensha2.calc.Site.Builder; import org.opensha2.geo.Bounds; import org.opensha2.geo.GriddedRegion; import org.opensha2.geo.Location; import org.opensha2.geo.LocationList; import org.opensha2.geo.Region; import org.opensha2.geo.Regions; import org.opensha2.internal.GeoJson; import org.opensha2.internal.Parsing; import org.opensha2.internal.Parsing.Delimiter; import com.google.common.base.Optional; import com.google.common.collect.ImmutableList; import com.google.common.collect.Iterables; import com.google.common.reflect.TypeToken; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonDeserializationContext; import com.google.gson.JsonDeserializer; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import java.io.IOException; import java.io.Reader; import java.lang.reflect.Type; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Iterator; import java.util.List; /** * Iterable {@code Site} container. Factory methods are supplied to creating * instances from different input formats. * * @author Peter Powers */ public abstract class Sites implements Iterable<Site> { /** * Create an unmodifiable {@code Iterable<Site>} from the comma-delimted site * file designated by {@code path}. * * @param path to comma-delimited site data file * @throws IOException if a problem is encountered */ public static Sites fromCsv(Path path, CalcConfig defaults) throws IOException { return readCsv(path, defaults); } private static Sites readCsv(Path path, CalcConfig defaults) throws IOException { checkArgument(Files.exists(path), "Specified site file [%s] does not exist", path); ImmutableList.Builder<Site> listBuilder = ImmutableList.builder(); Builder siteBuilder = Site.builder(defaults); List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8); boolean firstline = true; List<String> keyList = new ArrayList<>(); for (String line : lines) { /* Skip comments */ if (line.startsWith("#")) { continue; } if (line.trim().isEmpty()) { continue; } List<String> values = Parsing.splitToList(line, Delimiter.COMMA); /* Set up key/column ordering */ if (firstline) { for (String key : values) { checkState(Site.KEYS.contains(key), "Illegal site property key [%s]", key); keyList.add(key); } checkState(keyList.contains(Site.Key.LAT), "Site latitudes must be defined"); checkState(keyList.contains(Site.Key.LON), "Site longitudes must be defined"); firstline = false; continue; } int index = 0; double lat = 0.0; double lon = 0.0; for (String key : keyList) { String value = values.get(index); switch (key) { case Site.Key.LAT: lat = Double.parseDouble(value); break; case Site.Key.LON: lon = Double.parseDouble(value); break; case Site.Key.NAME: siteBuilder.name(value); break; case Site.Key.VS30: siteBuilder.vs30(Double.parseDouble(value)); break; case Site.Key.VS_INF: siteBuilder.vsInferred(Boolean.parseBoolean(value)); break; case Site.Key.Z1P0: siteBuilder.z1p0(value.equals(NULL) ? Double.NaN : Double.parseDouble(value)); break; case Site.Key.Z2P5: siteBuilder.z2p5(value.equals(NULL) ? Double.NaN : Double.parseDouble(value)); break; default: throw new IllegalStateException("Unsupported site key: " + key); } index++; } siteBuilder.location(lat, lon); listBuilder.add(siteBuilder.build()); } return new ListIterable(listBuilder.build()); } /** * Create an unmodifiable {@code Iterable<Site>} from the GeoJSON site file * designated by {@code path}. * * @param path to GeoJson site data file * @throws IOException if a problem is encountered */ public static Sites fromJson(Path path, CalcConfig defaults) throws IOException { checkArgument(Files.exists(path), "Specified site file [%s] does not exist", path); Gson gson = new GsonBuilder() .registerTypeAdapter(Site.class, new Site.Deserializer(defaults)) .registerTypeAdapter(Sites.class, new Deserializer(defaults)) .create(); Reader reader = Files.newBufferedReader(path, UTF_8); Sites iterable = gson.fromJson(reader, Sites.class); reader.close(); return iterable; } /** * Create an unmodifiable singleton {@code Iterable<Site>} from the supplied * string. String is expected to have the form: * * <p>{@code "name,lon,lat[,vs30,vsInf[,z1p0,z2p5]]"} * * <p>Although the latter 4 fields are optional, if {@code vs30} is defined, * so too must {@code vsInf}. Likewise, if {@code z1p0} is defined, so too * must {@code z2p5}. * * @param s String to parse */ public static Sites fromString(String s, CalcConfig defaults) { List<String> values = splitToList(s, Delimiter.COMMA); Builder b = Site.builder(defaults); checkArgument( values.size() > 2, NEWLINE + " Site string \"%s\" is incomplete; it must contain at least 'name, lon, lat'", s); b.name(values.get(0)).location( Double.valueOf(values.get(2)), Double.valueOf(values.get(1))); if (values.size() > 3) { checkArgument( values.size() > 4, NEWLINE + " Site string \"%s\" defines 'vs30'; it must also define 'vsInf'", s); b.vs30(Double.valueOf(values.get(3))); b.vsInferred(Boolean.valueOf(values.get(4))); } if (values.size() > 5) { checkArgument( values.size() > 6, NEWLINE + " Site string \"%s\" defines 'z1p0'; it must also define 'z2p5'", s); String z1p0str = values.get(5); b.z1p0(z1p0str.equals(NULL) ? Double.NaN : Double.valueOf(z1p0str)); String z2p5str = values.get(6); b.z2p5(z2p5str.equals(NULL) ? Double.NaN : Double.valueOf(z2p5str)); } return new ListIterable(ImmutableList.of(b.build())); } private static final int TO_STRING_LIMIT = 5; private static final String SITE_INDENT = LOG_INDENT + " "; @Override public String toString() { boolean map = this instanceof RegionIterable; StringBuilder sb = new StringBuilder(); if (map) { RegionIterable mapIterable = (RegionIterable) this; sb.append(mapIterable.region.name()) .append(" Region [size=") .append(size()) .append(" ").append(mapIterable.siteBuilder.state()) .append("]"); } else { sb.append("List") .append(" [size=") .append(size()) .append("]"); for (Site site : Iterables.limit(this, TO_STRING_LIMIT)) { sb.append(SITE_INDENT).append(site); } if (size() > TO_STRING_LIMIT) { int delta = size() - TO_STRING_LIMIT; sb.append(SITE_INDENT).append("... and ").append(delta).append(" more ..."); } } return sb.toString(); } /** * The number of {@code Site}s {@code this} contains. */ public abstract int size(); /** * An optional {@code Bounds} that is used to specify rectangular map extents, * which may differ from the range spanned by the sites in this. Presently * only containers initialized with GeoJSON that includes an 'Extents' polygon * will return an {@code Optional} where {@link Optional#isPresent()} * {@code == true}. */ public abstract Optional<Bounds> mapBounds(); /** * An optional spacing value that reflects the spacing of sites within an * attendant {@link #mapBounds()}. */ public abstract Optional<Double> mapSpacing(); private static final class ListIterable extends Sites { final List<Site> delegate; ListIterable(List<Site> delegate) { this.delegate = delegate; } @Override public Iterator<Site> iterator() { return delegate.iterator(); } @Override public int size() { return delegate.size(); } @Override public Optional<Bounds> mapBounds() { return Optional.absent(); } @Override public Optional<Double> mapSpacing() { return Optional.absent(); } } private static final class RegionIterable extends Sites { final GriddedRegion region; final Builder siteBuilder; final Optional<Bounds> bounds; RegionIterable( GriddedRegion region, Builder siteBuilder, Optional<Bounds> bounds) { this.region = region; this.siteBuilder = siteBuilder; this.bounds = bounds; } @Override public Iterator<Site> iterator() { return new Iterator<Site>() { final Iterator<Location> locations = region.iterator(); @Override public boolean hasNext() { return locations.hasNext(); } @Override public Site next() { siteBuilder.location(locations.next()); return siteBuilder.build(); } @Override public void remove() { throw new UnsupportedOperationException(); } }; } @Override public int size() { return region.size(); } @Override public Optional<Bounds> mapBounds() { return bounds; } @Override public Optional<Double> mapSpacing() { return Optional.of(region.latSpacing()); } } /* * Site GeoJSON is currently deserialized with the expectation that the * feature array will be strictly of type:Point or type:Polygon. Polygon * feature arrays are assumed to have only 1 or 2 elements. In the event that * there are 2, the first one is expected to have id:CLIP, define four * corners, and be rectangular (in a mercator projection) with edges parallel * to lines of latitude and longitude. Polygon holes, if present are not * processed. Results are undefined for self-intersecting polygon coordinate * arrays. */ private static final class Deserializer implements JsonDeserializer<Sites> { final CalcConfig defaults; Deserializer(CalcConfig defaults) { this.defaults = defaults; } @Override public Sites deserialize( JsonElement json, Type type, JsonDeserializationContext context) { // should always have a features array JsonArray features = json.getAsJsonObject() .get(GeoJson.Key.FEATURES) .getAsJsonArray(); checkState(features.size() > 0, "Feature array is empty"); // check if we have a site list String featureType = features.get(0).getAsJsonObject() .get(GeoJson.Key.GEOMETRY).getAsJsonObject() .get(GeoJson.Key.TYPE).getAsString(); if (featureType.equals(GeoJson.Value.POINT)) { Type siteType = new TypeToken<List<Site>>() {}.getType(); List<Site> sites = context.deserialize(features, siteType); return new ListIterable(ImmutableList.copyOf(sites)); } // or a region checkState(features.size() <= 2, "Only 2 polygon features may be defined"); // Optional<Region> extents = Optional.absent(); Optional<Bounds> mapBounds = Optional.absent(); String boundsName = ""; int calcPolyIndex = 0; if (features.size() == 2) { calcPolyIndex++; JsonObject extentsFeature = features.get(0).getAsJsonObject(); validateProperty(extentsFeature, GeoJson.Key.ID, GeoJson.Value.EXTENTS); mapBounds = Optional.of(validateExtents(readPolygon(extentsFeature)).bounds()); JsonObject properties = extentsFeature.getAsJsonObject(GeoJson.Key.PROPERTIES); boundsName = readName(properties, "Map Extents"); } JsonObject sitesFeature = features.get(calcPolyIndex).getAsJsonObject(); LocationList border = readPolygon(sitesFeature); JsonObject properties = sitesFeature.getAsJsonObject(GeoJson.Key.PROPERTIES); String mapName = readName(properties, "Unnamed Map"); /* * We special case a 5-coordinate border that defines a mercator recangle * so as to create a region that includes sites on the north and east * borders. */ Region calcRegion = null; try { Bounds b = validateExtents(border).bounds(); calcRegion = Regions.createRectangular(mapName, b.min(), b.max()); } catch (IllegalArgumentException iae) { calcRegion = Regions.create(mapName, border, MERCATOR_LINEAR); } checkState( properties.has(GeoJson.Properties.Key.SPACING), "A \"spacing\" : value (in degrees) must be defined in \"properties\""); double spacing = properties.get(GeoJson.Properties.Key.SPACING).getAsDouble(); // builder used to create all sites when iterating over region Builder builder = Site.builder(defaults); if (properties.has(Site.Key.VS30)) { double vs30 = properties.get(Site.Key.VS30).getAsDouble(); builder.vs30(vs30); } if (properties.has(Site.Key.VS_INF)) { boolean vsInf = properties.get(Site.Key.VS_INF).getAsBoolean(); builder.vsInferred(vsInf); } if (properties.has(Site.Key.Z1P0)) { double z1p0 = properties.get(Site.Key.Z1P0).getAsDouble(); builder.z1p0(z1p0); } if (properties.has(Site.Key.Z2P5)) { double z2p5 = properties.get(Site.Key.Z2P5).getAsDouble(); builder.z2p5(z2p5); } Region mapRegion = calcRegion; if (mapBounds.isPresent()) { Bounds b = mapBounds.get(); Region r = Regions.createRectangular(boundsName, b.min(), b.max()); mapRegion = Regions.intersectionOf(mapName, r, calcRegion); } GriddedRegion region = Regions.toGridded( mapRegion, spacing, spacing, GriddedRegion.ANCHOR_0_0); return new RegionIterable(region, builder, mapBounds); } } private static LocationList readPolygon(JsonObject feature) { JsonObject geometry = feature.getAsJsonObject(GeoJson.Key.GEOMETRY); validateProperty(geometry, GeoJson.Key.TYPE, GeoJson.Value.POLYGON); JsonArray coords = geometry.getAsJsonArray(GeoJson.Key.COORDINATES); LocationList border = GeoJson.fromCoordinates(coords); checkArgument( border.size() > 2, "A GeoJSON polygon must have at least 3 coordinates:%s", border); checkArgument( border.first().equals(border.last()), "The first and last points in a GeoJSON polygon must be the same:%s", border); return border; } private static String readName(JsonObject properties, String defaultName) { return properties.has(GeoJson.Properties.Key.TITLE) ? properties.get(GeoJson.Properties.Key.TITLE).getAsString() : defaultName; } private static LocationList validateExtents(LocationList locs) { checkArgument(locs.size() == 5, "Extents polygon must contain 5 coordinates:%s", locs); Location p1 = locs.get(0); Location p2 = locs.get(1); Location p3 = locs.get(2); Location p4 = locs.get(3); boolean rectangular = (p1.latRad() == p2.latRad()) ? (p3.latRad() == p4.latRad() && p1.lonRad() == p4.lonRad() && p2.lonRad() == p3.lonRad()) : (p1.latRad() == p4.latRad() && p2.latRad() == p3.latRad() && p1.lonRad() == p2.lonRad() && p3.lonRad() == p4.lonRad()); checkArgument(rectangular, "Extents polygon does not define a lat-lon Mercator rectangle:%s", locs); return locs; } }