Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Carbon.Application/Contracts/IEmissionsUseCase.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using Carbon.Application.Dtos.Emissions;

namespace Carbon.Application.Contracts;

public interface IEmissionsUseCase
{

Task<EmissionsResponseDto> Calculate(EmissionsRequestDto request, CancellationToken ct);
}
37 changes: 37 additions & 0 deletions Carbon.Application/Dtos/Emissions/EmissionsRequestDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json.Serialization;
using Carbon.Domain.Contracts.Services.Emissions;

namespace Carbon.Application.Dtos.Emissions;

public record EmissionsRequestDto(
[Required(ErrorMessage = "Flights field is required!")]
[MinLength(1, ErrorMessage = "At least one flight must be informed!")]
List<FlightLegDto> Flights,

[property: JsonConverter(typeof(JsonStringEnumConverter<CabinClass>))]
CabinClass Cabin = CabinClass.Economy,

[Range(1, 100, ErrorMessage = "Passengers must be between 1 and 100!")]
int Passengers = 1,

bool RoundTrip = false);

public record FlightLegDto(
[Required(ErrorMessage = "Origin field is required!")]
[StringLength(3, MinimumLength = 3, ErrorMessage = "Origin must be a 3-letter IATA code!")]
string Origin,

[Required(ErrorMessage = "Destination field is required!")]
[StringLength(3, MinimumLength = 3, ErrorMessage = "Destination must be a 3-letter IATA code!")]
string Destination,

[Required(ErrorMessage = "CarrierCode field is required!")]
[StringLength(2, MinimumLength = 2, ErrorMessage = "CarrierCode must have 2 characters!")]
string CarrierCode,

[Range(1, 9999, ErrorMessage = "FlightNumber must be between 1 and 9999!")]
int FlightNumber,

[Required(ErrorMessage = "DepartureDate field is required!")]
DateOnly DepartureDate);
15 changes: 15 additions & 0 deletions Carbon.Application/Dtos/Emissions/EmissionsResponseDto.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace Carbon.Application.Dtos.Emissions;

public record EmissionsResponseDto(
Guid Id,
string Cabin,
int Passengers,
bool RoundTrip,
int FlightsCount,
decimal KgPerPassenger,
decimal TotalKg,
decimal TotalTonnes,
string Level,
string ContrailsImpact,
DateOnly ModelDate,
DateTimeOffset CalculatedAt);
39 changes: 38 additions & 1 deletion Carbon.Application/UseCases/EmissionsUseCase.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,45 @@
using Carbon.Application.Contracts;
using Carbon.Application.Dtos.Emissions;
using Carbon.Domain.Contracts.Services.Emissions;

namespace Carbon.Application.UseCases;

public class EmissionsUseCase : IEmissionsUseCase
{

private readonly IEmissionsService _emissionsService;

public EmissionsUseCase(IEmissionsService emissionsService)
{
_emissionsService = emissionsService;
}

public async Task<EmissionsResponseDto> Calculate(EmissionsRequestDto request, CancellationToken ct = default)
{
var calculationRequest = new EmissionsCalculationRequest(
request.Flights.Select(f => new FlightLeg(
f.Origin,
f.Destination,
f.CarrierCode,
f.FlightNumber,
f.DepartureDate)).ToList(),
request.Cabin,
request.Passengers,
request.RoundTrip);

var result = await _emissionsService.CalculateAsync(calculationRequest, ct);

return new EmissionsResponseDto(
result.Id,
result.Cabin.ToString(),
result.Passengers,
result.RoundTrip,
result.FlightsCount,
result.KgPerPassenger,
result.TotalKg,
result.TotalTonnes,
result.Level.ToString(),
result.ContrailsImpact.ToString(),
result.ModelDate,
result.CalculatedAt);
}
}
7 changes: 7 additions & 0 deletions Carbon.Core/Controllers/EmissionsController.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
using Carbon.Application.Contracts;
using Carbon.Application.Dtos.Emissions;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace Carbon.Core.Controllers;

[ApiController]
[Authorize]
[Route("Carbon/[controller]")]
public class EmissionsController : ControllerBase
{
Expand All @@ -13,4 +16,8 @@ public EmissionsController(IEmissionsUseCase useCase)
{
_useCase = useCase;
}

[HttpPost("Calculate")]
public async Task<EmissionsResponseDto> Calculate(EmissionsRequestDto request, CancellationToken ct = default)
=> await _useCase.Calculate(request, ct);
}
1 change: 1 addition & 0 deletions Carbon.Core/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
app.UseRateLimiter();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();
58 changes: 57 additions & 1 deletion Carbon.Domain/Contracts/Services/Emissions/EmissionsContracts.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,60 @@
using Carbon.Domain.Contracts.Providers.TravelImpact;

namespace Carbon.Domain.Contracts.Services.Emissions
{

public enum CabinClass
{
Economy,
PremiumEconomy,
Business,
First
}

public enum EmissionLevel
{
Low,
Moderate,
High
}

public record FlightLeg(
string Origin,
string Destination,
string CarrierCode,
int FlightNumber,
DateOnly DepartureDate);

public record EmissionsCalculationRequest(
List<FlightLeg> Flights,
CabinClass Cabin,
int Passengers,
bool RoundTrip);

public record EmissionsCalculationResponse(
Guid Id,
CabinClass Cabin,
int Passengers,
bool RoundTrip,
int FlightsCount,
decimal KgPerPassenger,
decimal TotalKg,
decimal TotalTonnes,
EmissionLevel Level,
ImpactBucket ContrailsImpact,
DateOnly ModelDate,
DateTimeOffset CalculatedAt);

/// <summary>
/// Regras de negócio do cálculo. Os limites são de referência e podem ser ajustados pelo grupo.
/// </summary>
public static class EmissionsRules
{
public const int MaxFlights = 10;
public const int MinPassengers = 1;
public const int MaxPassengers = 100;

// Classificação por passageiro (kg de CO2e na viagem inteira)
public const decimal LowLimitKgPerPassenger = 100m;
public const decimal ModerateLimitKgPerPassenger = 500m;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ namespace Carbon.Domain.Contracts.Services.Emissions;

public interface IEmissionsService
{

Task<EmissionsCalculationResponse> CalculateAsync(EmissionsCalculationRequest request, CancellationToken ct = default);
}
123 changes: 123 additions & 0 deletions Carbon.Domain/Services/EmissionsService.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,137 @@
using Carbon.Domain.Contracts.Providers.TravelImpact;
using Carbon.Domain.Contracts.Services.Emissions;
using Carbon.Domain.Exceptions;

namespace Carbon.Domain.Services;

public class EmissionsService : IEmissionsService
{
private const decimal GramsPerKg = 1_000m;
private const decimal KgPerTonne = 1_000m;

private readonly ITravelImpactProvider _provider;

public EmissionsService(ITravelImpactProvider provider)
{
_provider = provider;
}

public async Task<EmissionsCalculationResponse> CalculateAsync(EmissionsCalculationRequest request, CancellationToken ct = default)
{
Validate(request);

var providerRequest = new ImpactProviderRequest
{
Flights = request.Flights.Select(leg => new Flight
{
Origin = leg.Origin.Trim(),
Destination = leg.Destination.Trim(),
CarrierCode = leg.CarrierCode.Trim(),
FlightNumber = leg.FlightNumber,
DepartureDate = leg.DepartureDate
}).ToList()
};

var providerResponse = await _provider.GetFlightEmissionsFromProviderAsync(providerRequest, ct);

if (providerResponse.Flights.Count == 0)
{
throw new NotFoundException("No emissions data found for the informed flights. Check airports, carrier and flight number.");
}

var gramsPerPassenger = GetGramsForCabin(providerResponse.Emissions, request.Cabin);

if (gramsPerPassenger <= 0)
{
throw new BadRequestException($"No emissions data for cabin {request.Cabin} on the informed flights. The cabin may not exist on this aircraft.");
}

var tripMultiplier = request.RoundTrip ? 2 : 1;

var kgPerPassenger = gramsPerPassenger / GramsPerKg * tripMultiplier;
var totalKg = kgPerPassenger * request.Passengers;

return new EmissionsCalculationResponse(
Id: providerResponse.Id,
Cabin: request.Cabin,
Passengers: request.Passengers,
RoundTrip: request.RoundTrip,
FlightsCount: providerResponse.Flights.Count,
KgPerPassenger: Math.Round(kgPerPassenger, 2),
TotalKg: Math.Round(totalKg, 2),
TotalTonnes: Math.Round(totalKg / KgPerTonne, 3),
Level: Classify(kgPerPassenger),
ContrailsImpact: providerResponse.ImpactBucket,
ModelDate: providerResponse.Dated,
CalculatedAt: DateTimeOffset.UtcNow);
}

private static void Validate(EmissionsCalculationRequest request)
{
if (request.Flights is null || request.Flights.Count == 0)
{
throw new BadRequestException("At least one flight must be informed.");
}

if (request.Flights.Count > EmissionsRules.MaxFlights)
{
throw new BadRequestException($"A maximum of {EmissionsRules.MaxFlights} flights can be calculated at once.");
}

if (request.Passengers is < EmissionsRules.MinPassengers or > EmissionsRules.MaxPassengers)
{
throw new BadRequestException($"Passengers must be between {EmissionsRules.MinPassengers} and {EmissionsRules.MaxPassengers}.");
}

foreach (var leg in request.Flights)
{
if (!IsIataAirport(leg.Origin) || !IsIataAirport(leg.Destination))
{
throw new BadRequestException($"Invalid airport code on flight {leg.CarrierCode}{leg.FlightNumber}. Use the 3-letter IATA code (e.g. GRU).");
}

if (string.Equals(leg.Origin.Trim(), leg.Destination.Trim(), StringComparison.OrdinalIgnoreCase))
{
throw new BadRequestException("Origin and destination must be different.");
}

if (string.IsNullOrWhiteSpace(leg.CarrierCode) || leg.CarrierCode.Trim().Length != 2)
{
throw new BadRequestException("Carrier code must have 2 characters (e.g. LA, G3, AD).");
}

if (leg.FlightNumber <= 0)
{
throw new BadRequestException("Flight number must be greater than zero.");
}

if (leg.DepartureDate == default)
{
throw new BadRequestException("Departure date is required.");
}
}
}

private static bool IsIataAirport(string? code)
=> !string.IsNullOrWhiteSpace(code)
&& code.Trim().Length == 3
&& code.Trim().All(char.IsLetter);

private static decimal GetGramsForCabin(EmissionsPerPax emissions, CabinClass cabin)
=> cabin switch
{
CabinClass.Economy => emissions.Economy,
CabinClass.PremiumEconomy => emissions.PremiumEconomy,
CabinClass.Business => emissions.Business,
CabinClass.First => emissions.First,
_ => throw new BadRequestException($"Unknown cabin class: {cabin}")
};

private static EmissionLevel Classify(decimal kgPerPassenger)
=> kgPerPassenger switch
{
< EmissionsRules.LowLimitKgPerPassenger => EmissionLevel.Low,
< EmissionsRules.ModerateLimitKgPerPassenger => EmissionLevel.Moderate,
_ => EmissionLevel.High
};
}
Loading