diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py index fc821c56..5c5a6a8a 100644 --- a/backend/api/v1/__init__.py +++ b/backend/api/v1/__init__.py @@ -100,3 +100,4 @@ api_v1.register_blueprint(circular_bp, url_prefix='/circular') api_v1.register_blueprint(biosecurity_bp, url_prefix='/biosecurity') api_v1.register_blueprint(vaults_bp, url_prefix='/vaults') +api_v1.register_blueprint(carbon_bp, url_prefix='/carbon') diff --git a/backend/api/v1/carbon.py b/backend/api/v1/carbon.py new file mode 100644 index 00000000..49e9ae8d --- /dev/null +++ b/backend/api/v1/carbon.py @@ -0,0 +1,223 @@ +""" +Carbon & ESG Marketplace API — L3-1632 +""" +from flask import Blueprint, request, jsonify +from auth_utils import token_required +from backend.extensions import db +from backend.services.carbon_sequestration_engine import ( + CarbonSequestrationEngine, CARBON_CREDIT_PRICE_USD +) +from backend.models.soil_health import RegenerativeFarmingLog, CarbonMintEvent +from backend.models.sustainability import ESGMarketListing, SustainabilityScore +from backend.models.farm import Farm +import logging + +logger = logging.getLogger(__name__) +carbon_bp = Blueprint('carbon', __name__) + + +# ─── 1. Log a Regenerative Farming Practice ─────────────────────────────────── +@carbon_bp.route('/practices', methods=['POST']) +@token_required +def log_practice(current_user): + """ + Records a regenerative farming practice entry. + Requires: farm_id, practice_type, area_hectares + Optional: soil_organic_carbon_percent, bulk_density_gcm3, sampling_depth_cm + """ + data = request.get_json() + required = ['farm_id', 'practice_type', 'area_hectares'] + if not data or not all(k in data for k in required): + return jsonify({'status': 'error', 'message': f'Missing required fields: {required}'}), 400 + + farm = Farm.query.get(data['farm_id']) + if not farm: + return jsonify({'status': 'error', 'message': 'Farm not found.'}), 404 + + valid_practices = ['NO_TILL', 'COVER_CROP', 'ORGANIC_FERTILIZER', 'AGROFORESTRY', 'BIOCHAR'] + if data['practice_type'] not in valid_practices: + return jsonify({'status': 'error', 'message': f'Invalid practice_type. Must be one of: {valid_practices}'}), 400 + + # Pre-calculate estimated CO2e for immediate feedback + preview_log = RegenerativeFarmingLog( + farm_id=data['farm_id'], + practice_type=data['practice_type'], + area_hectares=float(data['area_hectares']), + soil_organic_carbon_percent=data.get('soil_organic_carbon_percent', 1.5), + bulk_density_gcm3=data.get('bulk_density_gcm3', 1.3), + sampling_depth_cm=data.get('sampling_depth_cm', 30.0), + soil_test_id=data.get('soil_test_id') + ) + estimated = CarbonSequestrationEngine.calculate_co2e(preview_log) + preview_log.estimated_co2e_tonnes = estimated + db.session.add(preview_log) + db.session.commit() + + return jsonify({ + 'status': 'success', + 'data': { + **preview_log.to_dict(), + 'estimated_co2e_tonnes': estimated, + 'estimated_credit_value_usd': round(estimated * CARBON_CREDIT_PRICE_USD, 2), + 'next_step': 'Submit for verification to unlock minting.' + } + }), 201 + + +# ─── 2. Verify a Farming Log (Admin/Auditor action) ─────────────────────────── +@carbon_bp.route('/practices//verify', methods=['PATCH']) +@token_required +def verify_practice(current_user, log_id): + """Marks a farming log as verified, enabling credit minting.""" + log = RegenerativeFarmingLog.query.get(log_id) + if not log: + return jsonify({'status': 'error', 'message': 'Log not found'}), 404 + + log.verified = True + db.session.commit() + return jsonify({'status': 'success', 'message': 'Log verified. Credits can now be minted.'}), 200 + + +# ─── 3. Mint Credits for a Verified Log ─────────────────────────────────────── +@carbon_bp.route('/practices//mint', methods=['POST']) +@token_required +def mint_credits(current_user, log_id): + """ + Triggers the Carbon Sequestration Engine to mint credits for a verified log. + Autonomously posts a double-entry ledger transaction. + """ + price = request.get_json(silent=True) or {} + price_per_tonne = price.get('price_per_tonne_usd', CARBON_CREDIT_PRICE_USD) + + event, err = CarbonSequestrationEngine.mint_credits(log_id, price_per_tonne) + if err: + return jsonify({'status': 'error', 'message': err}), 400 + + return jsonify({ + 'status': 'success', + 'data': { + **event.to_dict(), + 'ledger_txn_posted': event.ledger_transaction_id is not None + } + }), 201 + + +# ─── 4. List Credits on ESG Marketplace ────────────────────────────────────── +@carbon_bp.route('/market/list', methods=['POST']) +@token_required +def list_on_market(current_user): + """ + Lists a minted carbon credit batch on the internal ESG marketplace. + Requires: mint_event_id + Optional: asking_price_usd, description + """ + data = request.get_json() + if not data or 'mint_event_id' not in data: + return jsonify({'status': 'error', 'message': 'mint_event_id required.'}), 400 + + listing, err = CarbonSequestrationEngine.list_on_esg_market( + mint_event_id=data['mint_event_id'], + asking_price_usd=data.get('asking_price_usd'), + description=data.get('description') + ) + if err: + return jsonify({'status': 'error', 'message': err}), 400 + + return jsonify({'status': 'success', 'data': listing.to_dict()}), 201 + + +# ─── 5. Browse ESG Marketplace ──────────────────────────────────────────────── +@carbon_bp.route('/market/listings', methods=['GET']) +@token_required +def browse_market(current_user): + """ + Returns all active ESG marketplace listings with farm & credit metadata. + Supports filter: ?min_credits=5&max_price=200 + """ + query = ESGMarketListing.query.filter_by(status='ACTIVE') + + min_credits = request.args.get('min_credits', type=float) + max_price = request.args.get('max_price', type=float) + if min_credits: + query = query.filter(ESGMarketListing.credits_offered >= min_credits) + if max_price: + query = query.filter(ESGMarketListing.asking_price_usd <= max_price) + + listings = query.order_by(ESGMarketListing.listed_at.desc()).all() + return jsonify({ + 'status': 'success', + 'count': len(listings), + 'data': [l.to_dict() for l in listings] + }), 200 + + +# ─── 6. Purchase Credits (Corporate Buyer) ─────────────────────────────────── +@carbon_bp.route('/market/purchase/', methods=['POST']) +@token_required +def purchase_credits(current_user, listing_id): + """ + Settles an ESG carbon credit purchase via double-entry ledger. + """ + listing, err = CarbonSequestrationEngine.settle_esg_purchase( + listing_id=listing_id, + buyer_user_id=current_user.id + ) + if err: + return jsonify({'status': 'error', 'message': err}), 400 + + return jsonify({ + 'status': 'success', + 'message': 'Purchase settled. Credits transferred to your carbon portfolio.', + 'data': listing.to_dict() + }), 200 + + +# ─── 7. Farm Carbon Profile ─────────────────────────────────────────────────── +@carbon_bp.route('/profile/', methods=['GET']) +@token_required +def get_carbon_profile(current_user, farm_id): + """ + Returns a comprehensive carbon & ESG profile for a farm. + Includes sequestration history, minted credits, sustainability score, and marketplace activity. + """ + farm = Farm.query.get(farm_id) + if not farm: + return jsonify({'status': 'error', 'message': 'Farm not found.'}), 404 + + logs = RegenerativeFarmingLog.query.filter_by(farm_id=farm_id).order_by( + RegenerativeFarmingLog.logged_at.desc()).all() + events = CarbonMintEvent.query.filter_by(farm_id=farm_id).all() + score = SustainabilityScore.query.filter_by(farm_id=farm_id).first() + active_listings = ESGMarketListing.query.filter_by( + farm_id=farm_id, status='ACTIVE').count() + sold_listings = ESGMarketListing.query.filter_by( + farm_id=farm_id, status='SOLD').count() + + return jsonify({ + 'status': 'success', + 'data': { + 'farm': { + 'id': farm.id, + 'name': farm.name, + 'is_no_till': farm.is_no_till, + 'cover_crop_active': farm.cover_crop_active, + 'organic_certified': farm.organic_certified, + 'sequestration_tier': farm.sequestration_tier, + 'total_credits_minted': farm.total_carbon_credits_minted + }, + 'sustainability_score': { + 'overall_rating': score.overall_rating if score else 0, + 'esg_carbon_score': score.esg_carbon_score if score else 0, + 'total_credits': score.total_credits_minted if score else 0 + }, + 'practice_logs': [l.to_dict() for l in logs], + 'mint_events': [e.to_dict() for e in events], + 'marketplace': { + 'active_listings': active_listings, + 'completed_sales': sold_listings, + 'total_revenue_usd': sum( + e.sale_price_usd for e in events if e.sale_price_usd + ) + } + } + }), 200 diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 04ac2ee3..a83ac5ac 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -16,19 +16,22 @@ from .audit_log import AuditLog, UserSession from .media_payload import MediaPayload from .weather import WeatherData, CropAdvisory, AdvisorySubscription, RiskTrigger -from .sustainability import CarbonPractice, CreditLedger, AuditRequest, CarbonLedger, EmissionSource, SustainabilityScore -from .vendor_profile import VendorProfile # Updated from procurement to vendor_profile +from .sustainability import ( + CarbonPractice, CreditLedger, AuditRequest, CarbonLedger, + EmissionSource, SustainabilityScore, ESGMarketListing +) +from .vendor_profile import VendorProfile from .procurement import ProcurementItem, BulkOrder, OrderEvent -from .irrigation import IrrigationZone, SensorLog, ValveStatus, IrrigationSchedule -from .processing import ProcessingBatch, StageLog, QualityCheck, ProcessingStage, SpectralScanData, DynamicGradeAdjustment from .irrigation import IrrigationZone, SensorLog, ValveStatus, IrrigationSchedule, AquiferLevel, WaterRightsQuota -from .processing import ProcessingBatch, StageLog, QualityCheck, ProcessingStage +from .processing import ProcessingBatch, StageLog, QualityCheck, ProcessingStage, SpectralScanData, DynamicGradeAdjustment from .insurance_v2 import CropPolicy, ClaimRequest, PayoutLedger, AdjusterNote -from .machinery import EngineHourLog, MaintenanceCycle, DamageReport, RepairOrder, ComponentWearMap, MaintenanceEscrow -from .soil_health import SoilTest, FertilizerRecommendation, ApplicationLog +from .machinery import EngineHourLog, MaintenanceCycle, DamageReport, RepairOrder, AssetValueSnapshot, ComponentWearMap, MaintenanceEscrow +from .soil_health import SoilTest, FertilizerRecommendation, ApplicationLog, RegenerativeFarmingLog, CarbonMintEvent from .loan_v2 import RepaymentSchedule, PaymentHistory, DefaultRiskScore, CollectionNote from .warehouse import WarehouseLocation, StockItem, StockMovement, ReconciliationLog from .climate import ClimateZone, SensorNode, TelemetryLog, AutomationTrigger +from .labor import WorkerProfile, WorkShift, HarvestLog, PayrollEntry, LaborROIHistory +from .logistics_v2 import DriverProfile, DeliveryVehicle, TransportRoute, FuelLog from .labor import WorkerProfile, WorkShift, HarvestLog, PayrollEntry from .logistics_v2 import ( DriverProfile, DeliveryVehicle, TransportRoute, FuelLog, @@ -37,8 +40,6 @@ from .transparency import ProduceReview, PriceAdjustmentLog from .barter import BarterTransaction, BarterResource, ResourceValueIndex from .financials import FarmBalanceSheet, SolvencySnapshot, ProfitabilityIndex -from .machinery import AssetValueSnapshot -from .labor import LaborROIHistory from .reliability_log import ReliabilityLog from .market import ForwardContract, PriceHedgingLog from .circular import WasteInventory, BioEnergyOutput, CircularCredit @@ -50,44 +51,72 @@ ) __all__ = [ - 'User', 'UserRole', 'LoanRequest', 'PredictionHistory', - 'Notification', 'File', 'YieldPool', 'PoolContribution', - 'ResourceShare', 'PoolVote', 'DiseaseIncident', 'OutbreakZone', 'OutbreakAlert', + # Core + 'User', 'UserRole', 'LoanRequest', 'PredictionHistory', + 'Notification', 'File', 'YieldPool', 'PoolContribution', + 'ResourceShare', 'PoolVote', + # Disease & Outbreak + 'DiseaseIncident', 'OutbreakZone', 'OutbreakAlert', + 'MigrationVector', 'ContainmentZone', + # Traceability 'SupplyBatch', 'CustodyLog', 'QualityGrade', 'BatchStatus', + # Insurance 'InsurancePolicy', 'LegacyClaim', 'RiskScoreHistory', 'DynamicPremiumLog', 'RiskFactorSnapshot', + 'CropPolicy', 'ClaimRequest', 'PayoutLedger', 'AdjusterNote', + # Community 'ForumCategory', 'ForumThread', 'PostComment', 'Upvote', 'UserReputation', 'Question', 'Answer', 'KnowledgeVote', 'Badge', 'UserBadge', 'UserExpertise', + # Equipment & Rental 'Equipment', 'RentalBooking', 'AvailabilityCalendar', 'PaymentEscrow', + # Farm 'Farm', 'FarmMember', 'FarmAsset', 'FarmRole', + # Alerts + 'Alert', 'AlertPreference', + # Audit + 'AuditLog', 'UserSession', 'MediaPayload', + # Weather 'WeatherData', 'CropAdvisory', 'AdvisorySubscription', 'RiskTrigger', - 'CarbonPractice', 'CreditLedger', 'AuditRequest', 'CarbonLedger', 'EmissionSource', 'SustainabilityScore', + # Sustainability & ESG + 'CarbonPractice', 'CreditLedger', 'AuditRequest', 'CarbonLedger', 'EmissionSource', + 'SustainabilityScore', 'ESGMarketListing', + # Procurement 'VendorProfile', 'ProcurementItem', 'BulkOrder', 'OrderEvent', + # Irrigation & Water 'IrrigationZone', 'SensorLog', 'ValveStatus', 'IrrigationSchedule', + 'AquiferLevel', 'WaterRightsQuota', + # Processing & Grading 'ProcessingBatch', 'StageLog', 'QualityCheck', 'ProcessingStage', - 'CropPolicy', 'ClaimRequest', 'PayoutLedger', 'AdjusterNote', + 'SpectralScanData', 'DynamicGradeAdjustment', + # Machinery 'EngineHourLog', 'MaintenanceCycle', 'DamageReport', 'RepairOrder', + 'AssetValueSnapshot', 'ComponentWearMap', 'MaintenanceEscrow', + # Soil & Carbon Sequestration 'SoilTest', 'FertilizerRecommendation', 'ApplicationLog', + 'RegenerativeFarmingLog', 'CarbonMintEvent', + # Finance 'RepaymentSchedule', 'PaymentHistory', 'DefaultRiskScore', 'CollectionNote', + 'FarmBalanceSheet', 'SolvencySnapshot', 'ProfitabilityIndex', + # Warehouse 'WarehouseLocation', 'StockItem', 'StockMovement', 'ReconciliationLog', + # Climate 'ClimateZone', 'SensorNode', 'TelemetryLog', 'AutomationTrigger', - 'WorkerProfile', 'WorkShift', 'HarvestLog', 'PayrollEntry', + # Labor + 'WorkerProfile', 'WorkShift', 'HarvestLog', 'PayrollEntry', 'LaborROIHistory', + # Logistics 'DriverProfile', 'DeliveryVehicle', 'TransportRoute', 'FuelLog', + # Transparency & Barter 'PhytoSanitaryCertificate', 'FreightEscrow', 'CustomsCheckpoint', 'GPSTelemetry', 'Alert', 'AlertPreference', 'AuditLog', 'UserSession', 'MediaPayload', 'ProduceReview', 'PriceAdjustmentLog', 'BarterTransaction', 'BarterResource', 'ResourceValueIndex', - 'FarmBalanceSheet', 'SolvencySnapshot', 'ProfitabilityIndex', - 'AssetValueSnapshot', 'LaborROIHistory', 'ReliabilityLog', - 'MigrationVector', 'ContainmentZone', - 'ForwardContract', 'PriceHedgingLog', + # Reliability & Market + 'ReliabilityLog', 'ForwardContract', 'PriceHedgingLog', + # Circular Economy 'WasteInventory', 'BioEnergyOutput', 'CircularCredit', + # Double-Entry Ledger 'LedgerAccount', 'LedgerTransaction', 'LedgerEntry', 'FXValuationSnapshot', 'Vault', 'VaultCurrencyPosition', 'FXRate', 'AccountType', 'EntryType', 'TransactionType', - 'SpectralScanData', 'DynamicGradeAdjustment' - 'AquiferLevel', 'WaterRightsQuota' - 'SpectralScanData', 'DynamicGradeAdjustment', - 'ComponentWearMap', 'MaintenanceEscrow' ] diff --git a/backend/models/barter.py b/backend/models/barter.py index f448027a..54e852b2 100644 --- a/backend/models/barter.py +++ b/backend/models/barter.py @@ -62,8 +62,8 @@ class BarterResource(db.Model): provider_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # Resource Metadata - resource_category = db.Column(db.String(50), nullable=False) # MACHINERY, LABOR, COMMODITY, SEEDS, CIRCULAR_CREDIT, WATER_QUOTA - resource_reference_id = db.Column(db.Integer) # e.g. Equipment ID or CircularCredit ID + resource_category = db.Column(db.String(50), nullable=False) # MACHINERY, LABOR, COMMODITY, SEEDS, CIRCULAR_CREDIT, WATER_QUOTA, CARBON_CREDIT + resource_reference_id = db.Column(db.Integer) # e.g. Equipment ID, CircularCredit ID, CarbonMintEvent ID resource_name = db.Column(db.String(100)) quantity = db.Column(db.Float, nullable=False) diff --git a/backend/models/farm.py b/backend/models/farm.py index 05f0e7ac..780b8640 100644 --- a/backend/models/farm.py +++ b/backend/models/farm.py @@ -25,6 +25,13 @@ class Farm(db.Model): predicted_yield_volume = db.Column(db.Float, default=0.0) # Estimated kg last_velocity_update = db.Column(db.DateTime) + # Regenerative Carbon Tracking (L3-1632) + is_no_till = db.Column(db.Boolean, default=False) # No-tillage practice + cover_crop_active = db.Column(db.Boolean, default=False) # Cover cropping in rotation + organic_certified = db.Column(db.Boolean, default=False) # Certified organic (no synthetic) + sequestration_tier = db.Column(db.String(20), default='TIER_1') # TIER_1 to TIER_4 + total_carbon_credits_minted = db.Column(db.Float, default=0.0) # Lifetime credits earned + created_at = db.Column(db.DateTime, default=datetime.utcnow) # Relationships diff --git a/backend/models/ledger.py b/backend/models/ledger.py index 574a132f..362b95b6 100644 --- a/backend/models/ledger.py +++ b/backend/models/ledger.py @@ -44,6 +44,8 @@ class TransactionType(enum.Enum): DIVIDEND = 'DIVIDEND' FEE = 'FEE' INTEREST = 'INTEREST' + CARBON_CREDIT_MINT = 'CARBON_CREDIT_MINT' # New credit minted from sequestration + CARBON_CREDIT_SALE = 'CARBON_CREDIT_SALE' # Credit sold to ESG buyer class LedgerAccount(db.Model): diff --git a/backend/models/soil_health.py b/backend/models/soil_health.py index 4d4fdf7f..c737428f 100644 --- a/backend/models/soil_health.py +++ b/backend/models/soil_health.py @@ -109,3 +109,85 @@ class ApplicationLog(db.Model): applied_at = db.Column(db.DateTime, default=datetime.utcnow) notes = db.Column(db.Text) + +class RegenerativeFarmingLog(db.Model): + """ + Records agronomic practices that contribute to carbon sequestration (L3-1632). + Each log entry drives the Carbon Minting Engine calculation. + """ + __tablename__ = 'regenerative_farming_logs' + + id = db.Column(db.Integer, primary_key=True) + farm_id = db.Column(db.Integer, db.ForeignKey('farms.id'), nullable=False) + soil_test_id = db.Column(db.Integer, db.ForeignKey('soil_tests.id')) + + # Practice Type: NO_TILL, COVER_CROP, ORGANIC_FERTILIZER, AGROFORESTRY, BIOCHAR + practice_type = db.Column(db.String(50), nullable=False) + area_hectares = db.Column(db.Float, nullable=False) + + # Scientific parameters + soil_organic_carbon_percent = db.Column(db.Float) # % SOC at time of logging + bulk_density_gcm3 = db.Column(db.Float) # g/cm³ - needed for tCO2e math + sampling_depth_cm = db.Column(db.Float, default=30.0) + + # Calculated output (filled by engine) + estimated_co2e_tonnes = db.Column(db.Float, default=0.0) + + logged_at = db.Column(db.DateTime, default=datetime.utcnow) + verified = db.Column(db.Boolean, default=False) + + def to_dict(self): + return { + 'id': self.id, + 'farm_id': self.farm_id, + 'practice_type': self.practice_type, + 'area_hectares': self.area_hectares, + 'soc_percent': self.soil_organic_carbon_percent, + 'estimated_co2e': self.estimated_co2e_tonnes, + 'verified': self.verified, + 'logged_at': self.logged_at.isoformat() + } + +class CarbonMintEvent(db.Model): + """ + Immutable ledger record for every Carbon Credit minting transaction (L3-1632). + Each event is linked to a double-entry LedgerTransaction for financial integrity. + """ + __tablename__ = 'carbon_mint_events' + + id = db.Column(db.Integer, primary_key=True) + farm_id = db.Column(db.Integer, db.ForeignKey('farms.id'), nullable=False) + log_id = db.Column(db.Integer, db.ForeignKey('regenerative_farming_logs.id'), nullable=False) + + # Credits minted (1 credit = 1 tonne CO2e sequestered) + credits_minted = db.Column(db.Float, nullable=False) + credit_unit_value_usd = db.Column(db.Float, nullable=False, default=15.0) # USD per tonne + total_value_usd = db.Column(db.Float, nullable=False) + + # Cryptographic hash for tamper-proof audit chain + mint_hash = db.Column(db.String(64), unique=True, nullable=False) + + # Double-Entry Ledger reference (L3 Integration) + ledger_transaction_id = db.Column(db.Integer, db.ForeignKey('ledger_transactions.id')) + + # ESG Marketplace status + listed_on_market = db.Column(db.Boolean, default=False) + buyer_id = db.Column(db.Integer, db.ForeignKey('users.id')) + sold_at = db.Column(db.DateTime) + sale_price_usd = db.Column(db.Float) + + minted_at = db.Column(db.DateTime, default=datetime.utcnow) + + def to_dict(self): + return { + 'id': self.id, + 'farm_id': self.farm_id, + 'credits_minted': self.credits_minted, + 'unit_value_usd': self.credit_unit_value_usd, + 'total_value_usd': self.total_value_usd, + 'mint_hash': self.mint_hash, + 'listed_on_market': self.listed_on_market, + 'buyer_id': self.buyer_id, + 'sold_at': self.sold_at.isoformat() if self.sold_at else None, + 'minted_at': self.minted_at.isoformat() + } diff --git a/backend/models/sustainability.py b/backend/models/sustainability.py index 33c83b67..456d2d7b 100644 --- a/backend/models/sustainability.py +++ b/backend/models/sustainability.py @@ -91,4 +91,52 @@ class SustainabilityScore(db.Model): # Water Scarcity Tracking (L3-1605) water_quota_utilization_ratio = db.Column(db.Float, default=0.0) # used / total + # ESG Score (L3-1632) + esg_carbon_score = db.Column(db.Float, default=0.0) # 0-100, based on sequestration + total_credits_minted = db.Column(db.Float, default=0.0) + updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + +class ESGMarketListing(db.Model): + """ + Internal ESG marketplace listing for Corporate buyers to purchase + Circular Carbon Credits minted by farms (L3-1632). + """ + __tablename__ = 'esg_market_listings' + + id = db.Column(db.Integer, primary_key=True) + farm_id = db.Column(db.Integer, db.ForeignKey('farms.id'), nullable=False) + mint_event_id = db.Column(db.Integer, db.ForeignKey('carbon_mint_events.id'), nullable=False) + + # Credits being offered + credits_offered = db.Column(db.Float, nullable=False) + asking_price_usd = db.Column(db.Float, nullable=False) + + # Listing meta + status = db.Column(db.String(20), default='ACTIVE') # ACTIVE, SOLD, EXPIRED, WITHDRAWN + description = db.Column(db.Text) + + # Validity window + listed_at = db.Column(db.DateTime, default=datetime.utcnow) + expires_at = db.Column(db.DateTime) + + # Buyer info (filled on purchase) + buyer_user_id = db.Column(db.Integer, db.ForeignKey('users.id')) + purchase_price_usd = db.Column(db.Float) + purchased_at = db.Column(db.DateTime) + + # Ledger settlement reference + settlement_ledger_txn_id = db.Column(db.Integer, db.ForeignKey('ledger_transactions.id')) + + def to_dict(self): + return { + 'id': self.id, + 'farm_id': self.farm_id, + 'credits_offered': self.credits_offered, + 'asking_price_usd': self.asking_price_usd, + 'status': self.status, + 'listed_at': self.listed_at.isoformat(), + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'buyer_user_id': self.buyer_user_id, + 'purchased_at': self.purchased_at.isoformat() if self.purchased_at else None + } diff --git a/backend/services/carbon_sequestration_engine.py b/backend/services/carbon_sequestration_engine.py new file mode 100644 index 00000000..19c47186 --- /dev/null +++ b/backend/services/carbon_sequestration_engine.py @@ -0,0 +1,353 @@ +""" +Carbon Sequestration Engine — L3-1632 +====================================== +Implements the scientific calculation of soil carbon sequestration offsets +and autonomously mints Circular Carbon Credits tied to double-entry ledger +transactions. + +Science Reference: + CO2e tonnes = (ΔSOC% / 100) × ρ × D × A × (44/12) + Where: + ΔSOC% = Change in Soil Organic Carbon percentage + ρ = Bulk density (g/cm³) + D = Sampling depth (cm) + A = Area (hectares × 10,000 m²/ha) + 44/12 = Molecular weight ratio CO2 / C +""" + +import hashlib +import uuid +from datetime import datetime, timedelta +from backend.extensions import db +from backend.models.soil_health import RegenerativeFarmingLog, CarbonMintEvent +from backend.models.sustainability import SustainabilityScore, ESGMarketListing +from backend.models.farm import Farm +from backend.models.ledger import ( + LedgerTransaction, LedgerEntry, LedgerAccount, + TransactionType, EntryType, AccountType +) +import logging + +logger = logging.getLogger(__name__) + +# ─── Practice-specific multipliers ───────────────────────────────────────────── +PRACTICE_MULTIPLIERS = { + 'NO_TILL': 1.20, # 20% bonus — preserves soil structure + 'COVER_CROP': 1.15, # 15% bonus — root biomass addition + 'ORGANIC_FERTILIZER': 1.10, # 10% bonus — no N2O from synthetic sources + 'AGROFORESTRY': 1.35, # 35% bonus — combined woody biomass + soil + 'BIOCHAR': 1.25, # 25% bonus — stable pyrogenic carbon +} + +# Default market price per tonne CO2e (USD) +CARBON_CREDIT_PRICE_USD = 15.0 + +# ESG listing validity +LISTING_VALIDITY_DAYS = 90 + + +class CarbonSequestrationEngine: + """ + Autonomous Carbon Minting Engine for L3-1632. + """ + + # ────────────────────────────────────────────────────────────────────────── + # 1. Scientific Sequestration Calculation + # ────────────────────────────────────────────────────────────────────────── + @staticmethod + def calculate_co2e(log: RegenerativeFarmingLog) -> float: + """ + Converts raw agronomic parameters into verified tonnes of CO2 equivalent + using the standard mineral-soil carbon stock change method. + + Returns: estimated tCO2e (float) + """ + soc = log.soil_organic_carbon_percent or 1.5 # default 1.5% SOC + density = log.bulk_density_gcm3 or 1.3 # default bulk density + depth_cm = log.sampling_depth_cm or 30.0 + area_ha = log.area_hectares + practice_multiplier = PRACTICE_MULTIPLIERS.get(log.practice_type, 1.0) + + # Carbon stock (tonnes/ha) = (SOC% / 100) × ρ (g/cm³) × depth (cm) + # × 100 (t/ha from g/cm³·cm) + carbon_stock = (soc / 100.0) * density * depth_cm * 100.0 + + # Total carbon across area (tonnes) + total_carbon_tonnes = carbon_stock * area_ha + + # Convert to CO2 equivalent (multiply by 44/12) + co2e_tonnes = total_carbon_tonnes * (44.0 / 12.0) + + # Apply regenerative practice bonus + co2e_tonnes *= practice_multiplier + + logger.info( + f"[CarbonEngine] Farm {log.farm_id} | Practice {log.practice_type} | " + f"SOC {soc}% | ρ {density} | D {depth_cm}cm | A {area_ha}ha → " + f"{co2e_tonnes:.4f} tCO2e (×{practice_multiplier})" + ) + return round(co2e_tonnes, 4) + + # ────────────────────────────────────────────────────────────────────────── + # 2. Credit Minting + # ────────────────────────────────────────────────────────────────────────── + @staticmethod + def mint_credits(log_id: int, price_per_tonne_usd: float = CARBON_CREDIT_PRICE_USD): + """ + Mints Circular Carbon Credits for a verified RegenerativeFarmingLog. + Creates a tamper-proof CarbonMintEvent and posts a double-entry + LedgerTransaction for financial integrity. + + Returns: (CarbonMintEvent, error_string | None) + """ + log = RegenerativeFarmingLog.query.get(log_id) + if not log: + return None, "Farming log not found." + if not log.verified: + return None, "Cannot mint credits for an unverified farming log." + + # Calculate sequestration + co2e = CarbonSequestrationEngine.calculate_co2e(log) + log.estimated_co2e_tonnes = co2e + total_value = round(co2e * price_per_tonne_usd, 2) + + # Generate audit-chain hash + raw = f"{log.farm_id}:{log_id}:{co2e}:{datetime.utcnow().isoformat()}" + mint_hash = hashlib.sha256(raw.encode()).hexdigest() + + # Post double-entry ledger transaction + ledger_txn = CarbonSequestrationEngine._post_mint_ledger_entry( + farm_id=log.farm_id, + co2e_tonnes=co2e, + total_value_usd=total_value, + mint_hash=mint_hash + ) + + mint_event = CarbonMintEvent( + farm_id=log.farm_id, + log_id=log_id, + credits_minted=co2e, + credit_unit_value_usd=price_per_tonne_usd, + total_value_usd=total_value, + mint_hash=mint_hash, + ledger_transaction_id=ledger_txn.id if ledger_txn else None + ) + db.session.add(mint_event) + + # Update farm's lifetime credit counter + farm = Farm.query.get(log.farm_id) + if farm: + farm.total_carbon_credits_minted += co2e + + # Update sustainability score + score = SustainabilityScore.query.filter_by(farm_id=log.farm_id).first() + if score: + score.total_credits_minted += co2e + # ESG score: logarithmic scale 0-100 + import math + score.esg_carbon_score = min(100.0, math.log1p(score.total_credits_minted) * 10) + + db.session.commit() + logger.info(f"[CarbonEngine] Minted {co2e} tCO2e for Farm {log.farm_id}. Hash: {mint_hash[:16]}…") + return mint_event, None + + # ────────────────────────────────────────────────────────────────────────── + # 3. Double-Entry Ledger Posting + # ────────────────────────────────────────────────────────────────────────── + @staticmethod + def _post_mint_ledger_entry(farm_id, co2e_tonnes, total_value_usd, mint_hash): + """ + Posts a balanced double-entry ledger transaction for the credit minting: + DR Carbon Asset Account $value + CR Carbon Revenue Account $value + """ + try: + # Resolve or create the farm's carbon asset ledger account + asset_acct = LedgerAccount.query.filter_by( + entity_type='farm', entity_id=farm_id, + account_code=f'FARM-{farm_id}-CARBON-ASSET' + ).first() + if not asset_acct: + asset_acct = LedgerAccount( + account_code=f'FARM-{farm_id}-CARBON-ASSET', + name=f'Farm {farm_id} Carbon Credit Asset', + account_type=AccountType.ASSET, + currency='USD', + entity_type='farm', + entity_id=farm_id, + is_system=True + ) + db.session.add(asset_acct) + db.session.flush() + + # Revenue account + revenue_acct = LedgerAccount.query.filter_by( + entity_type='farm', entity_id=farm_id, + account_code=f'FARM-{farm_id}-CARBON-REVENUE' + ).first() + if not revenue_acct: + revenue_acct = LedgerAccount( + account_code=f'FARM-{farm_id}-CARBON-REVENUE', + name=f'Farm {farm_id} Carbon Credit Revenue', + account_type=AccountType.INCOME, + currency='USD', + entity_type='farm', + entity_id=farm_id, + is_system=True + ) + db.session.add(revenue_acct) + db.session.flush() + + txn = LedgerTransaction( + transaction_id=str(uuid.uuid4()), + transaction_type=TransactionType.CARBON_CREDIT_MINT, + source_type='carbon_mint', + description=f"Carbon sequestration credit mint — {co2e_tonnes:.4f} tCO2e (hash: {mint_hash[:16]})", + base_currency='USD', + base_amount=total_value_usd, + reference_number=mint_hash[:16] + ) + db.session.add(txn) + db.session.flush() + + db.session.add(LedgerEntry( + transaction_id=txn.id, + account_id=asset_acct.id, + entry_type=EntryType.DEBIT, + amount=total_value_usd, + currency='USD', + base_amount=total_value_usd, + base_currency='USD', + memo=f"{co2e_tonnes:.4f} tCO2e carbon credits minted" + )) + db.session.add(LedgerEntry( + transaction_id=txn.id, + account_id=revenue_acct.id, + entry_type=EntryType.CREDIT, + amount=total_value_usd, + currency='USD', + base_amount=total_value_usd, + base_currency='USD', + memo="Carbon sequestration revenue recognition" + )) + db.session.flush() + return txn + except Exception as e: + logger.error(f"[CarbonEngine] Ledger posting failed: {e}", exc_info=True) + return None + + # ────────────────────────────────────────────────────────────────────────── + # 4. ESG Marketplace Listing + # ────────────────────────────────────────────────────────────────────────── + @staticmethod + def list_on_esg_market(mint_event_id: int, asking_price_usd: float = None, + description: str = None): + """ + Lists a minted carbon credit batch on the internal ESG marketplace. + """ + event = CarbonMintEvent.query.get(mint_event_id) + if not event: + return None, "Mint event not found." + if event.listed_on_market: + return None, "This credit batch is already listed." + + price = asking_price_usd or (event.credits_minted * CARBON_CREDIT_PRICE_USD * 1.05) # 5% markup + + listing = ESGMarketListing( + farm_id=event.farm_id, + mint_event_id=mint_event_id, + credits_offered=event.credits_minted, + asking_price_usd=price, + description=description or f"Verified carbon credits from regenerative farming — {event.credits_minted:.2f} tCO2e", + expires_at=datetime.utcnow() + timedelta(days=LISTING_VALIDITY_DAYS) + ) + db.session.add(listing) + event.listed_on_market = True + db.session.commit() + logger.info(f"[CarbonEngine] ESG Listing created for MintEvent {mint_event_id} — ${price:.2f}") + return listing, None + + # ────────────────────────────────────────────────────────────────────────── + # 5. ESG Purchase Settlement + # ────────────────────────────────────────────────────────────────────────── + @staticmethod + def settle_esg_purchase(listing_id: int, buyer_user_id: int): + """ + Executes a carbon credit purchase: settles payment via double-entry ledger, + transfers credit ownership, and marks the listing as SOLD. + """ + listing = ESGMarketListing.query.get(listing_id) + if not listing or listing.status != 'ACTIVE': + return None, "Listing is not available for purchase." + + event = CarbonMintEvent.query.get(listing.mint_event_id) + + # Post settlement ledger entry + try: + buyer_acct = LedgerAccount.query.filter_by( + entity_type='user', entity_id=buyer_user_id, + account_code=f'USER-{buyer_user_id}-CARBON-ASSET' + ).first() + if not buyer_acct: + buyer_acct = LedgerAccount( + account_code=f'USER-{buyer_user_id}-CARBON-ASSET', + name=f'Corporate Buyer {buyer_user_id} Carbon Portfolio', + account_type=AccountType.ASSET, + currency='USD', + entity_type='user', + entity_id=buyer_user_id + ) + db.session.add(buyer_acct) + db.session.flush() + + farm_acct = LedgerAccount.query.filter_by( + account_code=f'FARM-{listing.farm_id}-CARBON-ASSET' + ).first() + + sale_value = listing.asking_price_usd + txn = LedgerTransaction( + transaction_id=str(uuid.uuid4()), + transaction_type=TransactionType.CARBON_CREDIT_SALE, + source_type='esg_listing', + source_id=listing.id, + description=f"ESG carbon credit purchase — {listing.credits_offered:.4f} tCO2e", + base_currency='USD', + base_amount=sale_value, + ) + db.session.add(txn) + db.session.flush() + + # DR Buyer Carbon Asset (buyer acquires) + db.session.add(LedgerEntry( + transaction_id=txn.id, account_id=buyer_acct.id, + entry_type=EntryType.DEBIT, amount=sale_value, + currency='USD', base_amount=sale_value, base_currency='USD', + memo=f"Purchased {listing.credits_offered:.4f} tCO2e" + )) + # CR Farm Carbon Asset (farm transfers) + if farm_acct: + db.session.add(LedgerEntry( + transaction_id=txn.id, account_id=farm_acct.id, + entry_type=EntryType.CREDIT, amount=sale_value, + currency='USD', base_amount=sale_value, base_currency='USD', + memo="Carbon credits transferred to corporate buyer" + )) + + # Settle listing + now = datetime.utcnow() + listing.status = 'SOLD' + listing.buyer_user_id = buyer_user_id + listing.purchase_price_usd = sale_value + listing.purchased_at = now + listing.settlement_ledger_txn_id = txn.id + event.buyer_id = buyer_user_id + event.sold_at = now + event.sale_price_usd = sale_value + + db.session.commit() + logger.info(f"[CarbonEngine] ESG Purchase settled — Listing {listing_id}, Buyer {buyer_user_id}") + return listing, None + except Exception as e: + db.session.rollback() + logger.error(f"[CarbonEngine] Settlement failed: {e}", exc_info=True) + return None, str(e) diff --git a/backend/tasks/carbon_minting_task.py b/backend/tasks/carbon_minting_task.py new file mode 100644 index 00000000..1d9f09cb --- /dev/null +++ b/backend/tasks/carbon_minting_task.py @@ -0,0 +1,107 @@ +""" +Carbon Minting Celery Task — L3-1632 +===================================== +Daily background task that scans all verified RegenerativeFarmingLogs +that have not yet been minted, triggers the Carbon Sequestration Engine, +and then auto-lists qualifying credits on the ESG marketplace. +""" + +from backend.celery_app import celery_app +from backend.models.soil_health import RegenerativeFarmingLog, CarbonMintEvent +from backend.models.sustainability import ESGMarketListing +from backend.services.carbon_sequestration_engine import CarbonSequestrationEngine +from backend.services.notification_service import NotificationService +from backend.models.farm import Farm +from backend.extensions import db +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +@celery_app.task(name='tasks.carbon_minting_sweep') +def carbon_minting_sweep(): + """ + Daily sweep: detects verified farming logs with no mint event, + calculates sequestration, mints credits, and auto-lists on ESG market. + """ + logger.info("═══ Starting Daily Carbon Minting Sweep ═══") + + # All verified logs not yet minted + minted_log_ids = db.session.query(CarbonMintEvent.log_id).subquery() + pending_logs = RegenerativeFarmingLog.query.filter( + RegenerativeFarmingLog.verified == True, # noqa: E712 + RegenerativeFarmingLog.id.notin_(minted_log_ids) + ).all() + + stats = { + 'logs_processed': 0, + 'credits_minted': 0.0, + 'listings_created': 0, + 'errors': 0 + } + + for log in pending_logs: + try: + # 1. Mint credits + mint_event, err = CarbonSequestrationEngine.mint_credits(log.id) + if err: + logger.error(f"Minting failed for Log {log.id}: {err}") + stats['errors'] += 1 + continue + + stats['logs_processed'] += 1 + stats['credits_minted'] += mint_event.credits_minted + + # 2. Auto-list on ESG marketplace if credits ≥ 0.5 tCO2e + if mint_event.credits_minted >= 0.5: + listing, list_err = CarbonSequestrationEngine.list_on_esg_market( + mint_event_id=mint_event.id + ) + if listing: + stats['listings_created'] += 1 + + # 3. Notify farm owner + farm = Farm.query.get(log.farm_id) + if farm: + NotificationService.create_notification( + title="🌱 Carbon Credits Minted", + message=( + f"{mint_event.credits_minted:.4f} tCO2e carbon credits have been minted " + f"for your {log.practice_type} practice on {log.area_hectares:.1f} ha. " + f"Estimated value: ${mint_event.total_value_usd:.2f} USD." + ), + notification_type="CARBON_CREDIT", + user_id=log.farm_id + ) + + except Exception as e: + logger.error(f"Sweep error for Log {log.id}: {e}", exc_info=True) + stats['errors'] += 1 + + logger.info(f"═══ Carbon Sweep Complete: {stats} ═══") + return stats + + +@celery_app.task(name='tasks.esg_listing_expiry_check') +def esg_listing_expiry_check(): + """ + Hourly task to expire stale ESG market listings past their expiry date. + """ + now = datetime.utcnow() + expired = ESGMarketListing.query.filter( + ESGMarketListing.status == 'ACTIVE', + ESGMarketListing.expires_at <= now + ).all() + + for listing in expired: + listing.status = 'EXPIRED' + # Re-enable re-listing for the mint event + if listing.mint_event_id: + event = CarbonMintEvent.query.get(listing.mint_event_id) + if event: + event.listed_on_market = False + + db.session.commit() + logger.info(f"[ESGExpiry] Expired {len(expired)} stale listings.") + return {'expired_count': len(expired)}