package org.apache.solr.schema; /** * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import org.apache.lucene.document.Field; import org.apache.lucene.document.Fieldable; import org.apache.lucene.index.FieldInfo.IndexOptions; import org.apache.lucene.index.IndexReader; import org.apache.lucene.search.*; import org.apache.lucene.spatial.DistanceUtils; import org.apache.lucene.spatial.tier.InvalidGeoException; import org.apache.solr.common.SolrException; import org.apache.solr.response.TextResponseWriter; import org.apache.solr.response.XMLWriter; import org.apache.solr.search.QParser; import org.apache.solr.search.SolrIndexReader; import org.apache.solr.search.SpatialOptions; import org.apache.solr.search.function.DocValues; import org.apache.solr.search.function.ValueSource; import org.apache.solr.search.function.VectorValueSource; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Set; /** * Represents a Latitude/Longitude as a 2 dimensional point. Latitude is <b>always</b> specified first. */ public class LatLonType extends AbstractSubTypeFieldType implements SpatialQueryable { protected static final int LAT = 0; protected static final int LONG = 1; @Override protected void init(IndexSchema schema, Map<String, String> args) { super.init(schema, args); //TODO: refactor this, as we are creating the suffix cache twice, since the super.init does it too createSuffixCache(3);//we need three extra fields: one for the storage field, two for the lat/lon } @Override public Fieldable[] createFields(SchemaField field, String externalVal, float boost) { //we could have tileDiff + 3 fields (two for the lat/lon, one for storage) Fieldable[] f = new Fieldable[(field.indexed() ? 2 : 0) + (field.stored() ? 1 : 0)]; if (field.indexed()) { int i = 0; double[] latLon = new double[0]; try { latLon = DistanceUtils.parseLatitudeLongitude(null, externalVal); } catch (InvalidGeoException e) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); } //latitude f[i] = subField(field, i).createField(String.valueOf(latLon[LAT]), boost); i++; //longitude f[i] = subField(field, i).createField(String.valueOf(latLon[LONG]), boost); } if (field.stored()) { f[f.length - 1] = createField(field.getName(), externalVal, getFieldStore(field, externalVal), Field.Index.NO, Field.TermVector.NO, false, IndexOptions.DOCS_AND_FREQS_AND_POSITIONS, boost); } return f; } @Override public Query getRangeQuery(QParser parser, SchemaField field, String part1, String part2, boolean minInclusive, boolean maxInclusive) { int dimension = 2; String[] p1; String[] p2; try { p1 = DistanceUtils.parsePoint(null, part1, dimension); p2 = DistanceUtils.parsePoint(null, part2, dimension); } catch (InvalidGeoException e) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); } BooleanQuery result = new BooleanQuery(true); for (int i = 0; i < dimension; i++) { SchemaField subSF = subField(field, i); // points must currently be ordered... should we support specifying any two opposite corner points? result.add(subSF.getType().getRangeQuery(parser, subSF, p1[i], p2[i], minInclusive, maxInclusive), BooleanClause.Occur.MUST); } return result; } @Override public Query getFieldQuery(QParser parser, SchemaField field, String externalVal) { int dimension = 2; String[] p1 = new String[0]; try { p1 = DistanceUtils.parsePoint(null, externalVal, dimension); } catch (InvalidGeoException e) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); } BooleanQuery bq = new BooleanQuery(true); for (int i = 0; i < dimension; i++) { SchemaField sf = subField(field, i); Query tq = sf.getType().getFieldQuery(parser, sf, p1[i]); bq.add(tq, BooleanClause.Occur.MUST); } return bq; } public Query createSpatialQuery(QParser parser, SpatialOptions options) { double[] point = null; try { point = DistanceUtils.parseLatitudeLongitude(options.pointStr); } catch (InvalidGeoException e) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); } // lat & lon in degrees double latCenter = point[LAT]; double lonCenter = point[LONG]; point[0] = point[0] * DistanceUtils.DEGREES_TO_RADIANS; point[1] = point[1] * DistanceUtils.DEGREES_TO_RADIANS; //Get the distance double[] tmp = new double[2]; //these calculations aren't totally accurate, but it should be good enough //TODO: Optimize to do in single calculations. Would need to deal with poles, prime meridian, etc. double [] north = DistanceUtils.pointOnBearing(point[LAT], point[LONG], options.distance, 0, tmp, options.radius); //This returns the point as radians, but we need degrees b/c that is what the field is stored as double ur_lat = north[LAT] * DistanceUtils.RADIANS_TO_DEGREES;//get it now, as we are going to reuse tmp double [] east = DistanceUtils.pointOnBearing(point[LAT], point[LONG], options.distance, DistanceUtils.DEG_90_AS_RADS, tmp, options.radius); double ur_lon = east[LONG] * DistanceUtils.RADIANS_TO_DEGREES; double [] south = DistanceUtils.pointOnBearing(point[LAT], point[LONG], options.distance, DistanceUtils.DEG_180_AS_RADS, tmp, options.radius); double ll_lat = south[LAT] * DistanceUtils.RADIANS_TO_DEGREES; double [] west = DistanceUtils.pointOnBearing(point[LAT], point[LONG], options.distance, DistanceUtils.DEG_270_AS_RADS, tmp, options.radius); double ll_lon = west[LONG] * DistanceUtils.RADIANS_TO_DEGREES; //TODO: can we reuse our bearing calculations? double angDist = DistanceUtils.angularDistance(options.distance, options.radius);//in radians double latMin = -90.0, latMax = 90.0, lonMin = -180.0, lonMax = 180.0; double lon2Min = -180.0, lon2Max = 180.0; // optional second longitude restriction // for the poles, do something slightly different - a polar "cap". // Also, note point[LAT] is in radians, but ur and ll are in degrees if (point[LAT] + angDist > DistanceUtils.DEG_90_AS_RADS) { // we cross the north pole //we don't need a longitude boundary at all latMin = Math.min(ll_lat, ur_lat); } else if (point[LAT] - angDist < -DistanceUtils.DEG_90_AS_RADS) { // we cross the south pole latMax = Math.max(ll_lat, ur_lat); } else { // set the latitude restriction as normal latMin = ll_lat; latMax = ur_lat; if (ll_lon > ur_lon) { // we crossed the +-180 deg longitude... need to make // range queries of (-180 TO ur) OR (ll TO 180) lonMin = -180; lonMax = ur_lon; lon2Min = ll_lon; lon2Max = 180; } else { lonMin = ll_lon; lonMax = ur_lon; } } // Now that we've figured out the ranges, build them! SchemaField latField = subField(options.field, LAT); SchemaField lonField = subField(options.field, LONG); if (options.bbox) { BooleanQuery result = new BooleanQuery(); // only used if box==true Query latRange = latField.getType().getRangeQuery(parser, latField, String.valueOf(latMin), String.valueOf(latMax), true, true); result.add(latRange, BooleanClause.Occur.MUST); if (lonMin != -180 || lonMax != 180) { Query lonRange = lonField.getType().getRangeQuery(parser, lonField, String.valueOf(lonMin), String.valueOf(lonMax), true, true); if (lon2Min != -180 || lon2Max != 180) { // another valid longitude range BooleanQuery bothLons = new BooleanQuery(); bothLons.add(lonRange, BooleanClause.Occur.SHOULD); lonRange = lonField.getType().getRangeQuery(parser, lonField, String.valueOf(lon2Min), String.valueOf(lon2Max), true, true); bothLons.add(lonRange, BooleanClause.Occur.SHOULD); lonRange = bothLons; } result.add(lonRange, BooleanClause.Occur.MUST); } return result; } SpatialDistanceQuery spatial = new SpatialDistanceQuery(); spatial.origField = options.field.getName(); spatial.latSource = latField.getType().getValueSource(latField, parser); spatial.lonSource = lonField.getType().getValueSource(lonField, parser); spatial.latMin = latMin; spatial.latMax = latMax; spatial.lonMin = lonMin; spatial.lonMax = lonMax; spatial.lon2Min = lon2Min; spatial.lon2Max = lon2Max; spatial.lon2 = lon2Min != -180 || lon2Max != 180; spatial.latCenter = latCenter; spatial.lonCenter = lonCenter; spatial.dist = options.distance; spatial.planetRadius = options.radius; spatial.calcDist = !options.bbox; return spatial; } @Override public ValueSource getValueSource(SchemaField field, QParser parser) { ArrayList<ValueSource> vs = new ArrayList<ValueSource>(2); for (int i = 0; i < 2; i++) { SchemaField sub = subField(field, i); vs.add(sub.getType().getValueSource(sub, parser)); } return new LatLonValueSource(field, vs); } @Override public boolean isPolyField() { return true; } @Override public void write(XMLWriter xmlWriter, String name, Fieldable f) throws IOException { xmlWriter.writeStr(name, f.stringValue()); } @Override public void write(TextResponseWriter writer, String name, Fieldable f) throws IOException { writer.writeStr(name, f.stringValue(), false); } @Override public SortField getSortField(SchemaField field, boolean top) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Sorting not supported on SpatialTileField " + field.getName()); } //It never makes sense to create a single field, so make it impossible to happen @Override public Fieldable createField(SchemaField field, String externalVal, float boost) { throw new UnsupportedOperationException("SpatialTileField uses multiple fields. field=" + field.getName()); } } class LatLonValueSource extends VectorValueSource { private final SchemaField sf; public LatLonValueSource(SchemaField sf, List<ValueSource> sources) { super(sources); this.sf = sf; } @Override public String name() { return "latlon"; } @Override public String description() { return name() + "(" + sf.getName() + ")"; } } //////////////////////////////////////////////////////////////////////////////////////////// // TODO: recast as a value source that doesn't have to match all docs class SpatialDistanceQuery extends Query { String origField; ValueSource latSource; ValueSource lonSource; double lonMin, lonMax, lon2Min, lon2Max, latMin, latMax; boolean lon2; boolean calcDist; // actually calculate the distance with haversine double latCenter; double lonCenter; double dist; double planetRadius; @Override public Query rewrite(IndexReader reader) throws IOException { return this; } @Override public void extractTerms(Set terms) {} protected class SpatialWeight extends Weight { protected Searcher searcher; protected float queryNorm; protected float queryWeight; protected Map latContext; protected Map lonContext; public SpatialWeight(Searcher searcher) throws IOException { this.searcher = searcher; this.latContext = latSource.newContext(); this.lonContext = lonSource.newContext(); latSource.createWeight(latContext, searcher); lonSource.createWeight(lonContext, searcher); } @Override public Query getQuery() { return SpatialDistanceQuery.this; } @Override public float getValue() { return queryWeight; } @Override public float sumOfSquaredWeights() throws IOException { queryWeight = getBoost(); return queryWeight * queryWeight; } @Override public void normalize(float norm) { this.queryNorm = norm; queryWeight *= this.queryNorm; } @Override public Scorer scorer(IndexReader reader, boolean scoreDocsInOrder, boolean topScorer) throws IOException { return new SpatialScorer(getSimilarity(searcher), reader, this); } @Override public Explanation explain(IndexReader reader, int doc) throws IOException { SolrIndexReader topReader = (SolrIndexReader)reader; SolrIndexReader[] subReaders = topReader.getLeafReaders(); int[] offsets = topReader.getLeafOffsets(); int readerPos = SolrIndexReader.readerIndex(doc, offsets); int readerBase = offsets[readerPos]; return ((SpatialScorer)scorer(subReaders[readerPos], true, true)).explain(doc-readerBase); } } protected class SpatialScorer extends Scorer { final IndexReader reader; final SpatialWeight weight; final int maxDoc; final float qWeight; int doc=-1; final DocValues latVals; final DocValues lonVals; final double lonMin, lonMax, lon2Min, lon2Max, latMin, latMax; final boolean lon2; final boolean calcDist; final double latCenterRad; final double lonCenterRad; final double latCenterRad_cos; final double dist; final double planetRadius; int lastDistDoc; double lastDist; public SpatialScorer(Similarity similarity, IndexReader reader, SpatialWeight w) throws IOException { super(similarity, w); this.weight = w; this.qWeight = w.getValue(); this.reader = reader; this.maxDoc = reader.maxDoc(); latVals = latSource.getValues(weight.latContext, reader); lonVals = lonSource.getValues(weight.lonContext, reader); this.lonMin = SpatialDistanceQuery.this.lonMin; this.lonMax = SpatialDistanceQuery.this.lonMax; this.lon2Min = SpatialDistanceQuery.this.lon2Min; this.lon2Max = SpatialDistanceQuery.this.lon2Max; this.latMin = SpatialDistanceQuery.this.latMin; this.latMax = SpatialDistanceQuery.this.latMax; this.lon2 = SpatialDistanceQuery.this.lon2; this.calcDist = SpatialDistanceQuery.this.calcDist; this.latCenterRad = SpatialDistanceQuery.this.latCenter * DistanceUtils.DEGREES_TO_RADIANS; this.lonCenterRad = SpatialDistanceQuery.this.lonCenter * DistanceUtils.DEGREES_TO_RADIANS; this.latCenterRad_cos = this.calcDist ? Math.cos(latCenterRad) : 0; this.dist = SpatialDistanceQuery.this.dist; this.planetRadius = SpatialDistanceQuery.this.planetRadius; } boolean match() { // longitude should generally be more restrictive than latitude // (e.g. in the US, it immediately separates the coasts, and in world search separates // US from Europe from Asia, etc. double lon = lonVals.doubleVal(doc); if (! ((lon >= lonMin && lon <=lonMax) || (lon2 && lon >= lon2Min && lon <= lon2Max)) ) { return false; } double lat = latVals.doubleVal(doc); if ( !(lat >= latMin && lat <= latMax) ) { return false; } if (!calcDist) return true; // TODO: test for internal box where we wouldn't need to calculate the distance return dist(lat, lon) <= dist; } double dist(double lat, double lon) { double latRad = lat * DistanceUtils.DEGREES_TO_RADIANS; double lonRad = lon * DistanceUtils.DEGREES_TO_RADIANS; // haversine, specialized to avoid a cos() call on latCenterRad double diffX = latCenterRad - latRad; double diffY = lonCenterRad - lonRad; double hsinX = Math.sin(diffX * 0.5); double hsinY = Math.sin(diffY * 0.5); double h = hsinX * hsinX + (latCenterRad_cos * Math.cos(latRad) * hsinY * hsinY); double result = (planetRadius * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h))); // save the results of this calculation lastDistDoc = doc; lastDist = result; return result; } @Override public int docID() { return doc; } // instead of matching all docs, we could also embed a query. // the score could either ignore the subscore, or boost it. // Containment: floatline(foo:myTerm, "myFloatField", 1.0, 0.0f) // Boost: foo:myTerm^floatline("myFloatField",1.0,0.0f) @Override public int nextDoc() throws IOException { for(;;) { ++doc; if (doc>=maxDoc) { return doc=NO_MORE_DOCS; } //TODO: In trunk, there is a faster way of doing this, what about here? if (reader.isDeleted(doc)/*delDocs != null && delDocs.get(doc)*/) continue; if (!match()) continue; return doc; } } @Override public int advance(int target) throws IOException { // this will work even if target==NO_MORE_DOCS doc=target-1; return nextDoc(); } @Override public float score() throws IOException { double dist = (doc == lastDistDoc) ? lastDist : dist(latVals.doubleVal(doc), lonVals.doubleVal(doc)); return (float)(dist * qWeight); } public Explanation explain(int doc) throws IOException { advance(doc); boolean matched = this.doc == doc; this.doc = doc; float sc = matched ? score() : 0; double dist = dist(latVals.doubleVal(doc), lonVals.doubleVal(doc)); String description = SpatialDistanceQuery.this.toString(); Explanation result = new ComplexExplanation (this.doc == doc, sc, description + " product of:"); // result.addDetail(new Explanation((float)dist, "hsin("+latVals.explain(doc)+","+lonVals.explain(doc))); result.addDetail(new Explanation((float)dist, "hsin("+latVals.doubleVal(doc)+","+lonVals.doubleVal(doc))); result.addDetail(new Explanation(getBoost(), "boost")); result.addDetail(new Explanation(weight.queryNorm,"queryNorm")); return result; } } @Override public Weight createWeight(Searcher searcher) throws IOException { return new SpatialWeight(searcher); } /** Prints a user-readable version of this query. */ @Override public String toString(String field) { float boost = getBoost(); return (boost!=1.0?"(":"") + "geofilt(latlonSource="+origField +"(" + latSource + "," + lonSource + ")" +",latCenter="+latCenter+",lonCenter="+lonCenter +",dist=" + dist +",latMin=" + latMin + ",latMax="+latMax +",lonMin=" + lonMin + ",lonMax"+lonMax +",lon2Min=" + lon2Min + ",lon2Max" + lon2Max +",calcDist="+calcDist +",planetRadius="+planetRadius +")" + (boost==1.0 ? "" : ")^"+boost); } /** Returns true if <code>o</code> is equal to this. */ @Override public boolean equals(Object o) { if (!super.equals(o)) return false; SpatialDistanceQuery other = (SpatialDistanceQuery)o; return this.latCenter == other.latCenter && this.lonCenter == other.lonCenter && this.latMin == other.latMin && this.latMax == other.latMax && this.lonMin == other.lonMin && this.lonMax == other.lonMax && this.lon2Min == other.lon2Min && this.lon2Max == other.lon2Max && this.dist == other.dist && this.planetRadius == other.planetRadius && this.calcDist == other.calcDist && this.lonSource.equals(other.lonSource) && this.latSource.equals(other.latSource) && this.getBoost() == other.getBoost() ; } /** Returns a hash code value for this object. */ @Override public int hashCode() { // don't bother making the hash expensive - the center latitude + min longitude will be very uinque long hash = Double.doubleToLongBits(latCenter); hash = hash * 31 + Double.doubleToLongBits(lonMin); return (int)(hash >> 32 + hash); } }