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);
}
}