Mercurial > gemma
changeset 5259:680be197844d
Merged branch new-fwa.
author | Sascha Wilde <wilde@intevation.de> |
---|---|
date | Wed, 13 May 2020 11:28:34 +0200 |
parents | 1b5c80ea5582 (current diff) 256ebbeb1252 (diff) |
children | 771984cb74d8 |
files | pkg/controllers/bottlenecks.go pkg/controllers/stretches.go |
diffstat | 18 files changed, 1137 insertions(+), 1556 deletions(-) [+] |
line wrap: on
line diff
--- a/client/src/components/TimeSlider.vue Tue May 12 16:52:08 2020 +0200 +++ b/client/src/components/TimeSlider.vue Wed May 13 11:28:34 2020 +0200 @@ -257,7 +257,10 @@ zoom = d3 .zoom() .scaleExtent([0.8, 102000]) - .translateExtent([[0, 0], [svgWidth, svgHeight]]) + .translateExtent([ + [0, 0], + [svgWidth, svgHeight] + ]) .extent([[0, 0], [(svgWidth, svgHeight)]]) .on("zoom", this.zoomed);
--- a/client/src/components/fairway/Fairwayprofile.vue Tue May 12 16:52:08 2020 +0200 +++ b/client/src/components/fairway/Fairwayprofile.vue Wed May 13 11:28:34 2020 +0200 @@ -791,7 +791,10 @@ }); graph .append("path") - .datum([{ x: 0, y: 0 }, { x: this.totalLength, y: 0 }]) + .datum([ + { x: 0, y: 0 }, + { x: this.totalLength, y: 0 } + ]) .attr("fill-opacity", 0.65) .attr("fill", WATER_COLOR) .attr("stroke", "transparent")
--- a/client/src/components/gauge/HydrologicalConditions.vue Tue May 12 16:52:08 2020 +0200 +++ b/client/src/components/gauge/HydrologicalConditions.vue Wed May 13 11:28:34 2020 +0200 @@ -450,9 +450,7 @@ .attr("class", "main") .attr( "transform", - `translate(${dimensions.mainMargin.left}, ${ - dimensions.mainMargin.top - })` + `translate(${dimensions.mainMargin.left}, ${dimensions.mainMargin.top})` ); // create container for navigation diagram @@ -519,9 +517,7 @@ .attr("height", dimensions.mainHeight) .attr( "transform", - `translate(${dimensions.mainMargin.left}, ${ - dimensions.mainMargin.top - })` + `translate(${dimensions.mainMargin.left}, ${dimensions.mainMargin.top})` ); this.createZoom({ @@ -890,13 +886,22 @@ const brush = d3 .brushX() .handleSize(4) - .extent([[0, 0], [dimensions.width, dimensions.navHeight]]); + .extent([ + [0, 0], + [dimensions.width, dimensions.navHeight] + ]); const zoom = d3 .zoom() .scaleExtent([1, Infinity]) - .translateExtent([[0, 0], [dimensions.width, dimensions.mainHeight]]) - .extent([[0, 0], [dimensions.width, dimensions.mainHeight]]); + .translateExtent([ + [0, 0], + [dimensions.width, dimensions.mainHeight] + ]) + .extent([ + [0, 0], + [dimensions.width, dimensions.mainHeight] + ]); brush.on("brush end", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom")
--- a/client/src/components/gauge/Waterlevel.vue Tue May 12 16:52:08 2020 +0200 +++ b/client/src/components/gauge/Waterlevel.vue Wed May 13 11:28:34 2020 +0200 @@ -465,9 +465,7 @@ .attr("class", "main") .attr( "transform", - `translate(${dimensions.mainMargin.left}, ${ - dimensions.mainMargin.top - })` + `translate(${dimensions.mainMargin.left}, ${dimensions.mainMargin.top})` ); // create container for navigation diagram @@ -531,9 +529,7 @@ .attr("height", dimensions.mainHeight) .attr( "transform", - `translate(${dimensions.mainMargin.left}, ${ - dimensions.mainMargin.top - })` + `translate(${dimensions.mainMargin.left}, ${dimensions.mainMargin.top})` ); this.createZoom({ @@ -851,7 +847,10 @@ // draw in nav navigation .append("path") - .datum([{ x: new Date(), y: hi + dy }, { x: new Date(), y: lo - dy }]) + .datum([ + { x: new Date(), y: hi + dy }, + { x: new Date(), y: lo - dy } + ]) .attr("class", "now-line") .attr( "d", @@ -1033,13 +1032,22 @@ const brush = d3 .brushX() .handleSize(4) - .extent([[0, 0], [dimensions.width, dimensions.navHeight]]); + .extent([ + [0, 0], + [dimensions.width, dimensions.navHeight] + ]); const zoom = d3 .zoom() .scaleExtent([1, Infinity]) - .translateExtent([[0, 0], [dimensions.width, dimensions.mainHeight]]) - .extent([[0, 0], [dimensions.width, dimensions.mainHeight]]); + .translateExtent([ + [0, 0], + [dimensions.width, dimensions.mainHeight] + ]) + .extent([ + [0, 0], + [dimensions.width, dimensions.mainHeight] + ]); brush.on("brush end", () => { if (d3.event.sourceEvent && d3.event.sourceEvent.type === "zoom")
--- a/client/src/components/layers/LegendElement.vue Tue May 12 16:52:08 2020 +0200 +++ b/client/src/components/layers/LegendElement.vue Wed May 13 11:28:34 2020 +0200 @@ -174,7 +174,12 @@ if (this.layer.get("id") === "BOTTLENECKS") { feature = new Feature({ geometry: new Polygon([ - [[-1.7, -1.2], [-1.7, 0.5], [1.7, 1.2], [1.7, -0.5]] + [ + [-1.7, -1.2], + [-1.7, 0.5], + [1.7, 1.2], + [1.7, -0.5] + ] ]) }); if (typeof mapStyle === "function") { @@ -192,7 +197,11 @@ }); } else { feature = new Feature({ - geometry: new LineString([[-1, -1], [0, 0], [1, 1]]) + geometry: new LineString([ + [-1, -1], + [0, 0], + [1, 1] + ]) }); } // special case if we need to call the style function with a special
--- a/client/src/store/fairwayavailability.js Tue May 12 16:52:08 2020 +0200 +++ b/client/src/store/fairwayavailability.js Wed May 13 11:28:34 2020 +0200 @@ -14,17 +14,18 @@ /*eslint no-unused-vars: ["error", { "varsIgnorePattern": "_" }]*/ -import { HTTP } from "@/lib/http"; import { + endOfMonth, + endOfQuarter, + endOfYear, format, startOfMonth, - endOfMonth, - startOfYear, - endOfYear, startOfQuarter, - endOfQuarter + startOfYear } from "date-fns"; +import { HTTP } from "@/lib/http"; + const LIMITINGFACTORS = { WIDTH: "width", DEPTH: "depth" @@ -259,9 +260,16 @@ type } = options; let { from, to } = options; - let name = feature.hasOwnProperty("properties") - ? feature.properties.name - : feature.get("objnam"); + let name = ""; + if (type === TYPES.BOTTLENECK) { + name = feature.hasOwnProperty("properties") + ? feature.properties.bottleneck_id + : feature.get("bottleneck_id"); + } else { + name = feature.hasOwnProperty("properties") + ? feature.properties.name + : feature.get("objnam"); + } [from, to] = getIntervallBorders(from, to, frequency); let additionalParams = ""; let endpoint = type; @@ -275,7 +283,7 @@ } const start = encodeURIComponent("00:00:00+00:00"); const end = encodeURIComponent("23:59:59+00:00"); - const URL = `data/${endpoint}/fairway-depth/${encodeURIComponent( + const URL = `data/fairway/${endpoint}/${encodeURIComponent( name )}?from=${from}T${start}&to=${to}T${end}&mode=${frequency}&los=${LOS}${additionalParams}`; HTTP.get(URL, { @@ -304,9 +312,16 @@ type } = options; let { from, to } = options; - let name = feature.hasOwnProperty("properties") - ? feature.properties.name - : feature.get("objnam"); + let name = ""; + if (type === TYPES.BOTTLENECK) { + name = feature.hasOwnProperty("properties") + ? feature.properties.bottleneck_id + : feature.get("bottleneck_id"); + } else { + name = feature.hasOwnProperty("properties") + ? feature.properties.name + : feature.get("objnam"); + } [from, to] = getIntervallBorders(from, to, frequency); const start = encodeURIComponent("00:00:00+00:00"); const end = encodeURIComponent("23:59:59+00:00"); @@ -320,7 +335,7 @@ } else if (type == TYPES.SECTION || type == TYPES.STRETCH) { additionalParams = `&depthbreaks=${depthLimit1},${depthLimit2}&widthbreaks=${widthLimit1},${widthLimit2}`; } - const URL = `data/${endpoint}/availability/${encodeURIComponent( + const URL = `data/availability/${endpoint}/${encodeURIComponent( name )}?from=${from}T${start}&to=${to}T${end}&mode=${frequency}&los=${LOS}${additionalParams}`; HTTP.get(URL, {
--- a/client/src/store/gauges.js Tue May 12 16:52:08 2020 +0200 +++ b/client/src/store/gauges.js Wed May 13 11:28:34 2020 +0200 @@ -265,9 +265,7 @@ loadYearWaterlevels({ state, commit }) { return new Promise((resolve, reject) => { HTTP.get( - `/data/year-waterlevels/${state.selectedGaugeISRS}/${ - state.yearCompare - }`, + `/data/year-waterlevels/${state.selectedGaugeISRS}/${state.yearCompare}`, { headers: { "X-Gemma-Auth": localStorage.getItem("token") } }
--- a/go.sum Tue May 12 16:52:08 2020 +0200 +++ b/go.sum Wed May 13 11:28:34 2020 +0200 @@ -49,6 +49,7 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
--- a/pkg/common/errors.go Tue May 12 16:52:08 2020 +0200 +++ b/pkg/common/errors.go Wed May 13 11:28:34 2020 +0200 @@ -30,3 +30,15 @@ } return errors.New(b.String()) } + +// JoinErrors creates a comma separated string out of the given errors. +func JoinErrors(errors []error) string { + var b strings.Builder + for _, err := range errors { + if b.Len() > 0 { + b.WriteString(", ") + } + b.WriteString(err.Error()) + } + return b.String() +}
--- a/pkg/common/time.go Tue May 12 16:52:08 2020 +0200 +++ b/pkg/common/time.go Wed May 13 11:28:34 2020 +0200 @@ -97,3 +97,24 @@ return time.Unix(int64(secs), int64(nsecs)) } } + +func MinTime(a, b time.Time) time.Time { + if a.Before(b) { + return a + } + return b +} + +func MaxTime(a, b time.Time) time.Time { + if a.After(b) { + return a + } + return b +} + +func OrderTime(a, b time.Time) (time.Time, time.Time) { + if a.Before(b) { + return a, b + } + return b, a +}
--- a/pkg/controllers/bottlenecks.go Tue May 12 16:52:08 2020 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,794 +0,0 @@ -// This is Free Software under GNU Affero General Public License v >= 3.0 -// without warranty, see README.md and license for details. -// -// SPDX-License-Identifier: AGPL-3.0-or-later -// License-Filename: LICENSES/AGPL-3.0.txt -// -// Copyright (C) 2019 by via donau -// – Österreichische Wasserstraßen-Gesellschaft mbH -// Software engineering by Intevation GmbH -// -// Author(s): -// * Sascha L. Teichmann <sascha.teichmann@intevation.de> -// * Sascha Wilde <wilde@intevation.de> - -package controllers - -import ( - "context" - "database/sql" - "encoding/csv" - "fmt" - "log" - "net/http" - "sort" - "strconv" - "strings" - "time" - - "github.com/gorilla/mux" - - "gemma.intevation.de/gemma/pkg/common" - "gemma.intevation.de/gemma/pkg/middleware" -) - -const ( - selectLimitingSQL = ` -SELECT limiting FROM waterway.bottlenecks bn - WHERE bn.validity @> current_timestamp AND objnam = $1 -` - - selectAvailableDepthSQL = ` -WITH data AS ( - SELECT - efa.measure_date, - efa.available_depth_value, - efa.available_width_value, - efa.water_level_value - FROM waterway.effective_fairway_availability efa - JOIN waterway.fairway_availability fa - ON efa.fairway_availability_id = fa.id - JOIN waterway.bottlenecks bn - ON fa.bottleneck_id = bn.bottleneck_id - WHERE - bn.validity @> current_timestamp AND - bn.objnam = $1 AND - efa.level_of_service = $2 AND - efa.measure_type = 'Measured' AND - (efa.available_depth_value IS NOT NULL OR - efa.available_width_value IS NOT NULL) AND - efa.water_level_value IS NOT NULL -), -before AS ( - SELECT * FROM data WHERE measure_date < $3 - ORDER BY measure_date DESC LIMIT 1 -), -inside AS ( - SELECT * FROM data WHERE measure_date BETWEEN $3 AND $4 -), -after AS ( - SELECT * FROM data WHERE measure_date > $4 - ORDER BY measure_date LIMIT 1 -) -SELECT * FROM before -UNION ALL -SELECT * FROM inside -UNION ALL -SELECT * FROM after -ORDER BY measure_date -` - - selectGaugeLDCSQL = ` -SELECT - grwl.value -FROM waterway.gauges_reference_water_levels grwl - JOIN waterway.bottlenecks bns - ON grwl.location = bns.gauge_location - AND grwl.validity @> COALESCE(upper(bns.validity), current_timestamp) -WHERE lower(bns.validity) = (SELECT max(lower(validity)) - FROM waterway.bottlenecks WHERE objnam = $1) - AND bns.objnam = $1 - AND grwl.depth_reference like 'LDC%' -` -) - -type ( - availMeasurement struct { - when time.Time - depth int16 - width int16 - value int16 - } - - availMeasurements []availMeasurement -) - -// afdRefs are the typical available fairway depth reference values. -var afdRefs = []float64{ - 230, - 250, -} - -func (measurement *availMeasurement) getDepth() float64 { - return float64(measurement.depth) -} - -func (measurement *availMeasurement) getValue() float64 { - return float64(measurement.value) -} - -func (measurement *availMeasurement) getWidth() float64 { - return float64(measurement.width) -} - -func limitingFactor(limiting string) func(*availMeasurement) float64 { - switch limiting { - case "depth": - return (*availMeasurement).getDepth - case "width": - return (*availMeasurement).getWidth - default: - log.Printf("warn: unknown limitation '%s'. default to 'depth'\n", limiting) - return (*availMeasurement).getDepth - } -} - -// According to clarification, it has to be assumed, that at times -// with no data, the best case (which by convention is the highest -// class created by classify()) should be assumed. That is due to the -// fact, that at times where bottlenecks are not a limiting factor on -// the waterway, services don't provide any data for the bottleneck in -// question. -// -// FIXME: A potential improvement could be to intersect the time -// ranges with the time ranges where bottlenecks were "active" (this -// _might_ be derivable from the validity periods in the bottleneck -// data. So it _might_ be possible do detect actual missing data (BN -// valid, but no data from FA service). Anyway, this is left out for -// now, as many clarification regarding the base assumtions would be -// needed and the results still might be unrelyable. -func optimisticPadClassification( - from, to time.Time, - classified []time.Duration, -) []time.Duration { - - var actualDuration time.Duration - for _, v := range classified { - actualDuration += v - } - - // If the actual duration is smaller than the length - // of the classifaction interval extend the - // time spend in the highest class by the difference. - if delta := to.Sub(from) - actualDuration; delta > 0 { - classified[len(classified)-1] += delta - } - - return classified -} - -func (measurements availMeasurements) classify( - from, to time.Time, - breaks []float64, - access func(*availMeasurement) float64, -) []time.Duration { - - if len(breaks) == 0 { - return []time.Duration{} - } - - result := make([]time.Duration, len(breaks)+1) - classes := make([]float64, len(breaks)+2) - values := make([]time.Time, len(classes)) - - // Add sentinels - classes[0] = breaks[0] - 9999 - classes[len(classes)-1] = breaks[len(breaks)-1] + 9999 - for i := range breaks { - classes[i+1] = breaks[i] - } - - idx := sort.Search(len(measurements), func(i int) bool { - // All values before from can be ignored. - return !measurements[i].when.Before(from) - }) - - if idx >= len(measurements) { - return optimisticPadClassification(from, to, result) - } - - // Be safe for interpolation. - if idx > 0 { - idx-- - } - - measurements = measurements[idx:] - - for i := 0; i < len(measurements)-1; i++ { - p1 := &measurements[i] - p2 := &measurements[i+1] - - if p1.when.After(to) { - return optimisticPadClassification(from, to, result) - } - - if p2.when.Before(from) { - continue - } - - if p2.when.Sub(p1.when).Hours() > 1.5 { - // Don't interpolate ranges bigger then one and a half hour - continue - } - - lo, hi := maxTime(p1.when, from), minTime(p2.when, to) - - m1, m2 := access(p1), access(p2) - if m1 == m2 { // The whole interval is in only one class. - for j := 0; j < len(classes)-1; j++ { - if classes[j] <= m1 && m1 <= classes[j+1] { - result[j] += hi.Sub(lo) - break - } - } - continue - } - - f := common.InterpolateTime( - p1.when, m1, - p2.when, m2, - ) - - for j, c := range classes { - values[j] = f(c) - } - - for j := 0; j < len(values)-1; j++ { - start, end := orderTime(values[j], values[j+1]) - - if start.After(hi) || end.Before(lo) { - continue - } - - start, end = maxTime(start, lo), minTime(end, hi) - result[j] += end.Sub(start) - } - } - - return optimisticPadClassification(from, to, result) -} - -func orderTime(a, b time.Time) (time.Time, time.Time) { - if a.Before(b) { - return a, b - } - return b, a -} - -func minTime(a, b time.Time) time.Time { - if a.Before(b) { - return a - } - return b -} - -func maxTime(a, b time.Time) time.Time { - if a.After(b) { - return a - } - return b -} - -func durationsToPercentage(duration time.Duration, classes []time.Duration) []float64 { - percents := make([]float64, len(classes)) - total := 100 / duration.Seconds() - for i, v := range classes { - percents[i] = v.Seconds() * total - } - return percents -} - -func parseFormTime( - rw http.ResponseWriter, - req *http.Request, - field string, - def time.Time, -) (time.Time, bool) { - f := req.FormValue(field) - if f == "" { - return def.UTC(), true - } - v, err := common.ParseTime(f) - if err != nil { - http.Error( - rw, fmt.Sprintf("Invalid format for '%s'.", field), - http.StatusBadRequest, - ) - return time.Time{}, false - } - return v.UTC(), true -} - -func parseFormInt( - rw http.ResponseWriter, - req *http.Request, - field string, - def int, -) (int, bool) { - f := req.FormValue(field) - if f == "" { - return def, true - } - v, err := strconv.Atoi(f) - if err != nil { - http.Error( - rw, fmt.Sprintf("Invalid format for '%s'.", field), - http.StatusBadRequest, - ) - return 0, false - } - return v, true -} - -func intervalMode(mode string) int { - switch strings.ToLower(mode) { - case "monthly": - return 0 - case "quarterly": - return 1 - case "yearly": - return 2 - default: - return 0 - } -} - -func loadDepthValues( - ctx context.Context, - conn *sql.Conn, - bottleneck string, - los int, - from, to time.Time, -) (availMeasurements, error) { - - rows, err := conn.QueryContext( - ctx, selectAvailableDepthSQL, bottleneck, los, from, to) - if err != nil { - return nil, err - } - defer rows.Close() - - var ms availMeasurements - - for rows.Next() { - var m availMeasurement - if err := rows.Scan( - &m.when, - &m.depth, - &m.width, - &m.value, - ); err != nil { - return nil, err - } - m.when = m.when.UTC() - ms = append(ms, m) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return ms, nil -} - -func loadLDCReferenceValue( - ctx context.Context, - conn *sql.Conn, - bottleneck string, -) ([]float64, error) { - var value float64 - err := conn.QueryRowContext(ctx, selectGaugeLDCSQL, bottleneck).Scan(&value) - switch { - case err == sql.ErrNoRows: - return nil, nil - case err != nil: - return nil, err - } - return []float64{value}, nil -} - -func breaksToReferenceValue(breaks string) []float64 { - parts := strings.Split(breaks, ",") - var values []float64 - - for _, part := range parts { - part = strings.TrimSpace(part) - if v, err := strconv.ParseFloat(part, 64); err == nil { - values = append(values, v) - } - } - - sort.Float64s(values) - - // dedup - for i := 1; i < len(values); { - if values[i-1] == values[i] { - copy(values[i:], values[i+1:]) - values = values[:len(values)-1] - } else { - i++ - } - } - return values -} - -func bottleneckAvailabilty(rw http.ResponseWriter, req *http.Request) { - - mode := intervalMode(req.FormValue("mode")) - bn := mux.Vars(req)["objnam"] - - if bn == "" { - http.Error( - rw, - "Missing objnam of bottleneck", - http.StatusBadRequest, - ) - return - } - - from, ok := parseFormTime(rw, req, "from", time.Now().AddDate(-1, 0, 0)) - if !ok { - return - } - - to, ok := parseFormTime(rw, req, "to", from.AddDate(1, 0, 0)) - if !ok { - return - } - - if to.Before(from) { - to, from = from, to - } - - los, ok := parseFormInt(rw, req, "los", 1) - if !ok { - return - } - - conn := middleware.GetDBConn(req) - ctx := req.Context() - - var limiting string - err := conn.QueryRowContext(ctx, selectLimitingSQL, bn).Scan(&limiting) - switch { - case err == sql.ErrNoRows: - http.Error( - rw, fmt.Sprintf("Unknown limitation for %s.", bn), - http.StatusNotFound) - return - case err != nil: - http.Error( - rw, fmt.Sprintf("DB error: %v.", err), - http.StatusInternalServerError) - return - } - - access := limitingFactor(limiting) - - ldcRefs, err := loadLDCReferenceValue(ctx, conn, bn) - if err != nil { - http.Error( - rw, - fmt.Sprintf("Internal server error: %v", err), - http.StatusInternalServerError, - ) - return - } - - if len(ldcRefs) == 0 { - http.Error( - rw, - "No gauge reference values found for bottleneck", - http.StatusNotFound, - ) - return - } - - var breaks []float64 - if b := req.FormValue("breaks"); b != "" { - breaks = breaksToReferenceValue(b) - } else { - breaks = afdRefs - } - - log.Printf("info: time interval: (%v - %v)\n", from, to) - - var ms availMeasurements - if ms, err = loadDepthValues(ctx, conn, bn, los, from, to); err != nil { - return - } - - if len(ms) == 0 { - http.Error( - rw, - "No available fairway depth values found", - http.StatusNotFound, - ) - return - } - - rw.Header().Add("Content-Type", "text/csv") - - out := csv.NewWriter(rw) - - record := make([]string, 1+2+len(breaks)+1) - record[0] = "#time" - record[1] = fmt.Sprintf("# < LDC (%.1f) [%%]", ldcRefs[0]) - record[2] = fmt.Sprintf("# >= LDC (%.1f) [%%]", ldcRefs[0]) - for i, v := range breaks { - if i == 0 { - record[3] = fmt.Sprintf("#d < %.1f [%%]", v) - } - record[i+4] = fmt.Sprintf("#d >= %.1f [%%]", v) - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - - interval := intervals[mode](from, to) - - now := time.Now() - for pfrom, pto, label := interval(); label != ""; pfrom, pto, label = interval() { - // Don't interpolate for the future - if now.Sub(pto) < 0 { - pto = now - } - - lnwl := ms.classify( - pfrom, pto, - ldcRefs, - (*availMeasurement).getValue, - ) - - afd := ms.classify( - pfrom, pto, - breaks, - access, - ) - - duration := pto.Sub(pfrom) - lnwlPercents := durationsToPercentage(duration, lnwl) - afdPercents := durationsToPercentage(duration, afd) - - record[0] = label - for i, v := range lnwlPercents { - record[1+i] = fmt.Sprintf("%.3f", v) - } - for i, v := range afdPercents { - record[3+i] = fmt.Sprintf("%.3f", v) - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - } - - out.Flush() - if err := out.Error(); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - } -} - -func bottleneckAvailableFairwayDepth(rw http.ResponseWriter, req *http.Request) { - - mode := intervalMode(req.FormValue("mode")) - - bn := mux.Vars(req)["objnam"] - if bn == "" { - http.Error( - rw, "Missing objnam of bottleneck", - http.StatusBadRequest) - return - } - - from, ok := parseFormTime(rw, req, "from", time.Now().AddDate(-1, 0, 0)) - if !ok { - return - } - - to, ok := parseFormTime(rw, req, "to", from.AddDate(1, 0, 0)) - if !ok { - return - } - - if to.Before(from) { - to, from = from, to - } - - los, ok := parseFormInt(rw, req, "los", 1) - if !ok { - return - } - - conn := middleware.GetDBConn(req) - ctx := req.Context() - - var limiting string - err := conn.QueryRowContext(ctx, selectLimitingSQL, bn).Scan(&limiting) - switch { - case err == sql.ErrNoRows: - http.Error( - rw, fmt.Sprintf("Unknown limitation for %s.", bn), - http.StatusNotFound) - return - case err != nil: - http.Error( - rw, fmt.Sprintf("DB error: %v.", err), - http.StatusInternalServerError) - return - } - - access := limitingFactor(limiting) - - log.Printf("info: time interval: (%v - %v)\n", from, to) - - // load the measurements - ms, err := loadDepthValues(ctx, conn, bn, los, from, to) - if err != nil { - http.Error( - rw, fmt.Sprintf("Loading measurements failed: %v.", err), - http.StatusInternalServerError) - return - } - - ldcRefs, err := loadLDCReferenceValue(ctx, conn, bn) - if err != nil { - http.Error( - rw, fmt.Sprintf("Loading LDC failed: %v.", err), - http.StatusInternalServerError) - return - } - if len(ldcRefs) == 0 { - http.Error(rw, "No LDC found", http.StatusNotFound) - return - } - - var breaks []float64 - if b := req.FormValue("breaks"); b != "" { - breaks = breaksToReferenceValue(b) - } else { - breaks = afdRefs - } - - rw.Header().Add("Content-Type", "text/csv") - - out := csv.NewWriter(rw) - - // label, ldc, classes - record := make([]string, 1+2+len(breaks)+1) - record[0] = "#time" - record[1] = fmt.Sprintf("# < LDC (%.1f) [d]", ldcRefs[0]) - record[2] = fmt.Sprintf("# >= LDC (%.1f) [d]", ldcRefs[0]) - for i, v := range breaks { - if i == 0 { - record[3] = fmt.Sprintf("# < %.1f [d]", v) - } - record[i+4] = fmt.Sprintf("# >= %.1f [d]", v) - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - - //log.Println(len(ms)) - //for i := range ms { - // log.Println(ms[i].when, ms[i].depth) - //} - - log.Printf("info: measurements: %d\n", len(ms)) - if len(ms) > 1 { - log.Printf("info: first: %v\n", ms[0].when) - log.Printf("info: last: %v\n", ms[len(ms)-1].when) - log.Printf("info: interval: %.2f [h]\n", ms[len(ms)-1].when.Sub(ms[0].when).Hours()) - } - - interval := intervals[mode](from, to) - - now := time.Now() - for pfrom, pto, label := interval(); label != ""; pfrom, pto, label = interval() { - // Don't interpolate for the future - if now.Sub(pto) < 0 { - pto = now - } - - ldc := ms.classify( - pfrom, pto, - ldcRefs, - (*availMeasurement).getValue, - ) - - ranges := ms.classify( - pfrom, pto, - breaks, - access, - ) - - // Round to full days - ldcRounded := common.RoundToFullDays(ldc) - rangesRounded := common.RoundToFullDays(ranges) - - record[0] = label - for i, v := range ldcRounded { - record[i+1] = fmt.Sprintf("%d", v) - } - - for i, d := range rangesRounded { - record[3+i] = fmt.Sprintf("%d", d) - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - } - - out.Flush() - if err := out.Error(); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - } -} - -var intervals = []func(time.Time, time.Time) func() (time.Time, time.Time, string){ - monthly, - quarterly, - yearly, -} - -func monthly(from, to time.Time) func() (time.Time, time.Time, string) { - pfrom := from - return func() (time.Time, time.Time, string) { - if pfrom.After(to) { - return time.Time{}, time.Time{}, "" - } - f := pfrom - pfrom = pfrom.AddDate(0, 1, 0) - label := fmt.Sprintf("%02d-%d", f.Month(), f.Year()) - return f, f.AddDate(0, 1, 0).Add(-time.Nanosecond), label - } -} - -func quarterly(from, to time.Time) func() (time.Time, time.Time, string) { - pfrom := from - return func() (time.Time, time.Time, string) { - if pfrom.After(to) { - return time.Time{}, time.Time{}, "" - } - f := pfrom - pfrom = pfrom.AddDate(0, 3, 0) - label := fmt.Sprintf("Q%d-%d", (int(f.Month())-1)/3+1, f.Year()) - return f, f.AddDate(0, 3, 0).Add(-time.Nanosecond), label - } -} - -func yearly(from, to time.Time) func() (time.Time, time.Time, string) { - pfrom := from - return func() (time.Time, time.Time, string) { - if pfrom.After(to) { - return time.Time{}, time.Time{}, "" - } - f := pfrom - pfrom = pfrom.AddDate(1, 0, 0) - label := fmt.Sprintf("%d", f.Year()) - return f, f.AddDate(1, 0, 0).Add(-time.Nanosecond), label - } -}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pkg/controllers/fwa.go Wed May 13 11:28:34 2020 +0200 @@ -0,0 +1,930 @@ +// This is Free Software under GNU Affero General Public License v >= 3.0 +// without warranty, see README.md and license for details. +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// License-Filename: LICENSES/AGPL-3.0.txt +// +// Copyright (C) 2018, 2019, 2020 by via donau +// – Österreichische Wasserstraßen-Gesellschaft mbH +// Software engineering by Intevation GmbH +// +// Author(s): +// * Sascha L. Teichmann <sascha.teichmann@intevation.de> + +package controllers + +import ( + "context" + "database/sql" + "encoding/csv" + "fmt" + "log" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "github.com/gorilla/mux" + + "gemma.intevation.de/gemma/pkg/common" + "gemma.intevation.de/gemma/pkg/middleware" +) + +const ( + selectBottlenecksLimitingSQL = ` +SELECT + lower(validity), + upper(validity), + limiting +FROM + waterway.bottlenecks +WHERE + bottleneck_id = $1 AND + validity && tstzrange($2, $3)` + + selectSymbolBottlenecksSQL = ` +SELECT + distinct(b.bottleneck_id) +FROM + %s s, waterway.bottlenecks b +WHERE + ST_Intersects(b.area, s.area) + AND s.name = $1 + AND b.validity && tstzrange($2, $3)` + + selectLDCsSQL = ` +SELECT + lower(grwl.validity), + upper(grwl.validity), + grwl.value +FROM + waterway.gauges_reference_water_levels grwl + JOIN waterway.bottlenecks bns + ON grwl.location = bns.gauge_location +WHERE + grwl.depth_reference like 'LDC%' + AND bns.bottleneck_id = $1 + AND grwl.validity && tstzrange($2, $3)` + + selectMeasurementsSQL = ` +WITH data AS ( + SELECT + efa.measure_date, + efa.available_depth_value, + efa.available_width_value, + efa.water_level_value + FROM waterway.effective_fairway_availability efa + JOIN waterway.fairway_availability fa + ON efa.fairway_availability_id = fa.id + JOIN waterway.bottlenecks bn + ON fa.bottleneck_id = bn.bottleneck_id + WHERE + bn.validity @> efa.measure_date AND + bn.bottleneck_id = $1 AND + efa.level_of_service = $2 AND + efa.measure_type = 'Measured' AND + (efa.available_depth_value IS NOT NULL OR + efa.available_width_value IS NOT NULL) AND + efa.water_level_value IS NOT NULL +), +before AS ( + SELECT * FROM data WHERE measure_date < $3 + ORDER BY measure_date DESC LIMIT 1 +), +inside AS ( + SELECT * FROM data WHERE measure_date BETWEEN $3 AND $4 +), +after AS ( + SELECT * FROM data WHERE measure_date > $4 + ORDER BY measure_date LIMIT 1 +) +SELECT * FROM before +UNION ALL +SELECT * FROM inside +UNION ALL +SELECT * FROM after +ORDER BY measure_date` +) + +type ( + timeRange struct { + lower time.Time + upper time.Time + } + + ldc struct { + timeRange + value []float64 + } + + ldcs []*ldc + + limitingFactor int + + limitingValidity struct { + timeRange + limiting limitingFactor + ldcs ldcs + } + + limitingValidities []limitingValidity + + availMeasurement struct { + when time.Time + depth int16 + width int16 + value int16 + } + + availMeasurements []availMeasurement + + bottleneck struct { + id string + validities limitingValidities + measurements availMeasurements + } + + bottlenecks []bottleneck + + fwaMode int +) + +const ( + fwaMonthly fwaMode = iota + fwaQuarterly + fwaYearly +) + +const ( + limitingDepth limitingFactor = iota + limitingWidth +) + +var limitingAccess = [...]func(*availMeasurement) float64{ + limitingDepth: (*availMeasurement).getDepth, + limitingWidth: (*availMeasurement).getWidth, +} + +// afdRefs are the typical available fairway depth reference values. +var afdRefs = []float64{ + 230, + 250, +} + +func (ls ldcs) find(from, to time.Time) *ldc { + for _, l := range ls { + if l.intersects(from, to) { + return l + } + } + return nil +} + +func fairwayAvailability(rw http.ResponseWriter, req *http.Request) { + + from, to, ok := parseFromTo(rw, req) + if !ok { + return + } + + vars := mux.Vars(req) + name := vars["name"] + if name == "" { + http.Error(rw, "missing 'name' parameter.", http.StatusBadRequest) + return + } + + los, ok := parseFormInt(rw, req, "los", 3) + if !ok { + return + } + + ctx := req.Context() + conn := middleware.GetDBConn(req) + + var bns bottlenecks + var err error + + switch vars["kind"] { + case "bottleneck": + bns = bottlenecks{{id: name}} + case "stretch": + bns, err = loadSymbolBottlenecks(ctx, conn, "users.stretches", name, from, to) + case "section": + bns, err = loadSymbolBottlenecks(ctx, conn, "waterway.sections", name, from, to) + default: + http.Error(rw, "Invalid kind type.", http.StatusBadRequest) + return + } + + if err != nil { + log.Printf("error: %v\n", err) + http.Error(rw, "cannot extract bottlenecks", http.StatusBadRequest) + return + } + + // If there are no bottlenecks there is nothing to do. + if len(bns) == 0 { + http.Error(rw, "No bottlenecks found.", http.StatusNotFound) + return + } + + // load validities and limiting factors + for i := range bns { + if err := bns[i].loadLimitingValidities(ctx, conn, from, to); err != nil { + log.Printf("error: %v\n", err) + http.Error(rw, "cannot load validities", http.StatusInternalServerError) + return + } + // load LCDs + if err := bns[i].loadLDCs(ctx, conn, from, to); err != nil { + log.Printf("error: %v\n", err) + http.Error(rw, "cannot load LDCs", http.StatusInternalServerError) + return + } + // load values + if err := bns[i].loadValues(ctx, conn, from, to, los); err != nil { + log.Printf("error: %v\n", err) + http.Error(rw, "cannot load values", http.StatusInternalServerError) + return + } + } + + // separate breaks for depth and width + var ( + breaks = parseBreaks(req.FormValue("breaks"), afdRefs) + depthBreaks = parseBreaks(req.FormValue("depthbreaks"), breaks) + widthBreaks = parseBreaks(req.FormValue("widthbreaks"), breaks) + chooseBreaks = [...][]float64{ + limitingDepth: depthBreaks, + limitingWidth: widthBreaks, + } + + useDepth = bns.hasLimiting(limitingDepth, from, to) + useWidth = bns.hasLimiting(limitingWidth, from, to) + ) + + if useDepth && useWidth && len(widthBreaks) != len(depthBreaks) { + http.Error( + rw, + fmt.Sprintf("class breaks lengths differ: %d != %d", + len(widthBreaks), len(depthBreaks)), + http.StatusBadRequest, + ) + return + } + + availability := vars["type"] == "availability" + + var record []string + if !availability { + // in days + record = makeHeader(useDepth && useWidth, 1, breaks, 'd') + } else { + // percentage + record = makeHeader(useDepth && useWidth, 3, breaks, '%') + } + + rw.Header().Add("Content-Type", "text/csv") + + out := csv.NewWriter(rw) + + if err := out.Write(record); err != nil { + // Too late for HTTP status message. + log.Printf("error: %v\n", err) + return + } + + for i := range record[1:] { + record[i+1] = "0" + } + + // For every day on every bottleneck we need to find out if this day is valid. + validities := make([]func(time.Time, time.Time) *limitingValidity, len(bns)) + for i := range bns { + validities[i] = bns[i].validities.find() + } + + // Mode reflects if we use monthly, quarterly od yearly intervals. + mode := parseFWAMode(req.FormValue("mode")) + + label, finish := interval(mode, from) + + var ( + totalDays, overLDCDays int + missingLDCs = make([]int, len(validities)) + counters = make([]int, len(breaks)+1) + ) + + var current, next time.Time + + write := func() error { + record[0] = label(current) + + if !availability { + record[1] = strconv.Itoa(totalDays - overLDCDays) + record[2] = strconv.Itoa(overLDCDays) + for i, c := range counters { + record[3+i] = strconv.Itoa(c) + } + } else { + overPerc := float64(overLDCDays) * 100 / float64(totalDays) + record[1] = fmt.Sprintf("%.3f", 100-overPerc) + record[2] = fmt.Sprintf("%.3f", overPerc) + for i, c := range counters { + perc := float64(c) * 100 / float64(totalDays) + record[3+i] = fmt.Sprintf("%.3f", perc) + } + } + + return out.Write(record) + } + + // Stop yesterday + end := common.MinTime(dusk(time.Now()).Add(-time.Nanosecond), to) + + // We step through the time in steps of one day. + for current = from; current.Before(end); { + + next = current.AddDate(0, 0, 1) + + // Assume that a bottleneck is over LDC. + overLDC := true + lowest := len(counters) - 1 + + var hasValid bool + + // check all bottlenecks + for i, validity := range validities { + + // Check if bottleneck is available for this day. + vs := validity(current, next) + if vs == nil { + continue + } + + // Let's see if we have a LDC for this day. + ldc := vs.ldcs.find(current, next) + if ldc == nil { + missingLDCs[i]++ + continue + } + + hasValid = true + + if overLDC { // If its already not shipable we need no further tests. + result := bns[i].measurements.classify( + current, next, + ldc.value, + (*availMeasurement).getValue) + + if result[1] < 12*time.Hour { + overLDC = false + } + } + + if min := minClass(bns[i].measurements.classify( + current, next, + chooseBreaks[vs.limiting], + limitingAccess[vs.limiting]), + 12*time.Hour, + ); min < lowest { + lowest = min + } + } + + if hasValid { + if overLDC { + overLDCDays++ + } + counters[lowest]++ + } else { // assume that all is in best conditions + overLDCDays++ + counters[len(counters)-1]++ + } + + totalDays++ + + if finish(next) { + if err := write(); err != nil { + // Too late for HTTP status message. + log.Printf("error: %v\n", err) + return + } + + // Reset counters + overLDCDays, totalDays = 0, 0 + for i := range counters { + counters[i] = 0 + } + } + + current = next + } + + // Write rest if last period was not finished. + if totalDays > 0 { + if err := write(); err != nil { + // Too late for HTTP status message. + log.Printf("error: %v\n", err) + return + } + } + + // TODO: Log missing LDCs + + out.Flush() + if err := out.Error(); err != nil { + // Too late for HTTP status message. + log.Printf("error: %v\n", err) + } +} + +func minClass(classes []time.Duration, threshold time.Duration) int { + var sum time.Duration + for i, v := range classes { + if sum += v; sum >= threshold { + return i + } + } + return len(classes) - 1 +} + +func dusk(t time.Time) time.Time { + return time.Date( + t.Year(), + t.Month(), + t.Day(), + 0, 0, 0, 0, + t.Location()) +} + +func dawn(t time.Time) time.Time { + return time.Date( + t.Year(), + t.Month(), + t.Day(), + 23, 59, 59, 999999999, + t.Location()) +} + +func parseFromTo( + rw http.ResponseWriter, + req *http.Request, +) (time.Time, time.Time, bool) { + from, ok := parseFormTime(rw, req, "from", time.Now().AddDate(-1, 0, 0)) + if !ok { + return time.Time{}, time.Time{}, false + } + + to, ok := parseFormTime(rw, req, "to", from.AddDate(1, 0, 0)) + if !ok { + return time.Time{}, time.Time{}, false + } + + from, to = common.OrderTime(from, to) + // Operate on daily basis so go to full days. + return dusk(from), dawn(to), true +} + +func parseFWAMode(mode string) fwaMode { + switch strings.ToLower(mode) { + case "monthly": + return fwaMonthly + case "quarterly": + return fwaQuarterly + case "yearly": + return fwaYearly + default: + return fwaMonthly + } +} + +func breaksToReferenceValue(breaks string) []float64 { + parts := strings.Split(breaks, ",") + var values []float64 + + for _, part := range parts { + part = strings.TrimSpace(part) + if v, err := strconv.ParseFloat(part, 64); err == nil { + values = append(values, v) + } + } + + return common.DedupFloat64s(values) +} + +func parseBreaks(breaks string, defaults []float64) []float64 { + if breaks != "" { + return breaksToReferenceValue(breaks) + } + return defaults +} + +func (tr *timeRange) intersects(from, to time.Time) bool { + return !(to.Before(tr.lower) || from.After(tr.upper)) +} + +func (tr *timeRange) toUTC() { + tr.lower = tr.lower.UTC() + tr.upper = tr.upper.UTC() +} + +func (lvs limitingValidities) find() func(from, to time.Time) *limitingValidity { + + var last *limitingValidity + + return func(from, to time.Time) *limitingValidity { + if last != nil && last.intersects(from, to) { + return last + } + for i := range lvs { + if lv := &lvs[i]; lv.intersects(from, to) { + last = lv + return lv + } + } + return nil + } +} + +func (lvs limitingValidities) hasLimiting(limiting limitingFactor, from, to time.Time) bool { + for i := range lvs { + if lvs[i].limiting == limiting && lvs[i].intersects(from, to) { + return true + } + } + return false +} + +func (bns bottlenecks) hasLimiting(limiting limitingFactor, from, to time.Time) bool { + for i := range bns { + if bns[i].validities.hasLimiting(limiting, from, to) { + return true + } + } + return false +} + +func parseLimitingFactor(limiting string) limitingFactor { + switch limiting { + case "depth": + return limitingDepth + case "width": + return limitingWidth + default: + log.Printf("warn: unknown limitation '%s'. default to 'depth'\n", limiting) + return limitingDepth + } +} + +func loadLimitingValidities( + ctx context.Context, + conn *sql.Conn, + bottleneckID string, + from, to time.Time, +) (limitingValidities, error) { + + var lvs limitingValidities + + rows, err := conn.QueryContext( + ctx, + selectBottlenecksLimitingSQL, + bottleneckID, + from, to) + + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var ( + lv limitingValidity + limiting string + upper sql.NullTime + ) + if err := rows.Scan( + &lv.lower, + &upper, + &limiting, + ); err != nil { + return nil, err + } + if upper.Valid { + lv.upper = upper.Time + } else { + lv.upper = to.Add(24 * time.Hour) + } + lv.toUTC() + lv.limiting = parseLimitingFactor(limiting) + lvs = append(lvs, lv) + } + + return lvs, rows.Err() +} + +func loadSymbolBottlenecks( + ctx context.Context, + conn *sql.Conn, + what, name string, + from, to time.Time, +) (bottlenecks, error) { + + rows, err := conn.QueryContext( + ctx, + fmt.Sprintf(selectSymbolBottlenecksSQL, what), + name, + from, to) + if err != nil { + return nil, err + } + defer rows.Close() + + var bns bottlenecks + + for rows.Next() { + var b bottleneck + if err := rows.Scan(&b.id); err != nil { + return nil, err + } + bns = append(bns, b) + } + + return bns, rows.Err() +} + +func (bn *bottleneck) loadLimitingValidities( + ctx context.Context, + conn *sql.Conn, + from, to time.Time, +) error { + vs, err := loadLimitingValidities( + ctx, + conn, + bn.id, + from, to) + if err == nil { + bn.validities = vs + } + return err +} + +func (bn *bottleneck) loadLDCs( + ctx context.Context, + conn *sql.Conn, + from, to time.Time, +) error { + rows, err := conn.QueryContext( + ctx, selectLDCsSQL, + bn.id, + from, to) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + l := ldc{value: []float64{0}} + var upper sql.NullTime + if err := rows.Scan(&l.lower, &upper, &l.value[0]); err != nil { + return err + } + if upper.Valid { + l.upper = upper.Time + } else { + l.upper = to.Add(24 * time.Hour) + } + l.toUTC() + for i := range bn.validities { + vs := &bn.validities[i] + + if vs.intersects(l.lower, l.upper) { + vs.ldcs = append(vs.ldcs, &l) + } + } + } + return rows.Err() +} + +func (bn *bottleneck) loadValues( + ctx context.Context, + conn *sql.Conn, + from, to time.Time, + los int, +) error { + rows, err := conn.QueryContext( + ctx, selectMeasurementsSQL, + bn.id, + los, + from, to) + if err != nil { + return err + } + defer rows.Close() + + var ms availMeasurements + + for rows.Next() { + var m availMeasurement + if err := rows.Scan( + &m.when, + &m.depth, + &m.width, + &m.value, + ); err != nil { + return err + } + m.when = m.when.UTC() + ms = append(ms, m) + } + if err := rows.Err(); err != nil { + return err + } + bn.measurements = ms + return nil +} + +func (measurement *availMeasurement) getDepth() float64 { + return float64(measurement.depth) +} + +func (measurement *availMeasurement) getValue() float64 { + return float64(measurement.value) +} + +func (measurement *availMeasurement) getWidth() float64 { + return float64(measurement.width) +} + +func (measurements availMeasurements) classify( + from, to time.Time, + breaks []float64, + access func(*availMeasurement) float64, +) []time.Duration { + + if len(breaks) == 0 { + return []time.Duration{} + } + + result := make([]time.Duration, len(breaks)+1) + classes := make([]float64, len(breaks)+2) + values := make([]time.Time, len(classes)) + + // Add sentinels + classes[0] = breaks[0] - 9999 + classes[len(classes)-1] = breaks[len(breaks)-1] + 9999 + for i := range breaks { + classes[i+1] = breaks[i] + } + + idx := sort.Search(len(measurements), func(i int) bool { + // All values before from can be ignored. + return !measurements[i].when.Before(from) + }) + + if idx >= len(measurements) { + return result + } + + // Be safe for interpolation. + if idx > 0 { + idx-- + } + + measurements = measurements[idx:] + + for i := 0; i < len(measurements)-1; i++ { + p1 := &measurements[i] + p2 := &measurements[i+1] + + if p1.when.After(to) { + return result + } + + if p2.when.Before(from) { + continue + } + + // TODO: Discuss if we want somethinh like this. + if false && p2.when.Sub(p1.when).Hours() > 1.5 { + // Don't interpolate ranges bigger then one and a half hour + continue + } + + lo, hi := common.MaxTime(p1.when, from), common.MinTime(p2.when, to) + + m1, m2 := access(p1), access(p2) + if m1 == m2 { // The whole interval is in only one class. + for j := 0; j < len(classes)-1; j++ { + if classes[j] <= m1 && m1 <= classes[j+1] { + result[j] += hi.Sub(lo) + break + } + } + continue + } + + f := common.InterpolateTime( + p1.when, m1, + p2.when, m2, + ) + + for j, c := range classes { + values[j] = f(c) + } + + for j := 0; j < len(values)-1; j++ { + start, end := common.OrderTime(values[j], values[j+1]) + + if start.After(hi) || end.Before(lo) { + continue + } + + start, end = common.MaxTime(start, lo), common.MinTime(end, hi) + result[j] += end.Sub(start) + } + } + + return result +} + +func interval(mode fwaMode, t time.Time) ( + label func(time.Time) string, + finish func(time.Time) bool, +) { + switch mode { + case fwaMonthly: + label, finish = monthLabel, otherMonth(t) + case fwaQuarterly: + label, finish = quarterLabel, otherQuarter(t) + case fwaYearly: + label, finish = yearLabel, otherYear(t) + default: + panic("Unknown mode") + } + return +} + +func monthLabel(t time.Time) string { + return fmt.Sprintf("%02d-%d", t.Month(), t.Year()) +} + +func quarterLabel(t time.Time) string { + return fmt.Sprintf("Q%d-%d", (int(t.Month())-1)/3+1, t.Year()) +} + +func yearLabel(t time.Time) string { + return strconv.Itoa(t.Year()) +} + +func otherMonth(t time.Time) func(time.Time) bool { + return func(x time.Time) bool { + flag := t.Day() == x.Day() + if flag { + t = x + } + return flag + } +} + +func otherQuarter(t time.Time) func(time.Time) bool { + return func(x time.Time) bool { + flag := (t.Month()-1)/3 != (x.Month()-1)/3 + if flag { + t = x + } + return flag + } +} + +func otherYear(t time.Time) func(time.Time) bool { + return func(x time.Time) bool { + flag := t.Year() != x.Year() + if flag { + t = x + } + return flag + } +} + +func makeHeader(flag bool, prec int, breaks []float64, unit rune) []string { + record := make([]string, 1+2+len(breaks)+1) + record[0] = "# time" + record[1] = fmt.Sprintf("# < LDC [%c]", unit) + record[2] = fmt.Sprintf("# >= LDC [%c]", unit) + for i, v := range breaks { + if flag { + if i == 0 { + record[3] = fmt.Sprintf("# < break_1 [%c]", unit) + } + record[i+4] = fmt.Sprintf("# >= break_%d [%c]", i+1, unit) + } else { + if i == 0 { + record[3] = fmt.Sprintf("# < %.*f [%c]", prec, v, unit) + } + record[i+4] = fmt.Sprintf("# >= %.*f [%c]", prec, v, unit) + } + } + return record +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pkg/controllers/misc.go Wed May 13 11:28:34 2020 +0200 @@ -0,0 +1,65 @@ +// This is Free Software under GNU Affero General Public License v >= 3.0 +// without warranty, see README.md and license for details. +// +// SPDX-License-Identifier: AGPL-3.0-or-later +// License-Filename: LICENSES/AGPL-3.0.txt +// +// Copyright (C) 2019, 2020 by via donau +// – Österreichische Wasserstraßen-Gesellschaft mbH +// Software engineering by Intevation GmbH +// +// Author(s): +// * Sascha L. Teichmann <sascha.teichmann@intevation.de> + +package controllers + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "gemma.intevation.de/gemma/pkg/common" +) + +func parseFormTime( + rw http.ResponseWriter, + req *http.Request, + field string, + def time.Time, +) (time.Time, bool) { + f := req.FormValue(field) + if f == "" { + return def.UTC(), true + } + v, err := common.ParseTime(f) + if err != nil { + http.Error( + rw, fmt.Sprintf("Invalid format for '%s'.", field), + http.StatusBadRequest, + ) + return time.Time{}, false + } + return v.UTC(), true +} + +func parseFormInt( + rw http.ResponseWriter, + req *http.Request, + field string, + def int, +) (int, bool) { + f := req.FormValue(field) + if f == "" { + return def, true + } + v, err := strconv.Atoi(f) + if err != nil { + http.Error( + rw, fmt.Sprintf("Invalid format for '%s'.", field), + http.StatusBadRequest, + ) + return 0, false + } + return v, true +}
--- a/pkg/controllers/routes.go Tue May 12 16:52:08 2020 +0200 +++ b/pkg/controllers/routes.go Wed May 13 11:28:34 2020 +0200 @@ -324,21 +324,12 @@ // Handler to serve data to the client. + api.Handle("/data/{type:availability|fairway}/{kind:stretch|section|bottleneck}/{name:.+}", any( + mw.DBConn(http.HandlerFunc(fairwayAvailability)))).Methods(http.MethodGet) + api.Handle("/data/stretch/shape/{name:.+}", any( mw.DBConn(http.HandlerFunc(stretchShapeDownload)))).Methods(http.MethodGet) - api.Handle("/data/{kind:stretch|section}/availability/{name:.+}", any( - mw.DBConn(http.HandlerFunc(stretchAvailabilty)))).Methods(http.MethodGet) - - api.Handle("/data/{kind:stretch|section}/fairway-depth/{name:.+}", any( - mw.DBConn(http.HandlerFunc(stretchAvailableFairwayDepth)))).Methods(http.MethodGet) - - api.Handle("/data/bottleneck/fairway-depth/{objnam:.+}", any( - mw.DBConn(http.HandlerFunc(bottleneckAvailableFairwayDepth)))).Methods(http.MethodGet) - - api.Handle("/data/bottleneck/availability/{objnam:.+}", any( - mw.DBConn(http.HandlerFunc(bottleneckAvailabilty)))).Methods(http.MethodGet) - api.Handle("/data/waterlevels/{gauge:.+}", any( mw.DBConn(http.HandlerFunc(waterlevels)))).Methods(http.MethodGet)
--- a/pkg/controllers/stretches.go Tue May 12 16:52:08 2020 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,709 +0,0 @@ -// This is Free Software under GNU Affero General Public License v >= 3.0 -// without warranty, see README.md and license for details. -// -// SPDX-License-Identifier: AGPL-3.0-or-later -// License-Filename: LICENSES/AGPL-3.0.txt -// -// Copyright (C) 2019 by via donau -// – Österreichische Wasserstraßen-Gesellschaft mbH -// Software engineering by Intevation GmbH -// -// Author(s): -// * Sascha L. Teichmann <sascha.teichmann@intevation.de> -// * Sascha Wilde <wilde@intevation.de> - -package controllers - -import ( - "context" - "database/sql" - "encoding/csv" - "fmt" - "log" - "net/http" - "runtime" - "strings" - "sync" - "time" - - "github.com/gorilla/mux" - - "gemma.intevation.de/gemma/pkg/common" - "gemma.intevation.de/gemma/pkg/middleware" -) - -// The following requests are taking _all_ bottlenecks into account, not only -// the currently valid ones. This is neccessary, as we are doing reports on -// arbitrary time ranges and bottlenecks currently active might have been in the -// selected time range. -// -// FIXME: the better solution would be to limit the bottlenecks to those with: -// b.validity && REQUESTED_TIME_RANGE - -const ( - selectSectionBottlenecks = ` -SELECT - distinct(b.objnam), - b.limiting -FROM waterway.sections s, waterway.bottlenecks b -WHERE ST_Intersects(b.area, s.area) - AND s.name = $1` - - selectStretchBottlenecks = ` -SELECT - distinct(b.objnam), - b.limiting -FROM users.stretches s, waterway.bottlenecks b -WHERE ST_Intersects(b.area, s.area) - AND s.name = $1` -) - -type ( - stretchBottleneck struct { - name string - limiting string - } - - stretchBottlenecks []stretchBottleneck - - fullStretchBottleneck struct { - *stretchBottleneck - measurements availMeasurements - ldc []float64 - breaks []float64 - access func(*availMeasurement) float64 - } -) - -func (bns stretchBottlenecks) contains(limiting string) bool { - for i := range bns { - if bns[i].limiting == limiting { - return true - } - } - return false -} - -func maxDuration(a time.Duration, b time.Duration) time.Duration { - if a > b { - return a - } - return b -} - -func sumClassesTo(breaks []time.Duration, to int) time.Duration { - var result time.Duration - for i := 0; i <= to; i++ { - result += breaks[i] - } - return result -} - -func aggregateClasses( - new []time.Duration, - agg []time.Duration, -) []time.Duration { - newAgg := make([]time.Duration, len(agg)) - - for i := 0; i < len(new)-1; i++ { - oldSum := sumClassesTo(agg, i) - newSum := sumClassesTo(new, i) - newAgg[i] = maxDuration(newSum, oldSum) - sumClassesTo(newAgg, i-1) - } - // adjust highest class so the sum of all classes in agg - // matches the original sum of all classes in new. - newAgg[len(new)-1] = - sumClassesTo(new, len(new)-1) - sumClassesTo(newAgg, len(new)-2) - return newAgg -} - -func loadFullStretchBottleneck( - ctx context.Context, - conn *sql.Conn, - bn *stretchBottleneck, - los int, - from, to time.Time, - depthbreaks, widthbreaks []float64, -) (*fullStretchBottleneck, error) { - measurements, err := loadDepthValues(ctx, conn, bn.name, los, from, to) - if err != nil { - return nil, err - } - ldc, err := loadLDCReferenceValue(ctx, conn, bn.name) - if err != nil { - return nil, err - } - - if len(ldc) == 0 { - return nil, fmt.Errorf("no LDC found for bottleneck: %s", bn.name) - } - - var access func(*availMeasurement) float64 - var breaks []float64 - - switch bn.limiting { - case "width": - access = (*availMeasurement).getWidth - breaks = widthbreaks - case "depth": - access = (*availMeasurement).getDepth - breaks = depthbreaks - default: - log.Printf( - "warn: unknown limitation '%s'. default to 'depth'.\n", - bn.limiting) - access = (*availMeasurement).getDepth - breaks = depthbreaks - } - - return &fullStretchBottleneck{ - stretchBottleneck: bn, - measurements: measurements, - ldc: ldc, - breaks: breaks, - access: access, - }, nil -} - -func loadStretchBottlenecks( - ctx context.Context, - conn *sql.Conn, - stretch bool, - name string, -) (stretchBottlenecks, error) { - var sql string - if stretch { - sql = selectStretchBottlenecks - } else { - sql = selectSectionBottlenecks - } - - rows, err := conn.QueryContext(ctx, sql, name) - if err != nil { - return nil, err - } - defer rows.Close() - - var bns stretchBottlenecks - - for rows.Next() { - var bn stretchBottleneck - if err := rows.Scan( - &bn.name, - &bn.limiting, - ); err != nil { - return nil, err - } - bns = append(bns, bn) - } - - if err := rows.Err(); err != nil { - return nil, err - } - - return bns, nil -} - -func stretchAvailableFairwayDepth(rw http.ResponseWriter, req *http.Request) { - - vars := mux.Vars(req) - stretch := vars["kind"] == "stretch" - name := vars["name"] - mode := intervalMode(req.FormValue("mode")) - - depthbreaks, widthbreaks := afdRefs, afdRefs - - from, ok := parseFormTime(rw, req, "from", time.Now().AddDate(-1, 0, 0)) - if !ok { - return - } - - to, ok := parseFormTime(rw, req, "to", from.AddDate(1, 0, 0)) - if !ok { - return - } - - if to.Before(from) { - to, from = from, to - } - - los, ok := parseFormInt(rw, req, "los", 1) - if !ok { - return - } - - conn := middleware.GetDBConn(req) - ctx := req.Context() - - bns, err := loadStretchBottlenecks(ctx, conn, stretch, name) - if err != nil { - http.Error( - rw, fmt.Sprintf("DB error: %v.", err), - http.StatusInternalServerError) - return - } - - if len(bns) == 0 { - http.Error(rw, "No bottlenecks found.", http.StatusNotFound) - return - } - - if b := req.FormValue("depthbreaks"); b != "" { - depthbreaks = breaksToReferenceValue(b) - } - - if b := req.FormValue("widthbreaks"); b != "" { - widthbreaks = breaksToReferenceValue(b) - } - - useDepth, useWidth := bns.contains("depth"), bns.contains("width") - - if useDepth && useWidth && len(widthbreaks) != len(depthbreaks) { - http.Error( - rw, - fmt.Sprintf("class breaks lengths differ: %d != %d", - len(widthbreaks), len(depthbreaks)), - http.StatusBadRequest, - ) - return - } - - log.Printf("info: time interval: (%v - %v)\n", from, to) - - var loaded []*fullStretchBottleneck - var errors []error - - for i := range bns { - l, err := loadFullStretchBottleneck( - ctx, - conn, - &bns[i], - los, - from, to, - depthbreaks, widthbreaks, - ) - if err != nil { - log.Printf("error: %v\n", err) - errors = append(errors, err) - continue - } - loaded = append(loaded, l) - } - - if len(loaded) == 0 { - http.Error( - rw, - fmt.Sprintf("No bottleneck loaded: %v", joinErrors(errors)), - http.StatusInternalServerError, - ) - return - } - - n := runtime.NumCPU() / 2 - if n == 0 { - n = 1 - } - - type result struct { - label string - from time.Time - to time.Time - ldc []time.Duration - breaks []time.Duration - } - - jobCh := make(chan *result) - - var wg sync.WaitGroup - - for i := 0; i < n; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for res := range jobCh { - - var ldc, breaks []time.Duration - - now := time.Now() - for _, bn := range loaded { - // Don't interpolate for the future - if now.Sub(res.to) < 0 { - res.to = now - } - - l := bn.measurements.classify( - res.from, res.to, - bn.ldc, - (*availMeasurement).getValue, - ) - b := bn.measurements.classify( - res.from, res.to, - bn.breaks, - bn.access, - ) - - if ldc == nil { - ldc, breaks = l, b - } else { - ldc = aggregateClasses(l, ldc) - breaks = aggregateClasses(b, breaks) - } - } - - res.ldc = ldc - res.breaks = breaks - } - }() - } - - var results []*result - - interval := intervals[mode](from, to) - - var breaks []float64 - - if useDepth { - breaks = depthbreaks - } else { - breaks = widthbreaks - } - - for pfrom, pto, label := interval(); label != ""; pfrom, pto, label = interval() { - - res := &result{ - label: label, - from: pfrom, - to: pto, - } - results = append(results, res) - jobCh <- res - } - - close(jobCh) - wg.Wait() - - rw.Header().Add("Content-Type", "text/csv") - - out := csv.NewWriter(rw) - - // label, lnwl, classes - record := make([]string, 1+2+len(breaks)+1) - record[0] = "# time" - record[1] = "# < LDC [d]" - record[2] = "# >= LDC [d]" - for i, v := range breaks { - if useDepth && useWidth { - if i == 0 { - record[3] = "# < break_1 [d]" - } - record[i+4] = fmt.Sprintf("# >= break_%d", i+1) - } else { - if i == 0 { - record[3] = fmt.Sprintf("# < %.1f [d]", v) - } - record[i+4] = fmt.Sprintf("# >= %.1f [d]", v) - } - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - - empty := fmt.Sprintf("%.3f", 0.0) - for i := range record[1:] { - record[i+1] = empty - } - - for _, r := range results { - // Round to full days - ldcRounded := common.RoundToFullDays(r.ldc) - rangesRounded := common.RoundToFullDays(r.breaks) - - record[0] = r.label - for i, v := range ldcRounded { - record[1+i] = fmt.Sprintf("%d", v) - } - - for i, d := range rangesRounded { - record[3+i] = fmt.Sprintf("%d", d) - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - } - - out.Flush() - if err := out.Error(); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - } -} - -func joinErrors(errors []error) string { - var b strings.Builder - for _, err := range errors { - if b.Len() > 0 { - b.WriteString(", ") - } - b.WriteString(err.Error()) - } - return b.String() -} - -func stretchAvailabilty(rw http.ResponseWriter, req *http.Request) { - - vars := mux.Vars(req) - stretch := vars["kind"] == "stretch" - name := vars["name"] - mode := intervalMode(req.FormValue("mode")) - - if name == "" { - http.Error( - rw, - fmt.Sprintf("Missing %s name", vars["kind"]), - http.StatusBadRequest, - ) - return - } - - from, ok := parseFormTime(rw, req, "from", time.Now().AddDate(-1, 0, 0)) - if !ok { - return - } - - to, ok := parseFormTime(rw, req, "to", from.AddDate(1, 0, 0)) - if !ok { - return - } - - if to.Before(from) { - to, from = from, to - } - - los, ok := parseFormInt(rw, req, "los", 1) - if !ok { - return - } - - depthbreaks, widthbreaks := afdRefs, afdRefs - - if b := req.FormValue("depthbreaks"); b != "" { - depthbreaks = breaksToReferenceValue(b) - } - - if b := req.FormValue("widthbreaks"); b != "" { - widthbreaks = breaksToReferenceValue(b) - } - - conn := middleware.GetDBConn(req) - ctx := req.Context() - - bns, err := loadStretchBottlenecks(ctx, conn, stretch, name) - if err != nil { - http.Error( - rw, fmt.Sprintf("DB error: %v.", err), - http.StatusInternalServerError) - return - } - - if len(bns) == 0 { - http.Error( - rw, - "No bottlenecks found.", - http.StatusNotFound, - ) - return - } - - useDepth, useWidth := bns.contains("depth"), bns.contains("width") - - if useDepth && useWidth && len(widthbreaks) != len(depthbreaks) { - http.Error( - rw, - fmt.Sprintf("class breaks lengths differ: %d != %d", - len(widthbreaks), len(depthbreaks)), - http.StatusBadRequest, - ) - return - } - - log.Printf("info: time interval: (%v - %v)\n", from, to) - - var loaded []*fullStretchBottleneck - var errors []error - - for i := range bns { - l, err := loadFullStretchBottleneck( - ctx, - conn, - &bns[i], - los, - from, to, - depthbreaks, widthbreaks, - ) - if err != nil { - log.Printf("error: %v\n", err) - errors = append(errors, err) - continue - } - loaded = append(loaded, l) - } - - if len(loaded) == 0 { - http.Error( - rw, - fmt.Sprintf("No bottleneck loaded: %v", joinErrors(errors)), - http.StatusInternalServerError, - ) - return - } - - n := runtime.NumCPU() / 2 - if n == 0 { - n = 1 - } - - type result struct { - label string - from time.Time - to time.Time - ldc []float64 - breaks []float64 - } - - jobCh := make(chan *result) - - var wg sync.WaitGroup - - for i := 0; i < n; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for res := range jobCh { - var ldc, breaks []time.Duration - - now := time.Now() - for _, bn := range loaded { - // Don't interpolate for the future - if now.Sub(res.to) < 0 { - res.to = now - } - - l := bn.measurements.classify( - res.from, res.to, - bn.ldc, - (*availMeasurement).getValue, - ) - - b := bn.measurements.classify( - res.from, res.to, - bn.breaks, - bn.access, - ) - - if ldc == nil { - ldc, breaks = l, b - } else { - ldc = aggregateClasses(l, ldc) - breaks = aggregateClasses(b, breaks) - } - } - - duration := res.to.Sub(res.from) - - res.ldc = durationsToPercentage(duration, ldc) - res.breaks = durationsToPercentage(duration, breaks) - } - }() - } - - var results []*result - - interval := intervals[mode](from, to) - - var breaks []float64 - - if useDepth { - breaks = depthbreaks - } else { - breaks = widthbreaks - } - - for pfrom, pto, label := interval(); label != ""; pfrom, pto, label = interval() { - - res := &result{ - label: label, - from: pfrom, - to: pto, - } - results = append(results, res) - - jobCh <- res - } - - close(jobCh) - wg.Wait() - - rw.Header().Add("Content-Type", "text/csv") - - out := csv.NewWriter(rw) - - // label, lnwl, classes - record := make([]string, 1+2+len(breaks)+1) - record[0] = "# time" - record[1] = "# < LDC [%%]" - record[2] = "# >= LDC [%%]" - for i, v := range breaks { - if useDepth && useWidth { - if i == 0 { - record[3] = "# < break_1 [%%]" - } - record[i+4] = fmt.Sprintf("# >= break_%d [%%]", i+1) - } else { - if i == 0 { - record[3] = fmt.Sprintf("# < %.3f [%%]", v) - } - record[i+4] = fmt.Sprintf("# >= %.3f [%%]", v) - } - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - - empty := fmt.Sprintf("%.3f", 0.0) - for i := range record[1:] { - record[i+1] = empty - } - - for _, res := range results { - record[0] = res.label - - for i, v := range res.ldc { - record[1+i] = fmt.Sprintf("%.3f", v) - } - - for i, v := range res.breaks { - record[3+i] = fmt.Sprintf("%.3f", v) - } - - if err := out.Write(record); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - return - } - } - - out.Flush() - if err := out.Error(); err != nil { - // Too late for HTTP status message. - log.Printf("error: %v\n", err) - } -}
--- a/schema/default_sysconfig.sql Tue May 12 16:52:08 2020 +0200 +++ b/schema/default_sysconfig.sql Wed May 13 11:28:34 2020 +0200 @@ -4,7 +4,7 @@ -- SPDX-License-Identifier: AGPL-3.0-or-later -- License-Filename: LICENSES/AGPL-3.0.txt --- Copyright (C) 2018, 2019 by via donau +-- Copyright (C) 2018, 2019, 2020 by via donau -- – Österreichische Wasserstraßen-Gesellschaft mbH -- Software engineering by Intevation GmbH @@ -220,6 +220,7 @@ ('waterway', 'bottleneck_overview', 4326, NULL, $$ SELECT objnam AS name, + bn.bottleneck_id, ST_Centroid(area) AS point, (lower(stretch)).hectometre AS from, (upper(stretch)).hectometre AS to,
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/schema/updates/1439/01.add_bn_id_to_bn_overview.sql Wed May 13 11:28:34 2020 +0200 @@ -0,0 +1,22 @@ +-- Add bottleneck_id column to bottleneck_overview view. + +UPDATE sys_admin.published_services + SET + view_def = $$ + SELECT + objnam AS name, + bn.bottleneck_id, + ST_Centroid(area) AS point, + (lower(stretch)).hectometre AS from, + (upper(stretch)).hectometre AS to, + sr.current::text, + responsible_country + FROM waterway.bottlenecks bn LEFT JOIN ( + SELECT bottleneck_id, max(date_info) AS current + FROM waterway.sounding_results + GROUP BY bottleneck_id) sr + ON sr.bottleneck_id = bn.bottleneck_id + WHERE bn.validity @> current_timestamp + ORDER BY objnam + $$ + WHERE schema = 'waterway' AND name = 'bottleneck_overview';