using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; using System.Threading.Tasks; using FarmmapsApi; using FarmmapsApi.Models; using FarmmapsApi.Services; using FarmmapsBulkSatDownload.Models; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Npgsql; using Newtonsoft.Json.Linq; using static FarmmapsApiSamples.Constants; using System.Text; namespace FarmmapsBulkSatDownload { public class BulkSatDownloadApplication : IApplication { private readonly ILogger _logger; private readonly FarmmapsApiService _farmmapsApiService; private readonly BulkSatDownloadService _bulkSatDownloadService; private readonly GeneralService _generalService; public const string settingsfile = "Settings.json"; private Settings _settings; public BulkSatDownloadApplication(ILogger logger, FarmmapsApiService farmmapsApiService, GeneralService generalService, BulkSatDownloadService bulkSatDownloadService) { _logger = logger; _farmmapsApiService = farmmapsApiService; _generalService = generalService; _bulkSatDownloadService = bulkSatDownloadService; } public async Task RunAsync() { // Check if we have permission // !! this call is needed the first time an api is called with a fresh clientid and secret !! await _farmmapsApiService.GetCurrentUserCodeAsync(); var roots = await _farmmapsApiService.GetCurrentUserRootsAsync(); BulkSatDownloadInput bulkSatDownloadInput; List bulkSatDownloadInputList; // Below are two options to for bulk download: (1) from and to database or (2) inputs from json, output to csv // For illustration we make two lists bulkSatDownloadInputListDB and bulkSatDownloadInputListCsv and then choose which one we will use List bulkSatDownloadInputListDB; List bulkSatDownloadInputListCsv; DateTime lastdownloadedimagedate; int cropYear; // Option 1: When using database need to (1) fill in database data in DBsettings.secrets.json; (2) write tailor made SELECT query for fieldinputs in following lines; // (3) Write tailor made INSERT INTO query in Task Process() below; // Initialize databases. Username, password etc stored in file "DBsettings.secrets.json". // Crashes if "DBsettings.secrets.json" is absent or empty DB dbparcels = JsonConvert.DeserializeObject(File.ReadAllText("DBsettings.secrets.json")); string schemaname = "bigdata"; string parceltablename = "parcel_disac";//"parcelsijbrandij" "parcel"; "parcel_flowerbulbs"; "parcel_disac" string groenmonitortablename = "groenmonitor_disac";//"groenmonitorsijbrandij" "groenmonitor" "groenmonitor_flowerbulbs" "groenmonitor_disac" // The view 'groenmonitorlatestviewname' contains per parcelid (arbid) the year in which it "exists" and the date of the latest image downloaded. It is used to prevent unneccessary downloading of image statistics already in the database string groenmonitorlatestviewname = "groenmonitorlatest_disac"; //"groenmonitorsijbrandijlatest" "groenmonitorlatest" "groenmonitorlatest_flowerbulbs" "groenmonitorlatest_disac" //GroenmonitorTable gmtb; // Database query and connection. Geometry must be in WGS84 coordinate system, EPSG 4326 // Apparently the FarmmapsApi cannot handle MultiPolygon, so we need to convert to single Polygon // In case database returns a MultiPolygon use ST_NumGeometries(pt.geom) to count the number of polygons // If necessary use WHERE T_NumGeometries(pt.geom) = 1 to select only single polygons // // FarmMaps get's its satellite images from www.groenmonitor.nl through the https://agrodatacube.wur.nl/. // Many images are available at www.groenmonitor.nl, the https://agrodatacube.wur.nl/ serves only the clean images, 10-30 per year, 2019 onwards. Possibly more images will be added for earlier years // For other images contact www.groenmonitor.nl, gerbert.roerink@wur.nl bulkSatDownloadInputListDB = new List(); List satelliteBands = new List { "wdvi", "ndvi" }; string connectionString = dbparcels.GetConnectionString(); string readSql = string.Format( @" SELECT pt.arbid, pt.year, gml.lastwenrdate, ST_AsGeoJSON(ST_Transform((ST_DUMP(pt.geom)).geom::geometry(Polygon),4326)) AS geojson_polygon_wgs84, COALESCE(pt.cropfielditemcode,'') AS cropfielditemcode FROM {0}.{1} pt, {0}.{2} gml WHERE pt.arbid = gml.arbid ORDER BY pt.arbid LIMIT 15;", schemaname, parceltablename, groenmonitorlatestviewname); //LIMIT x for testing using (NpgsqlConnection connection = new NpgsqlConnection(connectionString)) { connection.Open(); // Read data (run query) = build a list of fields for which to download images NpgsqlCommand command = connection.CreateCommand(); command.CommandText = readSql; NpgsqlDataReader dr = command.ExecuteReader(); while (dr.Read()) { bulkSatDownloadInput = new BulkSatDownloadInput(); bulkSatDownloadInput.fieldID = dr.GetInt16(0); bulkSatDownloadInput.fieldName = string.Format($"{parceltablename}_{bulkSatDownloadInput.fieldID}"); bulkSatDownloadInput.cropYear = dr.GetInt16(1); ; bulkSatDownloadInput.lastdownloadedimagedate = dr.GetDateTime(2); bulkSatDownloadInput.GeometryJson = JObject.Parse(dr.GetString(3)); bulkSatDownloadInput.SatelliteBands = satelliteBands; bulkSatDownloadInput.cropfielditemcode = dr.GetString(4); bulkSatDownloadInput.database = dbparcels; bulkSatDownloadInput.schemaname = schemaname; bulkSatDownloadInput.cropfieldtable = parceltablename; bulkSatDownloadInput.satelllitetable = groenmonitortablename; bulkSatDownloadInputListDB.Add(bulkSatDownloadInput); } connection.Close(); } // Option 2: Example without database. Comment out this part if you want to use database // Read cropfields "BulkSatDownloadInput.json" and write all stats to a single csv file // Write all stats for multiple fields will be written to a single csv file string downloadFolder; string fileNameStats; string headerLineStats = $"FieldName,satelliteDate,satelliteBand,max,min,mean,mode,median,stddev,minPlus,curtosis,maxMinus,skewness,variance,populationCount,variationCoefficient,confidenceIntervalLow, confidenceIntervalHigh,confidenceIntervalErrorMargin" + Environment.NewLine; var fieldsInputJson = File.ReadAllText("BulkSatDownloadInput.json"); bulkSatDownloadInputListCsv = JsonConvert.DeserializeObject>(fieldsInputJson); for (int i = 0; i < bulkSatDownloadInputListCsv.Count; i++) { downloadFolder = bulkSatDownloadInputListCsv[i].downloadFolder; fileNameStats = Path.Combine(downloadFolder, bulkSatDownloadInputListCsv[i].fileNameStats); if (!Directory.Exists(downloadFolder)) Directory.CreateDirectory(downloadFolder); bulkSatDownloadInputListCsv[i].fileNameStats = fileNameStats; // Header same as in GeneralService.DownloadSatelliteStats // Delete fileNameStats if existing. Create a new file. Add a header to csv file File.Delete(fileNameStats); File.AppendAllText(fileNameStats, headerLineStats); } // Now choose which list you want to use bulkSatDownloadInputList = bulkSatDownloadInputListDB; //bulkSatDownloadInputListDB; //bulkSatDownloadInputListCsv; // Whichever option (database or json/csv), continue here // Delete the settingsfile File.Delete(settingsfile); // For each input download all images. Keep track to time, important when doing bulk downloads var watch = System.Diagnostics.Stopwatch.StartNew(); TimeSpan tsSofar = new TimeSpan(); TimeSpan tsRemaining; TimeSpan tsTotalEstimated; for (int i = 0; i < bulkSatDownloadInputList.Count; i++) { watch.Restart(); bulkSatDownloadInput = bulkSatDownloadInputList[i]; if (string.IsNullOrEmpty(bulkSatDownloadInput.fileNameStats) == false) _logger.LogInformation(string.Format($"// FarmmapsBulkSatDownload: Downloading stats for field {i+1} out of {bulkSatDownloadInputList.Count} to single csv file {bulkSatDownloadInput.fileNameStats}")); if (bulkSatDownloadInput.database != null) _logger.LogInformation(string.Format($"// FarmmapsBulkSatDownload: Downloading stats for field {i+1} out of {bulkSatDownloadInputList.Count} to database {bulkSatDownloadInput.schemaname}.{bulkSatDownloadInput.satelllitetable}")); try { await Process(roots, bulkSatDownloadInput); } catch (Exception ex) { _logger.LogError(ex.Message); } watch.Stop(); tsSofar = tsSofar + watch.Elapsed; tsTotalEstimated = tsSofar / (i + 1) * bulkSatDownloadInputList.Count; tsRemaining = tsTotalEstimated - tsSofar; _logger.LogInformation(string.Format($"// Time (hh:mm:ss): this field: {strTime(watch.Elapsed)}. Sofar: {strTime(tsSofar)}. Total: {strTime(tsTotalEstimated)}. Remaining: {strTime(tsRemaining)}")); } string strExeFilePath = System.Reflection.Assembly.GetExecutingAssembly().Location; string strWorkPath = Path.GetDirectoryName(strExeFilePath); _logger.LogInformation(string.Format($"// FarmmapsBulkSatDownload:")); _logger.LogInformation(string.Format($"// FarmmapsBulkSatDownload: Done! List of all downloaded cropfieldItems stored in {Path.Combine(strWorkPath,settingsfile)}")); _logger.LogInformation(string.Format($"// FarmmapsBulkSatDownload: If you plan to rerun certain fields then adding cropfielditemcode to your input can greatly speed up your application!")); } private async Task Process(List roots, BulkSatDownloadInput input) { string cropfielditemcode; Item cropfieldItem; bool satelliteItemsAvailable; bool statsAvailable; DateTime dtSatelliteDate; string strSatelliteDate; List satelliteItemsCropYear; GroenmonitorTable groenmonitorTable = new GroenmonitorTable(); List listSatelliteStatistics; SatelliteStatistics satelliteStatistics_wdvi; SatelliteStatistics satelliteStatistics_ndvi; int cntDatesDownloaded; string fieldName = input.fieldName; int cropYear = input.cropYear; List satelliteBands = input.SatelliteBands; string downloadFolder = input.downloadFolder; string fileNameStats = input.fileNameStats; DB database = input.database; string schemaname = input.schemaname; string cropfieldtable = input.cropfieldtable; string satelllitetable = input.satelllitetable; DateTime lastDownloadedSatelliteDate = input.lastdownloadedimagedate; cropfielditemcode = input.cropfielditemcode; string insertSql = InsertSQLfromClass(schemaname, satelllitetable); LoadSettings(settingsfile); var uploadedRoot = roots.SingleOrDefault(r => r.Name == "Uploaded"); if (uploadedRoot == null) { _logger.LogError("Could not find a needed root item"); return; } var myDriveRoot = roots.SingleOrDefault(r => r.Name == "My drive"); if (myDriveRoot == null) { _logger.LogError("Could not find a needed root item"); return; } if (string.IsNullOrEmpty(cropfielditemcode)) { _logger.LogInformation(string.Format($"Creating cropfield '{fieldName}' in the year {cropYear}")); cropfieldItem = await _generalService.CreateCropfieldItemAsync(myDriveRoot.Code, $"DataCropfield {fieldName}", cropYear, input.GeometryJson.ToString(Formatting.None)); cropfielditemcode = cropfieldItem.Code; // If working with a database, add this cropfieldItem.Code to the database so that next case same cropField is requested, will be faster if (database != null) { // add this CropfieldItemCode to the parceltable using (NpgsqlConnection connection = new NpgsqlConnection(database.GetConnectionString())) { connection.Open(); NpgsqlCommand updateCmd = connection.CreateCommand(); string updateSql = string.Format($"UPDATE {schemaname}.{cropfieldtable} SET cropfielditemcode = '{cropfieldItem.Code}' WHERE arbid = {input.fieldID};"); updateCmd.CommandText = updateSql; //Console.WriteLine(insertCmd.CommandText); int r = updateCmd.ExecuteNonQuery(); if (r != 1) throw new Exception("// FarmmapsBulkSatDownload: Insert Failed"); connection.Close(); } _logger.LogInformation($"// FarmmapsBulkSatDownload: Added cropfieldItem.Code '{cropfieldItem.Code}' for parcelid {input.fieldID} to {schemaname}.{cropfieldtable} "); } } else { // WOULD IT BE POSSIBLE TO GET AVAILABLE Item MEMBER VALUES FOR A GIVEN cropfielditemcode? cropfieldItem = new Item(); cropfieldItem.Code = cropfielditemcode; cropfieldItem.Name = "DataCropfield " + fieldName; _logger.LogInformation($"// FarmmapsBulkSatDownload: CropfieldItem.Code for parcelid {input.fieldID} already there in {schemaname}.{cropfieldtable}: '{cropfieldItem.Code}'"); } _settings.cropFieldItems.Add(cropfieldItem); SaveSettings(settingsfile); //Create satelliteTaskCode & save satelliteTaskCode.Code to settingsfile for retracing last call (can be useful if failed) _logger.LogInformation(string.Format($"Running RunSatelliteTask for cropfieldItem '{cropfielditemcode}' and saving settings to {settingsfile}")); var satelliteTaskCode = await _generalService.RunSatelliteTask(cropfieldItem); // POSSIBLE & DESIRABLE TO ALSO LOG satelliteTaskCode? // SaveSettings(settingsfile); // Getting satellite items _logger.LogInformation(string.Format($"Running FindSatelliteItemsCropYear for cropfieldItem.Code '{cropfieldItem.Code}', SatelliteTaskCode '{satelliteTaskCode}'")); satelliteItemsCropYear = await _generalService.FindSatelliteItems(cropfieldItem, satelliteTaskCode); // Checking if satellite items found satelliteItemsAvailable = true; if (satelliteItemsCropYear == null) { satelliteItemsAvailable = false; _logger.LogInformation($"No satellite tiffs found for fieldName '{fieldName}', cropYear {cropYear}"); } else { if (satelliteItemsCropYear.Count == 0) { satelliteItemsAvailable = false; _logger.LogInformation($"No satellite tiffs found for fieldName '{fieldName}', cropYear {cropYear}"); } } // Sort the list by date if (satelliteItemsAvailable) satelliteItemsCropYear = satelliteItemsCropYear.OrderBy(x => x.DataDate).ToList(); // Download statistics to a single csv file if (satelliteItemsAvailable && downloadFolder != null && fileNameStats != null) { // Write statistics for all images for all fieldNane and cropYear to a single csv file, fileNameStats _logger.LogInformation($"Downloading stats for field '{fieldName}' in cropyear {cropYear} to {fileNameStats}"); string downloadedStats = await _generalService.DownloadSatelliteStats(satelliteItemsCropYear, fieldName, satelliteBands, downloadFolder); // Add contents of this csv file to thee single large csv file var retainedLines = File.ReadAllLines(downloadedStats).Skip(1); File.AppendAllLines(fileNameStats, retainedLines); File.Delete(downloadedStats); // Optionally, also download the zipped tiffs. This can be a lot of files and increase runtime if (false) { foreach (Item selectedSatelliteItem in satelliteItemsCropYear) { // download the geotiffs. Returns a zip file with always these three files: // data.dat.aux.xml // thumbnail.jpg // wenr.tif. Contains 5 layers: (1) ndvi, (2) wdvi, (3) Red, (4) Green and (5) Blue var SatelliteDate = selectedSatelliteItem.DataDate.Value.ToString("yyyyMMdd"); _logger.LogInformation($"Downloading geotiff file for field {fieldName}, date {SatelliteDate}"); string fileName = string.Format($"satelliteGeotiff_{fieldName}_{SatelliteDate}"); // no need to add satelliteBand in the name because the tif contains all bands string fileNameZip = string.Format($"{fileName}.zip"); string fileNameGeotiff = string.Format($"{fileName}.tif"); await _farmmapsApiService.DownloadItemAsync(selectedSatelliteItem.Code, Path.Combine(downloadFolder, fileNameZip)); if (false) { // Extract the file "wenr.tif" from zip, rename it to fileNameGeotiff ZipFile.ExtractToDirectory(Path.Combine(downloadFolder, fileNameZip), downloadFolder, true); File.Delete(Path.Combine(downloadFolder, fileNameGeotiff)); // Delete the fileNameGeotiff file if exists File.Move(Path.Combine(downloadFolder, "wenr.tif"), Path.Combine(downloadFolder, fileNameGeotiff)); // Rename the oldFileName into newFileName // Cleanup string[] filesToDelete = new string[] { fileNameZip, "wenr.tif", "thumbnail.jpg", "data.dat.aux.xml" }; foreach (string f in filesToDelete) { File.Delete(Path.Combine(downloadFolder, f)); } } } } } // Download statistics to database if (satelliteItemsAvailable && database != null) { // Tailormade code for writing to database // No unnecessary intermediate step here of writing to csv and getting stats for all images in the crop year. // Efficient is to check if there is any image for which stats are to be added to database and add only add these new stats for not yet archived dates directly to database // A full check of downloaded dates versus available dates in the database is not made here. // We assume only new images will be added (i.e. for later dates), assuming no historical images are added in groenmonitor (check!). // And we assume farmMaps always nicely generates statistics, no hick-ups // Under this assumptions, we only need to compare with the lastDownloadedSatelliteDate from the database cntDatesDownloaded = 0; foreach (Item satelliteItem in satelliteItemsCropYear) { dtSatelliteDate = satelliteItem.DataDate.Value; strSatelliteDate = dtSatelliteDate.ToString("yyyy-MM-dd"); listSatelliteStatistics = await _generalService.ListSatelliteStatistics(satelliteItem, satelliteBands, fieldName); statsAvailable = true; if (listSatelliteStatistics == null) { statsAvailable = false; _logger.LogWarning($"No stats found for satellite, fielName '{fieldName}', date '{strSatelliteDate}'"); } else { if (listSatelliteStatistics.Count == 0) { statsAvailable = false; _logger.LogWarning($"No stats found for satellite, fielName '{fieldName}', date '{strSatelliteDate}'"); } } if (statsAvailable) { if (dtSatelliteDate <= input.lastdownloadedimagedate) { _logger.LogInformation($"// Stats for parcelid {input.fieldID}, date '{strSatelliteDate}' already there in {schemaname}.{satelllitetable}"); } else { cntDatesDownloaded++; // Map satelliteStatistics to groenmonitorTable satelliteStatistics_wdvi = listSatelliteStatistics.SingleOrDefault(p => p.satelliteBand == "wdvi"); satelliteStatistics_ndvi = listSatelliteStatistics.SingleOrDefault(p => p.satelliteBand == "ndvi"); groenmonitorTable.parcelid = input.fieldID; groenmonitorTable.date = strSatelliteDate; groenmonitorTable.source = "akkerwebwenr"; // Like this so SQL in bigdata.groenmonitorlatest_flowerbulbs works properly groenmonitorTable.wdvi_pixelcount = satelliteStatistics_wdvi.populationCount; //count of pixels with data groenmonitorTable.wdvi_max = satelliteStatistics_wdvi.max; groenmonitorTable.wdvi_mean = satelliteStatistics_wdvi.mean; groenmonitorTable.wdvi_min = satelliteStatistics_wdvi.min; groenmonitorTable.wdvi_stdev = satelliteStatistics_wdvi.stddev; groenmonitorTable.wdvi_median = satelliteStatistics_wdvi.median; groenmonitorTable.wdvi_p90 = -99; // Example of a statistics (90% not included in satelliteStatistics groenmonitorTable.ndvi_pixelcount = satelliteStatistics_ndvi.populationCount; //count of pixels with data groenmonitorTable.ndvi_max = satelliteStatistics_ndvi.max; groenmonitorTable.ndvi_mean = satelliteStatistics_ndvi.mean; groenmonitorTable.ndvi_min = satelliteStatistics_ndvi.min; groenmonitorTable.ndvi_stdev = satelliteStatistics_ndvi.stddev; groenmonitorTable.ndvi_median = satelliteStatistics_ndvi.median; groenmonitorTable.ndvi_p90 = -99; // Example of a statistics (90% not included in satelliteStatistics // fill the insertSql query with fieldValues from the groenmonitorTable, then run the query using (NpgsqlConnection connection = new NpgsqlConnection(database.GetConnectionString())) { connection.Open(); NpgsqlCommand insertCmd = connection.CreateCommand(); object[] fieldValues = groenmonitorTable.GetType() .GetFields() .Select(field => field.GetValue(groenmonitorTable)) .ToArray(); insertCmd.CommandText = string.Format(insertSql, fieldValues); //Console.WriteLine(insertCmd.CommandText); int r = insertCmd.ExecuteNonQuery(); if (r != 1) throw new Exception("// FarmmapsBulkSatDownload: Insert Failed"); connection.Close(); } _logger.LogInformation($"// Added stats to {schemaname}.{satelllitetable} for parcelid {input.fieldID}, date '{strSatelliteDate}'. cntDatesDownloaded: {cntDatesDownloaded}"); } } } } } // Functions to save previously created cropfields private void LoadSettings(string file) { if (File.Exists(file)) { var jsonText = File.ReadAllText(file); _settings = JsonConvert.DeserializeObject(jsonText); } else { Settings settings = new Settings(); settings.cropFieldItems = new List(); _settings = settings; } } private void SaveSettings(string file) { if (_settings == null) return; var json = JsonConvert.SerializeObject(_settings); File.WriteAllText(file, json); } private string strTime(TimeSpan ts) { return String.Format("{0:00}:{1:00}:{2:00}", ts.Hours, ts.Minutes, ts.Seconds); } private string InsertSQLfromClass(string schemaname, string groenmonitortablename) { // Generates an INSERT query for GroenmonitorTable. // When writing to a different table structure, just make a new class for the statellite statistics table, similar to GroenmonitorTable.cs // @" //INSERT INTO bigdata.groenmonitor (parcelid,date,wdvi_pixelcount,wdvi_max,wdvi_mean,wdvi_min,wdvi_stdev,wdvi_median,wdvi_p90,ndvi_pixelcount,ndvi_max,ndvi_mean,ndvi_min,ndvi_stdev,ndvi_median,ndvi_p90) //VALUES({0},'{1}',{2},{3},{4},{5},{6},{7},{8},{9},{10},{11},{12},{13},{14},{15}) //ON CONFLICT (parcelid, date) //DO UPDATE SET parcelid={0},date='{1}',wdvi_pixelcount={2},wdvi_max={3},wdvi_mean={4},wdvi_min={5},wdvi_stdev={6},wdvi_median={7},wdvi_p90={8},ndvi_pixelcount={9},ndvi_max={10},ndvi_mean={11},ndvi_min={12},ndvi_stdev={13},ndvi_median={14},ndvi_p90={15} //"; string fieldName; var fieldNames = typeof(GroenmonitorTable).GetFields() .Select(field => field.Name) .ToList(); StringBuilder sbInsertSql = new StringBuilder(); string insertSql; StringBuilder insertSql1 = new StringBuilder(); insertSql1.Append(@"INSERT INTO " + schemaname + "." + groenmonitortablename + " ("); StringBuilder insertSql2 = new StringBuilder(); insertSql2.Append("VALUES("); StringBuilder insertSql3 = new StringBuilder(); insertSql3.Append("ON CONFLICT(parcelid, date, source)"); StringBuilder insertSql4 = new StringBuilder(); insertSql4.Append("DO UPDATE SET "); for (int i = 0; i < fieldNames.Count; i++) { fieldName = fieldNames[i]; insertSql1.Append(fieldName); if (fieldName == "date" | fieldName == "source") { //add extra quotes ' for fields in the Postgress table for which we know they are date or varchar insertSql2.Append("'{" + i + "}'"); insertSql4.Append(fieldName + "='{" + i + "}'"); } else { insertSql2.Append("{" + i + "}"); insertSql4.Append(fieldName + "={" + i + "}"); } if (i < fieldNames.Count - 1) { insertSql1.Append(","); insertSql2.Append(","); insertSql4.Append(","); } else { insertSql1.Append(")"); insertSql2.Append(")"); } } sbInsertSql.AppendLine(insertSql1.ToString()); sbInsertSql.AppendLine(insertSql2.ToString()); sbInsertSql.AppendLine(insertSql3.ToString()); sbInsertSql.AppendLine(insertSql4.ToString()); insertSql = sbInsertSql.ToString(); return insertSql; } } }