diff --git a/VIPSWeb/settings.py b/VIPSWeb/settings.py index 56e3e4565407ea1fb8574166ad3c040d4cbe3641..65ef58f06bbf9af829c27d57d530dd276f014a84 100755 --- a/VIPSWeb/settings.py +++ b/VIPSWeb/settings.py @@ -143,6 +143,7 @@ INSTALLED_APPS = ( 'observations', 'information', 'cerealblotchmodels', + 'ipmd', 'calculators', 'roughage', 'applefruitmoth', diff --git a/VIPSWeb/urls.py b/VIPSWeb/urls.py index 4aa88ffb63ab9e54675948eaa5ec5515beafc93b..268850ec116eeaca4631b0a52ef472b93e39a552 100755 --- a/VIPSWeb/urls.py +++ b/VIPSWeb/urls.py @@ -58,6 +58,7 @@ else: re_path(r'^observations/', include('observations.urls', namespace = "observations")), re_path(r'^information/', include('information.urls', namespace = "information")), re_path(r'^blotch/', include('cerealblotchmodels.urls', namespace = "cerealblotchmodels")), + re_path(r'^ipmd/', include('ipmd.urls', namespace = "ipmd")), re_path(r'^calculators/', include('calculators.urls', namespace = "calculators")), re_path(r'^roughage/', include('roughage.urls', namespace = "roughage")), re_path(r'^security/', include('security.urls', namespace = "security")), diff --git a/docs/new_model_config.md b/docs/new_model_config.md index 5ca0010f0de2097de6fd61c789f8e5c68e108190..89fbcc4897ab93a11223886cdaeccf8ad6eefac9 100644 --- a/docs/new_model_config.md +++ b/docs/new_model_config.md @@ -13,7 +13,7 @@ Add a result parameter by clicking as shown below  The result parameter form is shown below. -* Namespace = The model ID (dictated by the model) +* Namespace = The model ID (dictated by the model) or WEATHER (Weather parameters are most times using the WEATHER namespace) * Key = The parameter ID (dictated by the model) * Name = The parameter name as displayed to users * Description = Further description of the parameter (currently not in use) diff --git a/ipmd/__init__.py b/ipmd/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ipmd/admin.py b/ipmd/admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8c38f3f3dad51e4585f3984282c2a4bec5349c1e --- /dev/null +++ b/ipmd/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/ipmd/apps.py b/ipmd/apps.py new file mode 100644 index 0000000000000000000000000000000000000000..a56463fc5eee2c0ef41757642b2e8e989138e104 --- /dev/null +++ b/ipmd/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IpmdConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ipmd' diff --git a/ipmd/migrations/__init__.py b/ipmd/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/ipmd/models.py b/ipmd/models.py new file mode 100644 index 0000000000000000000000000000000000000000..71a836239075aa6e6e4ecb700e9c42c95c022d91 --- /dev/null +++ b/ipmd/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/ipmd/static/ipmd/js/ipmdlib.js b/ipmd/static/ipmd/js/ipmdlib.js new file mode 100644 index 0000000000000000000000000000000000000000..a27adba0d5d8412d21548739972c3dd8012f0d81 --- /dev/null +++ b/ipmd/static/ipmd/js/ipmdlib.js @@ -0,0 +1,1121 @@ +/* + * Copyright (c) 2023 NIBIO <http://www.nibio.no/>. + * + * This file is part of VIPSWeb. + * VIPSLogic is free software: you can redistribute it and/or modify + * it under the terms of the NIBIO Open Source License as published by + * NIBIO, either version 1 of the License, or (at your option) any + * later version. + * + * VIPSWeb is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * NIBIO Open Source License for more details. + * + * You should have received a copy of the NIBIO Open Source License + * along with VIPSWeb. If not, see <http://www.nibio.no/licenses/>. + * + */ + +/** + * Utilities and methods for using the IPM Decisions DSS and Weather APIs + * + * Dependencies: + * momentJS + * turf 6 + * OpenLayers 4 + */ + +// TODO Refactor these to be set by the user of this library +const ipmdDSSApiURL = "https://platform.ipmdecisions.net/api/dss/"; +//const ipmdWeatherApiURL = "https://platform.ipmdecisions.net/api/wx/"; +const ipmdWeatherApiURL = "http://ipmdlocal/api/wx/"; + + +/** + * + * @param {String} dssId The id of the DSS + * @param {String} modelId The id of the DSS model + * @returns {Json} metadata for the requested DSS model + */ +async function getModelMetadata(dssId,modelId) { + const response = await fetch(ipmdDSSApiURL + "rest/dss/" + dssId); + const dss = await response.json(); + for(let i=0; i< dss.models.length;i++) + { + let model = dss.models[i]; + if(model.id == modelId) + { + return model; + } + } + return null; +} + +/** + * + * @param {String} dssId The id of the DSS + * @param {String} modelId The id of the DSS model + * @returns the UI form components of the Json schema for input data for the requested model. Hidden fields are excluded. + */ +async function getModelInputSchema(dssId,modelId) { + const response = await fetch(ipmdDSSApiURL + "rest/model/" + dssId + "/" + modelId + "/input_schema/ui_form"); + return await response.json(); +} + +/** + * + * @param {Json} weatherDatasource + * @returns The web service endpoint for the weather data source + */ +function getWeatherDatasourceEndpoint(weatherDatasource) +{ + return weatherDatasource.endpoint.replace("{WEATHER_API_URL}",ipmdWeatherApiURL); +} + +/** + * + * @param {Json} weatherDatasources The list of weather data sources + * @param {String} weatherDatasourceId the id of the requested weather data source + * @returns {Json} Metadata for the requested weather datasource + */ +function getWeatherDatasource(weatherDatasources, weatherDatasourceId) +{ + for(let i=0;i<weatherDatasources.length;i++) + { + if(weatherDatasources[i].id == weatherDatasourceId) + { + return weatherDatasources[i]; + } + } + return null; +} + +/** + * + * @returns {Json} list of all available weather datasources + */ +async function getWeatherDatasources() +{ + const response = await fetch(ipmdWeatherApiURL + "rest/weatherdatasource/"); + return await response.json(); +} + +/** + * + * @param {String} weatherDatasource + * @returns {Array} Weather stations listed as part of this weather data source. Example list item: {"id":"5", "name":"Merrapanna"} + */ +function getWeatherStationList(weatherDatasource){ + geoJson = JSON.parse(weatherDatasource.spatial.geoJSON); + let stationList = []; + for(let i=0;i<geoJson.features.length;i++) + { + let feature = geoJson.features[i]; + let station = {"id":feature.id, "name":feature.properties.name} + stationList.push(station); + } + stationList.sort((a, b) => { + return (a.name < b.name) ? -1 : (a.name > b.name) ? 1 : 0; + }); + return stationList; +} + +/** + * List all weather stations in list in the provided HTML Form select list + * @param {Array} stationList + * @param {HTMLElement} selectList + */ +function renderWeatherStationSelectList(stationList, selectList){ + selectList.options.length = 0; + selectList.add(new Option("-- Please select weather station --", -1)); + for(let i=0;i<stationList.length; i++) + { + selectList.add(new Option(stationList[i].name, stationList[i].id)); + } +} + +/** + * + * @param {String} endpoint URL to web service endpoint + * @param {String} weatherStationId Id of the weather station + * @param {Array} parameters List of weather parameters (IPM Decisions codes) + * @param {Int} interval log interval for the weather data, provided in seconds. E.g. 3600 = hourly, 86400 = daily + * @param {String} dateStart (YYYY-MM-DD) + * @param {String} dateEnd (YYYY-MM-DD) + * @returns weather data from a station based weather data source + */ +async function getStationWeatherData(endpoint, weatherStationId, parameters, interval, dateStart, dateEnd){ + const response = await fetch(endpoint + + "?timeStart=" + dateStart + + "&timeEnd=" + dateEnd + + "&interval=" + interval + + "&weatherStationId=" + weatherStationId + + "¶meters=" + parameters.join(",") + ); + return await response.json(); +} + +/** + * + * @param {String} endpoint URL to web service endpoint + * @param {Float} longitude Decimal degrees (WGS84) for the location + * @param {Float} latitude Decimal degrees (WGS84) for the location + * @param {Array} parameters List of weather parameters (IPM Decisions codes) + * @param {Int} interval log interval for the weather data, provided in seconds. E.g. 3600 = hourly, 86400 = daily + * @param {String} dateStart (YYYY-MM-DD) + * @param {String} dateEnd (YYYY-MM-DD) + * @returns weather data from a location based weather data source + */ +async function getLocationWeatherData(endpoint, longitude, latitude, parameters, interval, dateStart, dateEnd){ + const response = await fetch(endpoint + + "?timeStart=" + dateStart + + "&timeEnd=" + dateEnd + + "&interval=" + interval + + "&longitude=" + longitude + + "&latitude=" + latitude + + "¶meters=" + parameters.join(",") + ); + return (response.status == 200) ? await response.json() : null; +} + +/** + * Run and get results back from a DSS model + * @param {String} endpoint The web service endpont for that DSS model + * @param {Json} inputData as defined by the Json schema in the DSS model metadata property input_data + * @returns {Json} model results in IPM Decisions format + */ +async function runModel(endpoint, inputData) +{ + const response = await fetch(endpoint, { + method: "POST", + mode: "cors", + cache: "no-cache", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(inputData) + }); + return await response.json(); +} + +/** + * Util model for getting an array of consecutive dates + * @param {Integer} timestart UNIX epoch seconds + * @param {Integer} interval log interval in seconds. E.g. 3600 = hourly, 86400 = daily + * @param {Integer} length How many dates you want in the array + * @returns + */ +function getDateArray(timestart, interval, length) +{ + let dateArray = []; + currentTime = moment(timestart); + for(let i=0;i< length; i++) + { + dateArray.push(currentTime.valueOf()); + currentTime.add(interval,'seconds'); + } + return dateArray; +} + +/** + * + * @param {Integer} weatherParameterId the parameter id + * @returns {Json} parameter metadata (name, description, unit, aggregation type (MIN/MAX/AVG)) + */ +function getWeatherParameter(weatherParameterId){ + for(let i=0;i<weatherParameterList.length;i++) + { + if(weatherParameterList[i].id == weatherParameterId) + { + return weatherParameterList[i]; + } + } + return null; +} + +/** + * + * @param {Json} weatherDatasource Metadata for the weather datasource + * @param {String} weatherStationId the requested weather station + * @returns {Array} coordinate (Decimal degrees/WGS84) of the weather station. + */ +function getWeatherStationCoordinate(weatherDatasource, weatherStationId) +{ + geoJson = JSON.parse(weatherDatasource.spatial.geoJSON); + let stationList = []; + for(let i=0;i<geoJson.features.length;i++) + { + let feature = geoJson.features[i]; + if(feature.id == weatherStationId) + { + // TODO: Don't assume feature is a point + return feature.geometry.coordinates; + } + } + + return null; +} + +/** + * Models may require weather parameters that the requested weather datasource + * can't provide. The difference between a requested parameter and available parameter + * may be insignificant. For instance, a model may require average temperature for an hour, + * and the data source may provide instantaneous temperature for that hour. + * + * This methods figures out which parameters are considered fallback parameters for each other, + * and returns the intersection of the requested parameters (including fallbacks) and the available + * parameters + * + * @param {Array} requestedParameters the parameters that the model requires + * @param {Array} availableParameters the parameters that the weather datasource provides + * @returns + */ +function getPragmaticWeatherParameterList(requestedParameters, availableParameters) +{ + return getParametersWithFallbacks(requestedParameters).filter(param => availableParameters.includes(param)); +} + +function getParametersWithFallbacks(parameterList) +{ + let completeList = []; + for(let i=0;i<parameterList.length;i++) + { + completeList = completeList.concat(parameterList[i], fallbackParams[parameterList[i]]); + } + return completeList; +} + +function arePragmaticWeatherParametersInList(requestedParameters, availableParameters) +{ + for(let i=0; i<requestedParameters.length;i++) + { + let requestedParameter = requestedParameters[i]; + // 1. Is the requested parameter in the availableParameters list? + if(! availableParameters.includes(requestedParameter)) + { + if(isEmpty(fallbackParams[requestedParameter])) + { + return false; + } + // If no, check if any fallback parameters are available + let match = false; + + for(let j=0;j<fallbackParams[requestedParameter].length; j++) + { + if(availableParameters.includes(fallbackParams[requestedParameter][j])) + { + match = true; + } + } + if(!match) + { + return false; + } + } + } + + return true; +} + +function getRequiredParameters(modelMetaData) +{ + let parameterList = [] + modelMetaData.input.weather_parameters.forEach(function(weatherParameter){ + parameterList.push(weatherParameter.parameter_code) + }) + return parameterList; +} + +/** + * + * @param {Array} coordinate [longitude,latitude] decimal degrees (WGS84) + * @param {Json} datasource metadata + * @returns + */ +async function isPointCoveredByDatasource(coordinate, datasource) +{ + // If the geoJson is {"type":"Sphere"}, return true + if(datasource.spatial.geoJSON != null && JSON.parse(datasource.spatial.geoJSON).type.toLowerCase() == "sphere") + { + return true; + } + let geoJson = await getDatasourceFeatures(JSON.parse(datasource.spatial.geoJSON), datasource.spatial.countries); + let retVal = false; + for(let i=0; i<geoJson.features.length;i++) + { + if(turf.booleanPointInPolygon(coordinate, geoJson.features[i])) + { + retVal = true; + } + } + return retVal; +} + +/** + * Displays the weather data in a table + * @param {Object} weatherData the weatherData in IPM Decisions format + * @param {HTMLElement} container the element to render to + * @param {String} tableClass for styling the table, e.g. using Bootstrap + */ +function renderWeatherData(weatherData, container, tableClass){ + let dates = getDateArray(weatherData.timeStart, weatherData.interval, weatherData.locationWeatherData[0].length); + let html = "<table" + (tableClass != undefined ? " class=\"" + tableClass + "\"" : "") + "><thead><tr><th>Time</th>"; + weatherData.weatherParameters.forEach(function(weatherParameterId){ + html +="<th>" + getWeatherParameter(weatherParameterId).name + "</th>"; + }); + + html += "</tr></thead>" + html += "<tbody>"; + for(let i=0;i< weatherData.locationWeatherData[0].data.length;i++) + { + html += "<tr><td>" + moment(dates[i]).format("YYYY-MM-DD HH:mm:ss") + "</td>"; + weatherData.locationWeatherData[0].data[i].forEach(function(val){html+="<td>" + val + "</td>";}) + html += "</tr>"; + } + html += "</tbody>"; + html += "</table>"; + container.innerHTML = html; + +} + +/** + * Merges the two datasets. Keeping the primaryData if both sets have data for same time and parameter + * If a merge operation is not possible, primaryData is returned unchanged + * @param {Object} primaryData + * @param {Object} secondaryData + */ +function mergeWeatherData(primaryData, secondaryData) +{ + // Input check + // If one set is null, return the non-null one unchanged + // If both sets are null, return null + if(primaryData == null || secondaryData == null) + { + return primaryData == null ? secondaryData : primaryData; + } + + // If the log interval differs, return primaryData + if(primaryData.interval != secondaryData.interval) + { + return primaryData; + } + + // Initialize the result dataset + let mergedData = { + timeStart: moment(primaryData.timeStart <= secondaryData.timeStart ? primaryData.timeStart : secondaryData.timeStart), + timeEnd: moment(primaryData.timeEnd >= secondaryData.timeEnd ? primaryData.timeEnd : secondaryData.timeEnd), + interval: primaryData.interval, + weatherParameters: function() { + // Copy primaryData's weather parameters + var mergedParams = []; + for(let j=0;j<primaryData.weatherParameters.length;j++) + { + mergedParams.push(primaryData.weatherParameters[j]); + }; + return mergedParams; + }(), + locationWeatherData: [ + { + longitude: primaryData.locationWeatherData[0].longitude, + latitude: primaryData.locationWeatherData[0].latitude + } + ] + }; + + // The parameter ordering may differ between primaryData and secondaryData + // We solve this by mapping the parameter in secondaryData to an index in primaryData + paramIndexes = {}; + secondaryData.weatherParameters.forEach(element => { + if(mergedData.weatherParameters.includes(element)) + { + paramIndexes[element] = mergedData.weatherParameters.indexOf(element); + } + // Checking for fallback parameters + else if(Object.keys(fallbackParams).includes(element.toString())) + { + fallbackParams[element.toString()].forEach(fbElement=>{ + if(mergedData.weatherParameters.includes(fbElement)) + { + paramIndexes[element] = mergedData.weatherParameters.indexOf(fbElement); + } + }); + } + }); + + // Calculate dimensions of the merged data set + let length = (getUnix(mergedData.timeEnd) - getUnix(mergedData.timeStart)) / mergedData.interval; + let width = mergedData.weatherParameters.length; + //console.info(length + "*" + width); + let dataSet = Array.from(Array(length), () => new Array(width)); + // Keeping track of offsets (if dataset does not cover the entire combined period of the merging datasets) + let primaryOffset = (getUnix(primaryData.timeStart) - getUnix(mergedData.timeStart)) / mergedData.interval; + let secondaryOffset = (getUnix(secondaryData.timeStart) - getUnix(mergedData.timeStart)) / mergedData.interval; + let primaryDataSet = primaryData.locationWeatherData[0].data; + let secondaryDataSet = secondaryData.locationWeatherData[0].data; + + // Finally: Merge the data! + for(let i=0;i<dataSet.length;i++) + { + + if(secondaryOffset <= i && i-secondaryOffset < secondaryDataSet.length) + { + for(let j=0; j<secondaryData.weatherParameters.length;j++) + { + if(paramIndexes[secondaryData.weatherParameters[j]] != undefined && !isEmpty(secondaryDataSet[i-secondaryOffset][j])) + { + dataSet[i][paramIndexes[secondaryData.weatherParameters[j]]] = secondaryDataSet[i-secondaryOffset][j]; + } + } + } + if(primaryOffset <= i && i-primaryOffset < primaryDataSet.length ) + { + for(let j=0; j<primaryData.weatherParameters.length;j++) + { + if(!isEmpty(primaryDataSet[i-primaryOffset][j])) + { + dataSet[i][j] = primaryDataSet[i-primaryOffset][j]; + } + } + } + } + + mergedData.locationWeatherData[0].data = dataSet; + return mergedData; +} + +/** + * Util method if you don't know the type of object you're trying to get a Unix timestamp from + * @param {Object} aDate + * @returns {BigInt} the Unix timestamp + */ +function getUnix(aDate) +{ + if(typeof aDate == "string") + { + aDate = moment(aDate); + } + return aDate.unix(); +} + +/** + * Saves a bit of typing. Currently checks if the val is either undefined or null + * @param {Object} val + * @returns {Boolean} true if the value is considered empty + */ +function isEmpty(val) +{ + return val === undefined || val === null; +} + +// Keeping track of maps +var maps = {}; + +/** + * + * @param {String} containerId the container where the map to destroy is anchored + */ +function destroyDataSourceMap(containerId) +{ + let map = maps[containerId]; + // Delete whatever was there + if(map != undefined) + { + map.setTarget(undefined); + map = null; + } +} + +/** + * Requires OpenLayers v. 4 + * @param {String} containerId + * @param {JSON} geoJson + * @param {Array} countryCodeList + * @param {Function} featureClickedCallback Callback function taking parameters ({String} id, {Array<Float>} coordinate) + */ +async function initDataSourceMap(containerId, geoJson, countryCodeList, featureClickedCallback) +{ + + let map = maps[containerId]; + // Delete whatever was there + if(map != undefined) + { + map.setTarget(undefined); + map = null; + } + + // If no data, no map + if((isEmpty(geoJson) || isEmpty(geoJson.features) || geoJson.features.length == 0) && (countryCodeList == null || countryCodeList.length == 0)) + { + document.getElementById(containerId).innerHTML = "NO GEODATA PROVIDED BY WEATHER DATA SOURCE"; + return; + } + else + { + document.getElementById(containerId).innerHTML = ""; + } + + // Using OpenStreetMap as default layer + var backgroundLayer = new ol.layer.Tile({ + source: new ol.source.OSM() + }); + + // Creating the map + map = new ol.Map({ + target: containerId, + layers: [backgroundLayer], + renderer: 'canvas' + }); + + + // Adding it to list of maps, for bookkeeping + maps[containerId] = map; + + // Setting zoom and center for the map (need to do this after creating map. so that we can transform our + // center to correct map projection) + var view = new ol.View({ + center: ol.proj.transform([10,65], 'EPSG:4326', map.getView().getProjection().getCode()), + zoom: 7 + }); + map.setView(view); + + // Read the datasource's geoJson features + let features = new ol.Collection(); + let format = new ol.format.GeoJSON(); + let drawnFeatures = await format.readFeatures( + await getDatasourceFeatures(geoJson, countryCodeList), + { + dataProjection: 'EPSG:4326', + featureProjection: map.getView().getProjection().getCode() + } + ); + + // Add any features to the featureOverlay + let featureOverlay = undefined; + if(drawnFeatures != undefined) + { + // Create an empty layer + featureOverlay = new ol.layer.Vector({ + source: new ol.source.Vector({ + features: features + }), + style: styleUnselected + }); + // Add the stations or area + featureOverlay.getSource().addFeatures(drawnFeatures); + featureOverlay.setMap(map); + + // Fit the features to the extent of the map + extent = featureOverlay.getSource().getExtent(); + map.getView().fit(extent, map.getSize()); + } + + + /* + * A little bit of this and that to make feature selection work the way we want + * - Highlight a selected station (but only one station, multiple select is not allowed) + * - If user clicks on multiple stations, zoom in to make it easy to pick the right one + */ + + let selectInteraction = new ol.interaction.Select({ + toggleCondition: ol.events.condition.never // Only one can be selected at a time + }); + map.addInteraction(selectInteraction); + selectInteraction.on("select", function(e){ + // If it's an area (id == undefined), don't mark it as selected + if(e.target.getFeatures().getLength() > 0 && e.target.getFeatures().item(0).getId() == undefined) + { + e.target.getFeatures().clear(); + } + else if(e.target.getFeatures().getLength() == 1) + { + let feature = e.target.getFeatures().item(0); + featureClickedCallback(feature.getId(), undefined); + } + }); + + // We add this in order to detect if multiple features were clicked on, since the selectInteraction + // does not catch that + map.on('singleclick', function(evt) { + var pixel = map.getEventPixel(evt.originalEvent); + var coordinate = map.getEventCoordinate(evt.originalEvent); + handleMapClicked(map, featureOverlay, pixel, coordinate, featureClickedCallback); + }); +} + +/** + * Get "complete" spatial info from weather datasource, using either provided GeoJson or + * inferred from list of countries + * @param {JSON} geoJson + * @param {Array} countryCodeList + * @returns {Json} GeoJson + */ +async function getDatasourceFeatures(geoJson, countryCodeList) +{ + // If we have geoJson available, we display that + if (geoJson != null && geoJson.features !== undefined && geoJson.features.length > 0) { + return geoJson; + } + return await getCountryBoundaries(countryCodeList); +} + +// Default style for weather stations and areas +const styleUnselected = new ol.style.Style({ + fill: new ol.style.Fill({ + color: 'rgba(173, 173, 173, 0.5)' + }), + stroke: new ol.style.Stroke({ + color: '#000000', + width: 1 + }), + image: new ol.style.Circle({ + radius: 6, + fill: new ol.style.Fill({ + color: '#adadad' + }), + stroke: new ol.style.Stroke({ + color: '#000000', + width: 1 + }) + }) +}); + +/* + * Singleton pattern works. Used in function below + */ + + + +/** + * When a user clicks on the map, handles whatever was clicked (station, point in area, point outside any feature/area) + * @param {ol.map} map + * @param {ol.layer} layer + * @param {ol.Pixel} pixel + * @param {ol.coordinate} coordinate + * @param {Function} featureClickedCallback Callback function taking parameters ({String} id, {Array<Float>} coordinate) + */ +function handleMapClicked(map, layer, pixel, coordinate, featureClickedCallback) { + var features = []; + map.forEachFeatureAtPixel(pixel, function(feature){ + features.push(feature); + }); + + if (features.length == 1) { + // Area + if(features[0].getId() === undefined) + { + // We must place a marker where the user clicked + let marker = map.getOverlayById("marker"); + // If first time click, create a new marker + if(marker == undefined) + { + map.addOverlay(new ol.Overlay({ + id: "marker", + element: function() { + let markerElement = document.createElement("div"); + markerElement.setAttribute( + "style", + "width: 20px; height: 20px; border: 1px solid #088; border-radius: 10px; background-color: #0FF; opacity: 0.5;" + ); + return markerElement + }(), + position: coordinate, + positioning: "center-center" + }) + ); + } + // Otherwise, just move the current marker + else + { + marker.setPosition(coordinate); + } + featureClickedCallback(null, getDecimalDegrees(map, coordinate)); + } + // Click on a station is handled in the select interaction (see initDataSourceMap) + } + // The select interaction (see initDataSourceMap) highlights one and only one + // selected feature. If the clicked pixel has more than one feature, + // zoom in to let the user be able to separate the features + else if(features.length > 1) + { + map.getView().setCenter(coordinate); + map.getView().setZoom(map.getView().getZoom() + 2); + } +}; + +function createMarkerOverlay(coordinate) +{ + +} + +/** + * + * @param {ol.Map} map + * @param {Array<Float>} coordinate + * @return {ol.Coordinate} the pixel coordinate from a map converted to WGS84 Decimal degrees + */ +function getDecimalDegrees(map, coordinate) +{ + return ol.proj.transform(coordinate, map.getView().getProjection().getCode(), 'EPSG:4326'); +} + +async function getCountryBoundaries(countryCodeList) +{ + if(countryCodeList == undefined || countryCodeList == null) + { + return {}; + } + const response = await fetch(ipmdWeatherApiURL + "rest/country/" + countryCodeList.join(",")); + return await response.json(); +} + + +/** + * List of fallback parameters to use if the weather data source does not provide the + * initially requested weather parameter + */ +const fallbackParams = { + 1001: [1002], + 1002: [1001], + 3001: [3002], + 3002: [3001], + 4002: [4003,4012,4013], + 4003: [4002,4013,4012], + 4012: [4013,4002,4003], + 4013: [4012,4003,4002] +} + +/** + * Util method for programmatically selecting an option in a list + * @param {HTMLElement} selectList + * @param {String} optionValue + */ +function setSelection(selectList, optionValue) +{ + for(let i=0;i<selectList.options.length; i++) + { + if(selectList.options[i].value == optionValue) + { + selectList.options[i].selected = true; + } + else + { + selectList.options[i].selected = false; + } + } +} + + +/** + * All the available weather parameters + */ +const weatherParameterList = [ + { + "id": 1001, + "name": "Instantaneous temperature at 2m", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1002, + "name": "Mean air temperature at 2m", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1003, + "name": "Minimum air temperature at 2m", + "description": null, + "unit": "Celcius", + "aggregationType": "MIN" + }, + { + "id": 1004, + "name": "Maximum air temperature at 2m", + "description": null, + "unit": "Celcius", + "aggregationType": "MAX" + }, + { + "id": 1021, + "name": "Instantaneous temperature in canopy", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1022, + "name": "Mean air temperature in canopy", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1023, + "name": "Minimum air temperature in canopy", + "description": null, + "unit": "Celcius", + "aggregationType": "MIN" + }, + { + "id": 1024, + "name": "Maximum air temperature in canopy", + "description": null, + "unit": "Celcius", + "aggregationType": "MAX" + }, + { + "id": 1101, + "name": "Instantaneous temperature at -5cm (Celcius)", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1102, + "name": "Mean temperature at -5cm", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1111, + "name": "Instantaneous temperature at -10cm (Celcius)", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1112, + "name": "Mean temperature at -10cm", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1121, + "name": "Instantaneous temperature at -20cm (Celcius)", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1122, + "name": "Mean temperature at -20cm", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1131, + "name": "Instantaneous temperature at -30cm (Celcius)", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1132, + "name": "Mean temperature at -30cm", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1141, + "name": "Instantaneous temperature at -40cm (Celcius)", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1142, + "name": "Mean temperature at -40cm", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1151, + "name": "Instantaneous temperature at -50cm (Celcius)", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1152, + "name": "Mean temperature at -50cm", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 1901, + "name": "Dew point temperature", + "description": null, + "unit": "Celcius", + "aggregationType": "AVG" + }, + { + "id": 2001, + "name": "Precipitation", + "description": null, + "unit": "mm", + "aggregationType": "SUM" + }, + { + "id": 3001, + "name": "Instantaneous RH at 2m (%)", + "description": null, + "unit": "%", + "aggregationType": "AVG" + }, + { + "id": 3002, + "name": "Mean RH at 2m", + "description": null, + "unit": "%", + "aggregationType": "AVG" + }, + { + "id": 3003, + "name": "Minimum RH at 2m", + "description": null, + "unit": "%", + "aggregationType": "MIN" + }, + { + "id": 3004, + "name": "Maximum RH at 2m", + "description": null, + "unit": "%", + "aggregationType": "MAX" + }, + { + "id": 3021, + "name": "Instantaneous RH in canopy", + "description": null, + "unit": "%", + "aggregationType": "AVG" + }, + { + "id": 3022, + "name": "Mean RH in canopy", + "description": null, + "unit": "%", + "aggregationType": "AVG" + }, + { + "id": 3023, + "name": "Minimum RH in canopy", + "description": null, + "unit": "%", + "aggregationType": "MIN" + }, + { + "id": 3024, + "name": "Maximum RH in canopy", + "description": null, + "unit": "%", + "aggregationType": "MAX" + }, + { + "id": 3101, + "name": "Leaf wetness in 2m (minutes/hour)", + "description": null, + "unit": "minutes/hour", + "aggregationType": "SUM" + }, + { + "id": 3102, + "name": "Leaf wetness in canopy", + "description": null, + "unit": "minutes/hour", + "aggregationType": "SUM" + }, + { + "id": 3103, + "name": "Leaf wetness in grass", + "description": null, + "unit": "minutes/hour", + "aggregationType": "SUM" + }, + { + "id": 4001, + "name": "Wind direction at 2m (degrees 0-360)", + "description": null, + "unit": "degrees", + "aggregationType": "AVG" + }, + { + "id": 4002, + "name": "Instantaneous wind speed at 2m", + "description": null, + "unit": "m/s", + "aggregationType": "AVG" + }, + { + "id": 4003, + "name": "Mean wind speed at 2m", + "description": null, + "unit": "m/s", + "aggregationType": "AVG" + }, + { + "id": 4004, + "name": "Max wind speed at 2m", + "description": null, + "unit": "m/s", + "aggregationType": "MAX" + }, + { + "id": 4005, + "name": "Min wind speed at 2m", + "description": null, + "unit": "m/s", + "aggregationType": "MIN" + }, + { + "id": 4011, + "name": "Wind direction at 10m (degrees 0-360)", + "description": null, + "unit": "degrees", + "aggregationType": "AVG" + }, + { + "id": 4012, + "name": "Instantaneous wind speed at 10m", + "description": null, + "unit": "m/s", + "aggregationType": "AVG" + }, + { + "id": 4013, + "name": "Mean wind speed at 10m", + "description": null, + "unit": "m/s", + "aggregationType": "AVG" + }, + { + "id": 4014, + "name": "Max wind speed at 10m", + "description": null, + "unit": "m/s", + "aggregationType": "MAX" + }, + { + "id": 4015, + "name": "Min wind speed at 10m", + "description": null, + "unit": "m/s", + "aggregationType": "MIN" + }, + { + "id": 5001, + "name": "Solar radiation (Q0) (W/sqm)", + "description": null, + "unit": "W/sqm", + "aggregationType": "SUM" + } +]; \ No newline at end of file diff --git a/ipmd/templates/ipmd/index.html b/ipmd/templates/ipmd/index.html new file mode 100755 index 0000000000000000000000000000000000000000..1e005223c037ad4086fd7d253b6c752395b8b515 --- /dev/null +++ b/ipmd/templates/ipmd/index.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load static %} +{% comment %} + +# +# Copyright (c) 2023 NIBIO <http://www.nibio.no/>. +# +# This file is part of VIPSWeb. +# VIPSWeb is free software: you can redistribute it and/or modify +# it under the terms of the NIBIO Open Source License as published by +# NIBIO, either version 1 of the License, or (at your option) any +# later version. +# +# VIPSWeb is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# NIBIO Open Source License for more details. +# +# You should have received a copy of the NIBIO Open Source License +# along with VIPSWeb. If not, see <http://www.nibio.no/licenses/>. +# + +{% endcomment %} +{% load i18n %} +{% block title%}{% trans "IPM Decisions models" %}{%endblock%} +{% block content %} +<h1>{% trans "IPM Decisions models" %}</h1> +<ul> + <li><a href="/ipmd/saddlegallmidge">{% trans "Saddle gall midge" %}</a></li> +</ul> + +{% endblock %} \ No newline at end of file diff --git a/ipmd/templates/ipmd/saddlegallmidgeform.html b/ipmd/templates/ipmd/saddlegallmidgeform.html new file mode 100644 index 0000000000000000000000000000000000000000..ae1b580c43e7f09475b20625cb51e71823924e98 --- /dev/null +++ b/ipmd/templates/ipmd/saddlegallmidgeform.html @@ -0,0 +1,891 @@ +{% extends "base_with_date_picker.html" %} +{% load static %} +{% comment %} + +# +# Copyright (c) 2023 NIBIO <http://www.nibio.no/>. +# +# This file is part of VIPSWeb. +# VIPSWeb is free software: you can redistribute it and/or modify +# it under the terms of the NIBIO Open Source License as published by +# NIBIO, either version 1 of the License, or (at your option) any +# later version. +# +# VIPSWeb is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# NIBIO Open Source License for more details. +# +# You should have received a copy of the NIBIO Open Source License +# along with VIPSWeb. If not, see <http://www.nibio.no/licenses/>. +# + +{% endcomment %} +{% load i18n %} +{% block title %}{% trans "Saddle gall midge" %}{% endblock %} +{% block content %} +<div class="singleBlockContainer"> + <h1>{% trans "Saddle gall midge" %}</h1> + <div id="inputForm"></div> + <div id="weatherDataForm" style="display: none;"> + <h2>Weather data</h2> + <div class="row"> + <div class="col-md-6"> + <div id="parameterInfo" style="display: none;"></div> + <fieldset id="historicDatasourceFields"> + <legend>Weather datasource (historic)</legend> + <select class="form-control" name="weatherDatasourceId" id="weatherDatasourceList" onchange="handleWeatherDatasourceSelected(this);"></select> + <div id="historicSourceInfoPanel" class="panel panel-default" style="display: none;"> + <div class="panel-body" id="historicSourceInfo"></div> + </div> + </fieldset> + <fieldset id="stationFields" style="display: none;"> + <select class="form-control" name="weatherStationId" id="weatherStationId" onchange="handleWeatherStationSelected(this);" ></select> + </fieldset> + <fieldset id="locationFields"> + <legend>Location (decimal degrees)</legend> + <div class="form-group"> + <label for="latitude">Latitude</label> + <input type="number" class="form-control" id="latitude" name="latitude" placeholder="Latitude"> + </div> + <div class="form-group"> + <label for="longitude">Longitude</label> + <input type="number" class="form-control" id="longitude" name="longitude" placeholder="Longitude"> + </div> + </fieldset> + </div> + <div class="col-md-6"><div id="historicDatasourceMap"></div></div> + </div> + <div class="row"> + <div class="col-md-6"> + <fieldset id="forecastDatasourceFields"> + <legend>Weather forecasts datasource</legend> + <select class="form-control" name="forecastWeatherDatasourceId" id="forecastWeatherDatasourceList" onchange="handleForecastSourceSelected(this);"></select> + <div id="forecastSourceInfoPanel" class="panel panel-default" style="display: none;"> + <div class="panel-body" id="forecastSourceInfo"></div> + </div> + </fieldset> + </div> + <div class="col-md-6"><div id="forecastDatasourceMap"></div></div> + </div> + <button class="btn btn-primary" type="button" onclick="submitData();">Submit</button> + <div style="aspect-ratio: 2;"> + <canvas id="resultChart"></canvas> + </div> + <div id="weatherData" class="table-responsive"></div> + <pre id="modelDescription"></pre> +</div> +{% endblock %} + +{% block extendCSS %} +<link rel="stylesheet" href="{% static "css/3rdparty/ol.css" %}" type="text/css"> +{% endblock%} + +{% block customJS %} +<script src="https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js"></script> +<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> +<script src='https://unpkg.com/@turf/turf@6/turf.min.js'></script> +<script type="text/javascript" src="{% static "js/3rdparty/moment.min.js" %}"></script> +<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment"></script> +<script type="text/javascript" src="{% static "js/3rdparty/ol-debug.js" %}"></script> +<script type="text/javascript" src="{% static "ipmd/js/ipmdlib.js" %}"></script> +<script type="text/javascript"> + // Page globals + var currentModelMetaData = undefined; + var currentWeatherDatasource = undefined; + var currentForecastWeatherDatasource = undefined; + var weatherDatasources = undefined; + var editor = undefined; + var selectList = document.getElementById("weatherStationId"); + + var weatherData = undefined; + + async function initPage() { + currentModelMetaData = await getModelMetadata("adas.dss","HAPDMA"); + document.getElementById("modelDescription").innerHTML= await currentModelMetaData["description"]; + inputFormSchema = await getModelInputSchema("adas.dss","HAPDMA") + inputFormSchema.title ="Model input data"; + const element = document.getElementById('inputForm'); + + // See https://github.com/json-editor/json-editor#options + editor = new JSONEditor(element, + { + ajax: true, + schema: inputFormSchema, + theme: "bootstrap4", + iconlib: "fontawesome4", + disable_edit_json: true, + disable_properties: true + }); + let fullSchema = JSON.parse(currentModelMetaData["execution"]["input_schema"]); + if(fullSchema["properties"]["weatherData"] !== undefined) + { + // Display the required weather parameters + let requiredParameters = getRequiredParameters(currentModelMetaData); + let paramHTML = "<h4>Parameters required by the model</h4><ul>"; + for(let i=0;i<requiredParameters.length;i++) + { + let param = getWeatherParameter(requiredParameters[i]); + paramHTML += "<li>" + param.name + "</li>"; + } + paramHTML += "</ul>" + + let parameterInfo = document.getElementById("parameterInfo"); + parameterInfo.innerHTML = paramHTML; + parameterInfo.style.display = "block"; + + // Pull weather data sources from web service, render lists (historic and forecast sources, some are both) + weatherDatasources = await getWeatherDatasources(); + //console.info(weatherDatasources); + weatherDatasources.sort((a, b) => { + return (a.name < b.name) ? -1 : (a.name > b.name) ? 1 : 0; + }); + let weatherDatasourceList = document.getElementById("weatherDatasourceList"); + weatherDatasourceList.add(new Option("Please select a weather datasource", "-1")); + for(let i=0;i<weatherDatasources.length; i++) + { + if(weatherDatasources[i].temporal.historic != null && weatherDatasources[i].temporal.historic.start != null) + { + weatherDatasourceList.add(new Option(weatherDatasources[i].name, weatherDatasources[i].id)); + } + } + let forecastWeatherDatasourceList = document.getElementById("forecastWeatherDatasourceList"); + forecastWeatherDatasourceList.add(new Option("Please select a weather forecast datasource", "-1")); + for(let i=0;i<weatherDatasources.length; i++) + { + if(weatherDatasources[i].temporal.forecast != null && weatherDatasources[i].temporal.forecast > 0) + { + forecastWeatherDatasourceList.add(new Option(weatherDatasources[i].name, weatherDatasources[i].id)); + } + } + + document.getElementById("weatherDataForm").style.display = "block"; + + } + + // TODO: Remove this auto-mock!! + /*editor.getValue().optionalData.startDate="2023-08-01"; + editor.getValue().optionalData.endDate="2023-08-19"; + document.getElementById("longitude").value="11.781989"; + document.getElementById("latitude").value="59.680468";*/ + //submitData(); + } + + initPage(); + + /** + * + * When the user selects a weather datasource: + * - Displays the description from the weather datasources's metadata + * - Displays a map showing the stations or area OR nothing - depending on the metadata's "spatial" property + * + * + * @param {HTMLElement} weatherDatasourcelist - the select list + */ + function handleWeatherDatasourceSelected(weatherDatasourceList){ + currentWeatherDatasource = getWeatherDatasource( + weatherDatasources, + weatherDatasourceList.options[weatherDatasourceList.selectedIndex].value + ); + let sourceInfoPanel = document.getElementById("historicSourceInfoPanel"); + if(currentWeatherDatasource == null) + { + sourceInfoPanel.style.display="none"; + document.getElementById("stationFields").style.display = "none"; + destroyDataSourceMap("historicDatasourceMap"); + return; + } + + // Display map + initDataSourceMap( + "historicDatasourceMap", + JSON.parse(currentWeatherDatasource.spatial.geoJSON), + currentWeatherDatasource.spatial.countries, + handleHistoricDatasourceMapClicked + ); + + let sourceInfo = document.getElementById("historicSourceInfo"); + let sourceHTML = currentWeatherDatasource.description; + sourceHTML += "<h4>Provided weather parameters</h4><ul>"; + for(let i=0;i<currentWeatherDatasource.parameters.common.length;i++) + { + sourceHTML += "<li>" + getWeatherParameter(currentWeatherDatasource.parameters.common[i]).name + "</li>"; + } + sourceHTML += "</ul>"; + + if(!isEmpty(currentWeatherDatasource.parameters.optional) && currentWeatherDatasource.parameters.optional.length > 0) + { + sourceHTML += "<h5>Optional parameters (some weather stations)</h5><ul>"; + for(let i=0;i<currentWeatherDatasource.parameters.optional.length;i++) + { + sourceHTML += "<li>" + getWeatherParameter(currentWeatherDatasource.parameters.optional[i]).name + "</li>"; + } + sourceHTML += "</ul>"; + } + + sourceInfo.innerHTML = sourceHTML; + sourceInfoPanel.style.display="block"; + if(currentWeatherDatasource.access_type == "stations") + { + // Display the weather stations + renderWeatherStationSelectList(getWeatherStationList(currentWeatherDatasource), selectList); + document.getElementById("stationFields").style.display = "block"; + } + else + { + // Location based API + // Display lat/lon input fields + document.getElementById("stationFields").style.display = "none"; + } + + // Check that this datasource can deliver the model's requested parameters + if(!arePragmaticWeatherParametersInList(getRequiredParameters(currentModelMetaData), currentWeatherDatasource.parameters.common)) + { + alert("WARNING: The selected datasource can't deliver all weather parameters required by the DSS"); + } + } + + /** + * This is a callback method passed on to impdlib.initDataSourceMap. When the user clicks on + * - For station based datasources: a weather station + * -> the correct station is selected in the station list + * -> The station's location is set in the latitude/longitude form fields + * + * - For gridded datasources: Anywhere inside the grid area + * -> The location selected by the user is set in the latitude/longitude form fields + * + * @param {String} id the weatherstation id NULL => gridded weather datasource + * @param {Array} [lon,lat] (decimal degrees) of the clicked location. NULL => station based weather datasource + */ + function handleHistoricDatasourceMapClicked(id, coordinate) + { + // id != null => station. Pull station coordinates from source list to avoid inacurracies in user's map click coordinate + if(id !== null) + { + // Set the selected station + setSelection(selectList, id); + handleWeatherStationSelected(selectList); + } + // id == null => area. Use the provided coordinate + else + { + setLatLon(coordinate); + } + } + + + + /** + * Shows datasource info and displays map + * Alerts if the selected forecast data source does not cover the selected location + */ + async function handleForecastSourceSelected(forecastSourceSelectList) + { + currentForecastWeatherDatasource = await getWeatherDatasource( + weatherDatasources, + forecastWeatherDatasourceList.options[forecastWeatherDatasourceList.selectedIndex].value + ); + + let sourceInfoPanel = document.getElementById("forecastSourceInfoPanel"); + // No datasource selected? Hide info panel and map and return + if(currentForecastWeatherDatasource == null) + { + sourceInfoPanel.style.display="none"; + destroyDataSourceMap("forecastDatasourceMap"); + return; + } + + let geoJson = JSON.parse(currentForecastWeatherDatasource.spatial.geoJSON); + + // Display map + initDataSourceMap( + "forecastDatasourceMap", + geoJson, + currentForecastWeatherDatasource.spatial.countries, + function(){} // We don't do nothing, right? + ); + + let sourceInfo = document.getElementById("forecastSourceInfo"); + let sourceHTML = currentForecastWeatherDatasource.description; + sourceHTML += "<h4>Provided weather parameters</h4><ul>"; + for(let i=0;i<currentForecastWeatherDatasource.parameters.common.length;i++) + { + sourceHTML += "<li>" + getWeatherParameter(currentForecastWeatherDatasource.parameters.common[i]).name + "</li>"; + } + sourceHTML += "</ul>"; + + sourceInfo.innerHTML = sourceHTML; + sourceInfoPanel.style.display="block"; + // Does the forecast data source cover the point in question? + if(! await isPointCoveredByDatasource(getLatLon(), currentForecastWeatherDatasource)) + { + alert("The selected forecast datasource does not cover the selected location."); + } + // Check that this datasource can deliver the model's requested parameters + if(!arePragmaticWeatherParametersInList(getRequiredParameters(currentModelMetaData), currentForecastWeatherDatasource.parameters.common)) + { + alert("WARNING: The selected forecast datasource can't deliver all weather parameters required by the DSS"); + } + } + + /** + * Pulls the coordinates of the selected weather station from the metadata, + * enters it in the form fields + */ + function handleWeatherStationSelected(weatherStationSelectList) + { + let weatherStationId = selectList.options[selectList.selectedIndex].value; + let stationCoordinate = getWeatherStationCoordinate(currentWeatherDatasource, weatherStationId); + setLatLon(stationCoordinate); + } + + /** + * Populate the form fields with the given coordinate + */ + function setLatLon(coordinate) + { + document.getElementById("longitude").value = coordinate[0]; + document.getElementById("latitude").value = coordinate[1]; + } + + /** + * @returns {Array} [longitude,latitude] from the form fields + */ + function getLatLon() + { + return [document.getElementById("longitude").value, document.getElementById("latitude").value]; + } + + /** + * Collects the input data into Json that conforms to the DSS model's Json schema + * Also collects weather data (which goes into the input data Json) + */ + async function submitData(){ + let inputData = editor.getValue(); + // Add hidden parameters + let fullSchema = JSON.parse(currentModelMetaData["execution"]["input_schema"]); + const hiddenParameters = currentModelMetaData["execution"]["input_schema_categories"]["hidden"]; + for(let i=0;i<hiddenParameters.length;i++) + { + let hiddenParameter = hiddenParameters[i]; + inputData[hiddenParameter] = fullSchema["properties"][hiddenParameter]["default"]; + } + // Check for weatherData element. Assuming it's at the root node + if(fullSchema["properties"]["weatherData"] !== undefined) + { + let forecastData = undefined; + // 1. Historic weather data + if(currentWeatherDatasource != undefined) + { + if(currentWeatherDatasource.access_type == "stations") + { + let weatherStationId = selectList.options[selectList.selectedIndex].value; + + weatherData = await getStationWeatherData( + getWeatherDatasourceEndpoint(currentWeatherDatasource), + weatherStationId, + getPragmaticWeatherParameterList( + function (){ + let parameterList = [] + currentModelMetaData.input.weather_parameters.forEach(function(weatherParameter){ + parameterList.push(weatherParameter.parameter_code) + }) + return parameterList; + }(), + currentWeatherDatasource.parameters.common + ), + 3600, + inputData.optionalData.startDate, + inputData.optionalData.endDate, + ); + + } + else + { + coordinate = getLatLon(); + // Need to check that the selected datasource covers the requested location + if(! await isPointCoveredByDatasource(coordinate, currentWeatherDatasource)) + { + alert("ERROR: The selected historic datasource does not cover your selected location. Please select an appropriate location or datasource."); + return; + } + weatherData = await getLocationWeatherData( + getWeatherDatasourceEndpoint(currentWeatherDatasource), + coordinate[0], + coordinate[1], + getPragmaticWeatherParameterList( + function (){ + let parameterList = [] + currentModelMetaData.input.weather_parameters.forEach(function(weatherParameter){ + parameterList.push(weatherParameter.parameter_code) + }) + return parameterList; + }(), + currentWeatherDatasource.parameters.common + ), + 3600, + inputData.optionalData.startDate, + inputData.optionalData.endDate, + ); + } + } + + // 2. Forecast weather data + if(currentForecastWeatherDatasource != undefined) + { + // Need to check that the selected datasource covers the requested location + if(! await isPointCoveredByDatasource(getLatLon(), currentForecastWeatherDatasource)) + { + alert("ERROR: The selected forecast datasource does not cover your selected location. Please select an appropriate location or datasource."); + return; + } + forecastData = await getLocationWeatherData( + getWeatherDatasourceEndpoint(currentForecastWeatherDatasource), + document.getElementById("longitude").value, + document.getElementById("latitude").value, + getPragmaticWeatherParameterList( + function (){ + let parameterList = [] + currentModelMetaData.input.weather_parameters.forEach(function(weatherParameter){ + parameterList.push(weatherParameter.parameter_code) + }) + return parameterList; + }(), + currentForecastWeatherDatasource.parameters.common + ), + 3600, + inputData.optionalData.startDate, + inputData.optionalData.endDate, + ); + // Merge if both historic and forecast data have been collected + if(weatherData == undefined) + { + weatherData = forecastData; + } + else + { + // Is useless until method is implemented + weatherData = mergeWeatherData(weatherData, forecastData); + } + } + if(weatherData != null) + { + inputData["weatherData"] = weatherData; + } + else + { + alert("ERROR: Could not get weather data. Please check if your datasource covers your location."); + return; + } + } + // Ready to call server? + //let result = mockResult; + let result = await runModel(currentModelMetaData.execution.endpoint, inputData) + displayResult(result); + }; + + /** + * Render results in a chart + */ + function displayResult(result) { + + let chartData = []; + // Generate dates + let dates = getDateArray(result.timeStart, 86400, result.locationResult[0].length); + + for(let i=0; i< result.resultParameters.length;i++) + { + let dataset = {label:result.resultParameters[i], data: []} + for(let j=0;j<dates.length;j++) + { + dataset.data.push({x:dates[j],y:result.locationResult[0].data[j][i]}); + } + chartData.push(dataset); + } + + const ctx = document.getElementById('resultChart'); + + new Chart(ctx, { + type: "line", + options: { + responsive: true, + scales: { + x: { + type: "time", + time: { + //unit: "day", + diplayFormats: { + day: "YYYY-MM-DD" // This doesn't seem to have any effect. Why?? + } + } + } + } + }, + data: { + datasets: chartData + } + }) + renderWeatherData(weatherData, document.getElementById("weatherData"), "table table-striped") + } + + + + // Mock result!!! Waiting for ADAS to fix CORS issue + const mockResult={ + "timeStart": "2023-06-30T22:00:00+00:00", + "timeEnd": "2023-08-10T22:00:00+00:00", + "interval": 86400, + "resultParameters": [ + "Cumulative_DayDegrees", + "StartOfEmergenceThreshold", + "FirstThreshold", + "SecondThreshold", + "ThirdThreshold" + ], + "locationResult": [ + { + "longitude": 10.62687, + "latitude": 62.10944, + "altitude": 478.0, + "data": [ + [ + 12.957083333333332, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 26.471249999999998, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 38.164583333333333, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 50.12833333333333, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 63.424166666666665, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 76.315416666666664, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 90.9525, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 106.4175, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 124.07208333333334, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 143.23041666666666, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 158.54916666666665, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 173.02458333333331, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 186.06041666666664, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 198.31124999999997, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 210.43499999999997, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 224.8820833333333, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 239.05541666666665, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 251.71874999999997, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 263.06991666666664, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 274.32391666666666, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 284.68975, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 296.90266666666668, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 309.94766666666669, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 322.764, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 334.34983333333332, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 346.98191666666668, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 359.79066666666665, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 372.52816666666666, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 386.75358333333332, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 402.21066666666667, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 416.82858333333331, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 428.63025, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 442.98983333333331, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 456.554, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 470.31691666666666, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 483.14233333333334, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 497.56275, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 508.33566666666667, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 521.79775, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 533.56191666666666, + 500.0, + 750.47, + 1023.93, + 1500.0 + ], + [ + 545.3566992753623, + 500.0, + 750.47, + 1023.93, + 1500.0 + ] + ], + "warningStatus": [ + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 2, + 4, + 4, + 4, + 4 + ], + "width": 5, + "length": 41 + } + ], + "message": "Start of cumulative emergence: 07/08/2023 .", + "messageType": 0 + }; + +</script> +{% endblock %} \ No newline at end of file diff --git a/ipmd/tests.py b/ipmd/tests.py new file mode 100644 index 0000000000000000000000000000000000000000..7ce503c2dd97ba78597f6ff6e4393132753573f6 --- /dev/null +++ b/ipmd/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/ipmd/urls.py b/ipmd/urls.py new file mode 100755 index 0000000000000000000000000000000000000000..f076576d0af1a217592b2cfe4d0aa935b34e70c2 --- /dev/null +++ b/ipmd/urls.py @@ -0,0 +1,28 @@ +# +# Copyright (c) 2023 NIBIO <http://www.nibio.no/>. +# +# This file is part of VIPSWeb. +# VIPSWeb is free software: you can redistribute it and/or modify +# it under the terms of the NIBIO Open Source License as published by +# NIBIO, either version 1 of the License, or (at your option) any +# later version. +# +# VIPSWeb is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# NIBIO Open Source License for more details. +# +# You should have received a copy of the NIBIO Open Source License +# along with VIPSWeb. If not, see <http://www.nibio.no/licenses/>. +# + +from django.urls import re_path +from ipmd import views + +app_name = "ipmd" + +urlpatterns = [ + # ex: /forecasts/ + re_path(r'^$', views.index, name='index'), + re_path(r'saddlegallmidge/', views.saddlegallmidgeform, name='saddlegallmidgeform') +] \ No newline at end of file diff --git a/ipmd/views.py b/ipmd/views.py new file mode 100644 index 0000000000000000000000000000000000000000..8f147afb1e7c08c34243d1d064549172b45ef64f --- /dev/null +++ b/ipmd/views.py @@ -0,0 +1,11 @@ +from django.shortcuts import render + +# Create your views here. + +def index(request): + context = {} + return render(request, 'ipmd/index.html', context) + +def saddlegallmidgeform(request): + context = {} + return render(request, 'ipmd/saddlegallmidgeform.html', context) \ No newline at end of file