Skip to content

Commit

Permalink
Provide positioned log entries as GeoJSON #189
Browse files Browse the repository at this point in the history
  • Loading branch information
domi-b authored Apr 2, 2024
2 parents d9bc4c4 + 6ef40fa commit cb53157
Show file tree
Hide file tree
Showing 13 changed files with 268 additions and 19 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

This Log shows all major changes and enhancements of INTERLIS webcheck service (ilicop)

## _Unreleased_

### Features

- Validation log entries with coordinates using the LV95 Reference System can be downloaded as GeoJSON

## 3.0.100 (2024-03-25)

### Features
Expand Down
16 changes: 15 additions & 1 deletion src/ILICheck.Web/ClientApp/src/protokoll.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React, { useState, useRef, useEffect } from "react";
import DayJS from "dayjs";
import { Card, Container } from "react-bootstrap";
import { GoFile, GoFileCode } from "react-icons/go";
import { BsLink45Deg } from "react-icons/bs";
import { BsGeoAlt, BsLink45Deg } from "react-icons/bs";
import { LogDisplay } from "./logDisplay";

export const Protokoll = (props) => {
Expand Down Expand Up @@ -94,6 +94,20 @@ export const Protokoll = (props) => {
</div>
</span>
)}
{statusData.geoJsonLogUrl && (
<span className="icon-tooltip">
<a
download={protokollFileName + ".geojson"}
className={statusClass + " download-icon"}
href={statusData.geoJsonLogUrl}
>
<BsGeoAlt />
</a>
<span className="icon-tooltip-text">
Positionsbezogene Log-Daten als GeoJSON-Datei herunterladen
</span>
</span>
)}
</span>
</Card.Title>
)}
Expand Down
5 changes: 3 additions & 2 deletions src/ILICheck.Web/Controllers/DownloadController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public DownloadController(ILogger<DownloadController> logger, IFileProvider file
/// <param name="logType">The log type to download.</param>
/// <returns>The ilivalidator log file.</returns>
[HttpGet]
[SwaggerResponse(StatusCodes.Status201Created, "Returns the ilivalidator log file.", ContentTypes = new[] { "text/xml; charset=utf-8" })]
[SwaggerResponse(StatusCodes.Status200OK, "Returns the ilivalidator log file.", ContentTypes = new[] { "text/xml; charset=utf-8", "application/geo+json" })]
[SwaggerResponse(StatusCodes.Status400BadRequest, "The server cannot process the request due to invalid or malformed request.", typeof(ProblemDetails), new[] { "application/json" })]
[SwaggerResponse(StatusCodes.Status404NotFound, "The log file for the requested jobId cannot be found.", ContentTypes = new[] { "application/json" })]
public IActionResult Download(Guid jobId, LogType logType)
Expand All @@ -40,7 +40,8 @@ public IActionResult Download(Guid jobId, LogType logType)
try
{
logger.LogInformation("Log file (<{LogType}>) for job identifier <{JobId}> requested.", HttpUtility.HtmlEncode(logType), jobId);
return File(fileProvider.OpenText(fileProvider.GetLogFile(logType)).BaseStream, "text/xml; charset=utf-8");
var contentType = logType == LogType.GeoJson ? "application/geo+json" : "text/xml; charset=utf-8";
return File(fileProvider.OpenText(fileProvider.GetLogFile(logType)).BaseStream, contentType);
}
catch (Exception)
{
Expand Down
16 changes: 4 additions & 12 deletions src/ILICheck.Web/Controllers/StatusController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,16 @@ public IActionResult GetStatus(ApiVersion version, Guid jobId)
return Problem($"No job information available for job id <{jobId}>", statusCode: StatusCodes.Status404NotFound);
}

var xtfLogUrl = GetLogDownloadUrl(version, jobId, LogType.Xtf);
return Ok(new StatusResponse
{
JobId = jobId,
Status = job.Status,
StatusMessage = job.StatusMessage,
LogUrl = GetLogDownloadUrl(version, jobId, LogType.Log),
XtfLogUrl = GetLogDownloadUrl(version, jobId, LogType.Xtf),
JsonLogUrl = GetJsonLogUrl(version, jobId),
XtfLogUrl = xtfLogUrl,
JsonLogUrl = xtfLogUrl == null ? null : GetJsonLogUrl(version, jobId), // JSON is generated from the XTF log file
GeoJsonLogUrl = GetLogDownloadUrl(version, jobId, LogType.GeoJson),
});
}

Expand Down Expand Up @@ -86,16 +88,6 @@ internal Uri GetLogDownloadUrl(ApiVersion version, Guid jobId, LogType logType)

private Uri GetJsonLogUrl(ApiVersion version, Guid jobId)
{
try
{
// JSON log is generated from the xtf log
_ = fileProvider.GetLogFile(LogType.Xtf);
}
catch (FileNotFoundException)
{
return null;
}

var logUrlTemplate = "/api/v{0}/download/json?jobId={1}";
return new Uri(string.Format(
CultureInfo.InvariantCulture,
Expand Down
100 changes: 100 additions & 0 deletions src/ILICheck.Web/GeoJsonHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
using DotSpatial.Projections;
using ILICheck.Web.XtfLog;
using NetTopologySuite.Features;
using NetTopologySuite.Geometries;
using System.Collections.Generic;
using System.Linq;

namespace ILICheck.Web
{
/// <summary>
/// Provides helper methods for the GeoJSON (RFC 7946) format.
/// </summary>
public static class GeoJsonHelper
{
private static readonly ProjectionInfo lv95 = ProjectionInfo.FromEpsgCode(2056);

/// <summary>
/// Converts XTF log entries to a GeoJSON feature collection.
/// </summary>
/// <param name="logResult">The XTF log entries.</param>
/// <returns>A feature collection containing the log entries or <c>null</c> if the log entries contain either no coordinates or coordinates outside of the LV95 bounds.</returns>
public static FeatureCollection CreateFeatureCollection(IEnumerable<LogError> logResult)
{
if (!AllCoordinatesAreLv95(logResult))
{
return null;
}

var features = logResult
.Where(log => log.Geometry?.Coord != null)
.Select(log => new Feature(ProjectLv95ToWgs84(log.Geometry.Coord), new AttributesTable(new KeyValuePair<string, object>[]
{
new ("type", log.Type),
new ("message", log.Message),
new ("objTag", log.ObjTag),
new ("dataSource", log.DataSource),
new ("line", log.Line),
new ("techDetails", log.TechDetails),
})));

var featureCollection = new FeatureCollection();
foreach (var feature in features)
{
featureCollection.Add(feature);
}

return featureCollection;
}

/// <summary>
/// Checks that the log entries contain coordinates and all are in the LV95 bounds.
/// </summary>
/// <param name="logResult">The XTF log entries.</param>
/// <returns><c>true</c> if the log entries contain coordinates and all are in the LV95 bounds; otherwise, <c>false</c>.</returns>
private static bool AllCoordinatesAreLv95(IEnumerable<LogError> logResult)
{
var hasLv95Coordinates = false;
foreach (var logEntry in logResult)
{
if (logEntry.Geometry?.Coord != null)
{
hasLv95Coordinates = true;
if (!IsLv95Coordinate(logEntry.Geometry.Coord))
{
return false;
}
}
}

return hasLv95Coordinates;
}

/// <summary>
/// Checks if the coordinate is within the LV95 bounds.
/// </summary>
/// <param name="coord">The coordinate to check.</param>
/// <returns><c>true</c> if the coordinate is in the LV95 bounds; otherwise, <c>false</c>.</returns>
private static bool IsLv95Coordinate(Coord coord)
{
// Values are based on https://models.geo.admin.ch/CH/CHBase_Part1_GEOMETRY_V2.ili
return coord.C1 >= 2_460_000 && coord.C1 <= 2_870_000
&& coord.C2 >= 1_045_000 && coord.C2 <= 1_310_000;
}

/// <summary>
/// Projects the LV95 coordinate to WGS84.
/// </summary>
/// <param name="coord">The coordinate using LV95 CRS.</param>
/// <returns>The coordinate projected to WGS84.</returns>
private static Point ProjectLv95ToWgs84(Coord coord)
{
var source = lv95;
var target = KnownCoordinateSystems.Geographic.World.WGS1984;
double[] xy = { (double)coord.C1, (double)coord.C2 };
double[] z = { 0 };
Reproject.ReprojectPoints(xy, z, source, target, 0, 1);
return new Point(xy[0], xy[1]);
}
}
}
2 changes: 2 additions & 0 deletions src/ILICheck.Web/ILICheck.Web.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="DotSpatial.Projections" Version="4.0.656" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Versioning" Version="5.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="7.0.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="7.0.2" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="4.0.0" />
<PackageReference Include="Serilog.Extensions.Logging.File" Version="3.0.0" />
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
Expand Down
5 changes: 5 additions & 0 deletions src/ILICheck.Web/LogType.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,10 @@ public enum LogType
/// follows the 'IliVErrors' model which can be used to visualize errors.
/// </summary>
Xtf,

/// <summary>
/// Log containing error messages and warnings related to a coordinate in GeoJSON (RFC 7946) format.
/// </summary>
GeoJson,
}
}
2 changes: 2 additions & 0 deletions src/ILICheck.Web/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.OpenApi.Models;
using NetTopologySuite.IO.Converters;
using System;
using System.IO;
using System.Reflection;
Expand Down Expand Up @@ -68,6 +69,7 @@ public void ConfigureServices(IServiceCollection services)
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
options.JsonSerializerOptions.Converters.Add(new GeoJsonConverterFactory());
});
services.Configure<FormOptions>(options =>
{
Expand Down
5 changes: 5 additions & 0 deletions src/ILICheck.Web/StatusResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,10 @@ public class StatusResponse
/// The JSON log url if available; otherwise, <c>null</c>. Please be aware that the log file might not be complete while validation is still processing.
/// </summary>
public Uri JsonLogUrl { get; set; }

/// <summary>
/// The GeoJSON log url if available; otherwise, <c>null</c>. Please be aware that the log file might not be complete while validation is still processing.
/// </summary>
public Uri GeoJsonLogUrl { get; set; }
}
}
45 changes: 43 additions & 2 deletions src/ILICheck.Web/Validator.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
using Microsoft.Data.Sqlite;
using ILICheck.Web.XtfLog;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
Expand All @@ -22,6 +26,7 @@ public class Validator : IValidator
private readonly ILogger<Validator> logger;
private readonly IConfiguration configuration;
private readonly IFileProvider fileProvider;
private readonly JsonOptions jsonOptions;

/// <inheritdoc/>
public virtual Guid Id { get; } = Guid.NewGuid();
Expand All @@ -38,11 +43,12 @@ public class Validator : IValidator
/// <summary>
/// Initializes a new instance of the <see cref="Validator"/> class.
/// </summary>
public Validator(ILogger<Validator> logger, IConfiguration configuration, IFileProvider fileProvider)
public Validator(ILogger<Validator> logger, IConfiguration configuration, IFileProvider fileProvider, IOptions<JsonOptions> jsonOptions)
{
this.logger = logger;
this.configuration = configuration;
this.fileProvider = fileProvider;
this.jsonOptions = jsonOptions.Value;

this.fileProvider.Initialize(Id);
}
Expand Down Expand Up @@ -194,6 +200,8 @@ internal async Task ValidateAsync(CancellationToken cancellationToken)
GpkgModelNames);

var exitCode = await ExecuteCommandAsync(configuration, command, cancellationToken).ConfigureAwait(false);

await GenerateGeoJsonAsync().ConfigureAwait(false);
if (exitCode != 0)
{
throw new ValidationFailedException("The ilivalidator found errors in the file. Validation failed.");
Expand Down Expand Up @@ -235,5 +243,38 @@ private IEnumerable<string> ReadGpkgModelNameEntries(string connectionString)
var reader = command.ExecuteReader();
while (reader.Read()) yield return reader["modelName"].ToString();
}

/// <summary>
/// Asynchronously generates a GeoJSON file from the XTF log file.
/// </summary>
private async Task GenerateGeoJsonAsync()
{
try
{
var xtfLogFile = fileProvider.GetLogFile(LogType.Xtf);
if (!fileProvider.Exists(xtfLogFile)) return;

logger.LogInformation("Generating GeoJSON file from XTF log file <{XtfLogFile}>", xtfLogFile);

using var reader = fileProvider.OpenText(xtfLogFile);
var logResult = XtfLogParser.Parse(reader);

var featureCollection = GeoJsonHelper.CreateFeatureCollection(logResult);
if (featureCollection == null)
{
logger.LogInformation("No or unknown coordinates found in XTF log file <{XtfLogFile}>. Skipping GeoJSON generation.", xtfLogFile);
return;
}

var geoJsonLogFile = Path.ChangeExtension(xtfLogFile, ".geojson");

using var geoJsonStream = fileProvider.CreateFile(geoJsonLogFile);
await JsonSerializer.SerializeAsync(geoJsonStream, featureCollection, jsonOptions.JsonSerializerOptions).ConfigureAwait(false);
}
catch (Exception e)
{
logger.LogError(e, "Failed to generate the GeoJSON file from the XTF log file for id <{Id}>.", Id);
}
}
}
}
54 changes: 54 additions & 0 deletions tests/ILICheck.Web.Test/GeoJsonHelperTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using ILICheck.Web.XtfLog;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using NetTopologySuite.IO.Converters;
using System.Text.Json;

namespace ILICheck.Web
{
[TestClass]
public class GeoJsonHelperTest
{
private JsonSerializerOptions serializerOptions;

[TestInitialize]
public void Initialize()
{
serializerOptions = new JsonSerializerOptions();
serializerOptions.Converters.Add(new GeoJsonConverterFactory());
}

[TestMethod]
public void CreateFeatureCollectionForGeoJson()
{
var logResult = new[]
{
new LogError
{
Message = "Error message without coordinate",
Type = "Error",
},
new LogError
{
Message = "Error message 1",
Type = "Error",
ObjTag = "Model.Topic.Class",
Line = 11,
Geometry = new Geometry
{
Coord = new Coord
{
C1 = 2671295m,
C2 = 1208106m,
},
},
},
};

var featureCollection = GeoJsonHelper.CreateFeatureCollection(logResult);
Assert.AreEqual(1, featureCollection.Count);

var geoJson = JsonSerializer.Serialize(featureCollection, serializerOptions);
Assert.AreEqual("{\"type\":\"FeatureCollection\",\"features\":[{\"type\":\"Feature\",\"geometry\":{\"type\":\"Point\",\"coordinates\":[8.376399953106437,47.02016965999489]},\"properties\":{\"type\":\"Error\",\"message\":\"Error message 1\",\"objTag\":\"Model.Topic.Class\",\"dataSource\":null,\"line\":11,\"techDetails\":null}}]}", geoJson);
}
}
}
Loading

0 comments on commit cb53157

Please sign in to comment.