Skip to main content

EPANET Input File Export - Implementation Plan

Version: 1.0 Date: October 17, 2025 Status: Ready for Implementation Design Doc: scenario_inp_export_design.md


📋 Implementation Phases

Phase 1: Backend Core (EpanetConsoleCore) ⏱️ 3-4 hours

Phase 2: API Layer (DWD API) ⏱️ 2-3 hours

Phase 3: Frontend (React) ⏱️ 2-3 hours

Phase 4: Testing & Documentation ⏱️ 2 hours

Total Estimated Time: 9-12 hours


Phase 1: Backend Core (EpanetConsoleCore)

File Changes Required

  1. ✏️ epanetconsolecore/EpanetConsoleCore/Program.cs (MODIFY)
  2. 📁 epanetconsolecore/EpanetConsoleCore/exports/.gitkeep (NEW)
  3. ✏️ epanetconsolecore/.gitignore (MODIFY - add exports/)

1.1 Program.cs - Argument Parsing

Location: epanetconsolecore/EpanetConsoleCore/Program.cs

Existing Code (Line ~55-70):

public static async Task Main(string[] args)
{
if (args.Length < 2)
{
Console.WriteLine("Usage: EpanetConsoleCore <connection-string> <scenario-id>");
Console.WriteLine("Please provide a valid database connection string as the first argument and scenario ID as the second argument.");
return;
}

string pgConnStr = args[0];
if (!int.TryParse(args[1], out scenarioId))
{
Console.WriteLine("Please provide a valid scenario ID as the second argument.");
return;
}

logger = HTLogger.HTLogger.GetDefaultLogger();
logger.LogInformation($"Starting scenario {scenarioId}...");

try
{
await dwdRun(pgConnStr);
}
// ... rest of existing code
}

Modified Code:

public static async Task Main(string[] args)
{
// Parse command-line arguments
if (args.Length < 2)
{
Console.WriteLine("Usage: EpanetConsoleCore <connection-string> <scenario-id> [--export-only]");
Console.WriteLine(" <connection-string> Database connection string");
Console.WriteLine(" <scenario-id> Scenario ID to run or export");
Console.WriteLine(" --export-only Export INP file without running simulation");
return;
}

string pgConnStr = args[0];
if (!int.TryParse(args[1], out scenarioId))
{
Console.WriteLine("ERROR: Invalid scenario ID. Please provide a valid integer.");
Environment.Exit(1);
return;
}

// Check for export-only flag
bool exportOnly = args.Length >= 3 &&
(args[2] == "--export-only" || args[2] == "--export");

logger = HTLogger.HTLogger.GetDefaultLogger();

if (exportOnly)
{
logger.LogInformation($"Exporting scenario {scenarioId} to INP file...");
try
{
await ExportScenarioInputFile(pgConnStr, scenarioId);
}
catch (Exception ex)
{
logger.LogError($"Export failed: {ex.Message}");
Console.WriteLine($"EXPORT_ERROR:1:{ex.Message}");
Environment.Exit(1);
}
}
else
{
logger.LogInformation($"Starting scenario {scenarioId}...");
try
{
await dwdRun(pgConnStr);
}
catch (Exception ex)
{
logger.LogError($"Error executing scenario: {ex.Message}");
// ... existing error handling
}
}
}

1.2 Program.cs - New ExportScenarioInputFile Method

Location: epanetconsolecore/EpanetConsoleCore/Program.cs

Add New Method (after dwdRun method):

/// <summary>
/// Exports the scenario to an EPANET input file without running simulation.
/// Applies all database modifications and saves the resulting network.
/// </summary>
/// <param name="pgConnStr">PostgreSQL connection string</param>
/// <param name="scenarioId">Scenario ID to export</param>
private static async Task ExportScenarioInputFile(string pgConnStr, int scenarioId)
{
EpanetWrapper localEpanetWrapper = null;
EN2PostgresContext localContext = null;

try
{
// Initialize database context
var options = new DbContextOptionsBuilder<EN2PostgresContext>()
.UseNpgsql(pgConnStr)
.Options;
localContext = new EN2PostgresContext(options);

logger.LogInformation($"Loading scenario {scenarioId} from database...");

// Load scenario with options
var scenario = await localContext.Scenarios
.Include(s => s.Options)
.FirstOrDefaultAsync(s => s.Id == scenarioId);

if (scenario == null)
{
throw new Exception($"Scenario {scenarioId} not found in database");
}

// Validate base input file exists
if (string.IsNullOrEmpty(scenario.InputFile))
{
throw new Exception($"Scenario {scenarioId} has no base input file specified");
}

if (!File.Exists(scenario.InputFile))
{
throw new Exception($"Base input file not found: {scenario.InputFile}");
}

logger.LogInformation($"Base input file: {scenario.InputFile}");

// Get options (use scenario's options or default)
var scenarioOptions = scenario.Options;
if (scenarioOptions == null)
{
scenarioOptions = await localContext.Options.FirstOrDefaultAsync(o => o.IsDefault)
?? await localContext.Options.FirstOrDefaultAsync();
if (scenarioOptions == null)
{
throw new Exception("No options configuration found");
}
}

// Initialize EPANET wrapper
var filenameBase = Path.GetFileNameWithoutExtension(scenario.InputFile);
var inpBase = Path.Combine(Path.GetDirectoryName(scenario.InputFile), filenameBase);
localEpanetWrapper = new EpanetWrapper(
scenario.InputFile,
$"{inpBase}_export.rpt",
$"{inpBase}_export.out",
logger
);

logger.LogInformation("Opening EPANET project...");

// Open EPANET project
bool startupSuccess = localEpanetWrapper.Startup(
false, // doQuality - not needed for export
null, // msxFile - not needed for export
out int epanetErrorCode,
out string epanetErrorMessage
);

if (!startupSuccess)
{
throw new Exception($"EPANET startup failed (code {epanetErrorCode}): {epanetErrorMessage}");
}

logger.LogInformation("Applying database modifications...");

// Apply all database modifications
var syncService = new EpanetConsoleCore.Services.NetworkSynchronizationService(
localEpanetWrapper,
localContext,
logger
);
await syncService.SynchronizeFromDatabase(scenarioId);

logger.LogInformation("Database synchronization complete");

// Generate output file path
var exportDir = Path.Combine(
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location),
"exports"
);
Directory.CreateDirectory(exportDir);

var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var outputFileName = $"scenario_{scenarioId}_{timestamp}.inp";
var outputFilePath = Path.Combine(exportDir, outputFileName);

logger.LogInformation($"Saving to: {outputFilePath}");

// Save the modified network
int saveResult = localEpanetWrapper.EN_saveinpfile(outputFilePath);
if (saveResult != 0)
{
throw new Exception($"EN_saveinpfile failed with error code {saveResult}");
}

// Verify file was created
if (!File.Exists(outputFilePath))
{
throw new Exception("Output file was not created");
}

var fileInfo = new FileInfo(outputFilePath);
logger.LogInformation($"Export successful. File size: {fileInfo.Length} bytes");

// Output success message to stdout for API to capture
Console.WriteLine($"EXPORT_SUCCESS:{outputFilePath}");
Environment.Exit(0);
}
catch (Exception ex)
{
logger.LogError($"Export failed: {ex.Message}", ex);
Console.WriteLine($"EXPORT_ERROR:1:{ex.Message}");
Environment.Exit(1);
}
finally
{
// Clean up resources
localEpanetWrapper?.Dispose();
if (localContext != null)
{
await localContext.DisposeAsync();
}
}
}

1.3 Create exports directory

Location: epanetconsolecore/EpanetConsoleCore/exports/.gitkeep

Create empty file to ensure directory exists in git

Update .gitignore:

# Existing entries...

# Export temporary files
exports/*.inp
exports/*.rpt
exports/*.out

Phase 2: API Layer (DWD API)

File Changes Required

  1. ✏️ dwd-api-aspnet/DwdApiAspNet/Controllers/ScenarioController.cs (MODIFY)

2.1 ScenarioController.cs - New Download Endpoint

Location: dwd-api-aspnet/DwdApiAspNet/Controllers/ScenarioController.cs

Add after the StartScenario endpoint (around line 448):

// GET: api/Scenario/download/5
/// <summary>
/// Downloads the EPANET input file for a scenario with all database modifications applied.
/// </summary>
/// <param name="id">Scenario ID</param>
/// <returns>EPANET input file (.inp) as file download</returns>
[HttpGet("download/{id}")]
[ProducesResponseType(typeof(FileResult), StatusCodes.Status200OK)]
[ProducesResponseType(typeof(object), StatusCodes.Status404NotFound)]
[ProducesResponseType(typeof(object), StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> DownloadScenarioInputFile(int id)
{
#if DEBUG
LogRequest("DownloadScenarioInputFile", new { id });
#endif

try
{
// Validate scenario exists
var scenario = await _context.Scenarios.FindAsync(id);
if (scenario == null)
{
_logger.LogWarning("Scenario {Id} not found for download", id);
return NotFound(new { Message = $"Scenario with ID {id} not found" });
}

_logger.LogInformation("Starting export for scenario {Id} - {Name}", id, scenario.Name);

// Prepare process to export the INP file
var process = new Process
{
StartInfo = new ProcessStartInfo
{
FileName = _epanetConsoleCorePath,
Arguments = $"\"{_connectionString}\" {id} --export-only",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = Path.GetDirectoryName(_epanetConsoleCorePath)
}
};

var output = new System.Text.StringBuilder();
var errors = new System.Text.StringBuilder();

process.OutputDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
output.AppendLine(e.Data);
_logger.LogDebug($"EpanetConsoleCore Output: {e.Data}");
}
};

process.ErrorDataReceived += (sender, e) =>
{
if (!string.IsNullOrEmpty(e.Data))
{
errors.AppendLine(e.Data);
_logger.LogError($"EpanetConsoleCore Error: {e.Data}");
}
};

process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();

// Wait for process with timeout (30 seconds)
var timeout = TimeSpan.FromSeconds(30);
bool completed = await Task.Run(() => process.WaitForExit((int)timeout.TotalMilliseconds));

if (!completed)
{
try
{
process.Kill();
_logger.LogError("Export process timed out for scenario {Id}", id);
}
catch (Exception killEx)
{
_logger.LogError(killEx, "Failed to kill timed-out process");
}

return StatusCode(500, new
{
Message = "Export operation timed out",
Error = "The export took longer than expected. Please try again or contact support."
});
}

// Parse output to find file path
var outputText = output.ToString();
var errorText = errors.ToString();

if (outputText.Contains("EXPORT_SUCCESS:"))
{
// Extract file path from output
var lines = outputText.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var successLine = lines.FirstOrDefault(l => l.StartsWith("EXPORT_SUCCESS:"));

if (successLine == null)
{
_logger.LogError("EXPORT_SUCCESS found but could not parse file path");
return StatusCode(500, new { Message = "Export completed but file path not found" });
}

var filePath = successLine.Substring("EXPORT_SUCCESS:".Length).Trim();

if (!System.IO.File.Exists(filePath))
{
_logger.LogError("Export file not found: {FilePath}", filePath);
return StatusCode(500, new { Message = "Export file was not created" });
}

try
{
// Read file into memory
var fileBytes = await System.IO.File.ReadAllBytesAsync(filePath);
var fileInfo = new FileInfo(filePath);

_logger.LogInformation(
"Export successful for scenario {Id}. File size: {Size} bytes",
id,
fileInfo.Length
);

// Generate sanitized filename
var sanitizedName = SanitizeFileName(scenario.Name);
var fileName = $"{sanitizedName}_scenario_{id}.inp";

// Delete temporary file
try
{
System.IO.File.Delete(filePath);
_logger.LogDebug("Cleaned up temporary file: {FilePath}", filePath);
}
catch (Exception cleanupEx)
{
_logger.LogWarning(cleanupEx, "Failed to delete temporary file: {FilePath}", filePath);
// Don't fail the request if cleanup fails
}

// Return file to browser
return File(fileBytes, "text/plain", fileName);
}
catch (Exception fileEx)
{
_logger.LogError(fileEx, "Error reading or returning export file");
return StatusCode(500, new
{
Message = "Failed to read export file",
Error = fileEx.Message
});
}
}
else if (outputText.Contains("EXPORT_ERROR:"))
{
_logger.LogError("Export failed for scenario {Id}: {Error}", id, errorText);
return StatusCode(500, new
{
Message = "Export operation failed",
Error = errorText,
Details = outputText
});
}
else
{
_logger.LogError("Unexpected export output for scenario {Id}. Exit code: {ExitCode}", id, process.ExitCode);
return StatusCode(500, new
{
Message = "Export operation failed with unexpected output",
ExitCode = process.ExitCode,
Output = outputText,
Error = errorText
});
}
}
catch (Exception ex)
{
return HandleError(ex, "DownloadScenarioInputFile");
}
}

/// <summary>
/// Sanitizes a filename by removing/replacing invalid characters
/// </summary>
private string SanitizeFileName(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
{
return "scenario";
}

// Get invalid characters for filename
var invalidChars = Path.GetInvalidFileNameChars();

// Replace invalid characters with underscore
var sanitized = new string(fileName.Select(c =>
invalidChars.Contains(c) ? '_' : c
).ToArray());

// Replace multiple underscores with single underscore
while (sanitized.Contains("__"))
{
sanitized = sanitized.Replace("__", "_");
}

// Trim underscores from start and end
sanitized = sanitized.Trim('_');

// Limit length (keep it reasonable for filenames)
const int maxLength = 50;
if (sanitized.Length > maxLength)
{
sanitized = sanitized.Substring(0, maxLength).TrimEnd('_');
}

// If result is empty, use default
if (string.IsNullOrWhiteSpace(sanitized))
{
return "scenario";
}

return sanitized;
}

Add using statement at top of file:

using System.Linq;

Phase 3: Frontend (React)

File Changes Required

  1. ✏️ dwd-frontend-react/src/components/scenarios/ScenariosTable.jsx (MODIFY)
  2. ✏️ dwd-frontend-react/src/components/scenarios/ScenarioDetailsDialog.jsx (MODIFY)
  3. ✏️ dwd-frontend-react/src/pages/ScenariosList.jsx (MODIFY)

3.1 ScenariosTable.jsx - Add Download Button

Location: dwd-frontend-react/src/components/scenarios/ScenariosTable.jsx

Step 1: Add import for Download icon

Find the imports section and add:

import { Download as DownloadIcon } from '@mui/icons-material';

Step 2: Add state for download tracking

Find where props are destructured and add new prop:

const ScenariosTable = ({
scenarios,
isLoading,
selectedScenarioId,
onScenarioSelect,
onStartScenario,
onViewDetails,
onEditScenario,
onDeleteScenario,
onDownloadScenario, // NEW
downloadingScenarioId, // NEW
onRowClick,
onRowDoubleClick,
onRowMouseDown,
onRowMouseUp,
onRowMouseLeave
}) => {

Step 3: Find the Actions column in TableBody (around line 140-200)

Add Download button to actions cell:

<TableCell align="right">
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Tooltip title="Start Scenario">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onStartScenario(scenario);
}}
disabled={scenario.status === 'Running'}
>
<PlayArrowIcon fontSize="small" />
</IconButton>
</Tooltip>

{/* NEW: Download Button */}
<Tooltip title="Download INP File">
<span>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onDownloadScenario(scenario);
}}
disabled={downloadingScenarioId === scenario.id}
>
{downloadingScenarioId === scenario.id ? (
<CircularProgress size={20} />
) : (
<DownloadIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>

<Tooltip title="View Details">
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
onViewDetails(scenario);
}}
>
<InfoIcon fontSize="small" />
</IconButton>
</Tooltip>

{/* ... existing Edit and Delete buttons ... */}
</Box>
</TableCell>

Step 4: Add CircularProgress import

import { CircularProgress } from '@mui/material';

3.2 ScenariosList.jsx - Add Download Handler

Location: dwd-frontend-react/src/pages/ScenariosList.jsx

Step 1: Add state for download tracking (around line 26)

const [openDialog, setOpenDialog] = useState(false);
const [downloadingScenarioId, setDownloadingScenarioId] = useState(null); // NEW

Step 2: Add download handler function (around line 298)

const handleDownloadScenario = async (scenario) => {
try {
setDownloadingScenarioId(scenario.id);

const response = await fetch(
`${API_BASE_URL}/api/Scenario/download/${scenario.id}`,
{
method: 'GET',
headers: {
'Accept': 'text/plain'
}
}
);

if (!response.ok) {
let errorMessage = 'Download failed';
try {
const errorData = await response.json();
errorMessage = errorData.Message || errorData.Error || errorMessage;
} catch {
errorMessage = `Download failed with status ${response.status}`;
}
throw new Error(errorMessage);
}

// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `scenario_${scenario.id}.inp`;

if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
}
}

// Download the file
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();

// Cleanup
window.URL.revokeObjectURL(url);
document.body.removeChild(a);

console.log(`Downloaded: ${filename}`);
// TODO: Show success notification if notification system exists

} catch (error) {
console.error('Error downloading scenario:', error);
alert(`Failed to download scenario: ${error.message}`);
} finally {
setDownloadingScenarioId(null);
}
};

Step 3: Pass props to ScenariosTable (around line 319)

<ScenariosTable
scenarios={availableScenarios}
isLoading={isLoadingScenarios}
selectedScenarioId={selectedScenarioId}
onScenarioSelect={setSelectedScenarioId}
onStartScenario={handleStartScenario}
onViewDetails={handleViewDetails}
onEditScenario={handleOpenDialog}
onDeleteScenario={handleDelete}
onDownloadScenario={handleDownloadScenario} // NEW
downloadingScenarioId={downloadingScenarioId} // NEW
onRowClick={handleRowClick}
onRowDoubleClick={handleRowDoubleClick}
onRowMouseDown={handleRowMouseDown}
onRowMouseUp={handleRowMouseUp}
onRowMouseLeave={handleRowMouseLeave}
/>

3.3 ScenarioDetailsDialog.jsx - Add Download Button

Location: dwd-frontend-react/src/components/scenarios/ScenarioDetailsDialog.jsx

Step 1: Add import for Download icon and CircularProgress

import { Download as DownloadIcon } from '@mui/icons-material';
import { CircularProgress } from '@mui/material';

Step 2: Add props (find the component declaration)

const ScenarioDetailsDialog = ({
open,
onClose,
scenario,
onEdit,
onDelete,
onDownload, // NEW
downloadingScenarioId, // NEW
onEditControl,
onDeleteControl
}) => {

Step 3: Find the dialog actions section (usually at bottom of dialog content)

Add Download button to actions:

<DialogActions>
{/* Existing buttons */}
<Button onClick={() => onEdit(scenario)} startIcon={<EditIcon />}>
Edit
</Button>
<Button onClick={() => onDelete(scenario)} color="error" startIcon={<DeleteIcon />}>
Delete
</Button>

{/* NEW: Download Button */}
<Button
onClick={() => onDownload(scenario)}
startIcon={downloadingScenarioId === scenario?.id ?
<CircularProgress size={20} /> :
<DownloadIcon />
}
disabled={downloadingScenarioId === scenario?.id}
>
{downloadingScenarioId === scenario?.id ? 'Downloading...' : 'Download INP'}
</Button>

<Button onClick={onClose}>Close</Button>
</DialogActions>

Step 4: Update ScenariosList.jsx to pass props to dialog (around line 337)

<ScenarioDetailsDialog
open={openDetailsDialog}
onClose={() => setOpenDetailsDialog(false)}
scenario={viewingScenario}
onEdit={handleEditFromDetails}
onDelete={handleDelete}
onDownload={handleDownloadScenario} // NEW
downloadingScenarioId={downloadingScenarioId} // NEW
onEditControl={handleEditControl}
onDeleteControl={handleDeleteControl}
/>

Phase 4: Testing & Documentation

4.1 Manual Testing Checklist

Create test scenarios:

-- Test Scenario 1: Simple scenario with no modifications
INSERT INTO scenarios (name, description, input_file)
VALUES ('Test Simple Export', 'Basic export test', '/path/to/base.inp');

-- Test Scenario 2: Scenario with property overrides
-- (Create scenario with node/link property modifications)

-- Test Scenario 3: Scenario with special characters in name
INSERT INTO scenarios (name, description, input_file)
VALUES ('Test! @#$ Export%', 'Special chars test', '/path/to/base.inp');

Test Cases:

  • Download from scenarios table
  • Download from scenario details dialog
  • Download shows loading indicator
  • Download completes successfully
  • Downloaded file has correct name
  • Downloaded file is valid EPANET INP format
  • Downloaded file contains database modifications
  • Error handling: non-existent scenario
  • Error handling: missing base INP file
  • Error handling: EPANET error
  • Concurrent downloads work
  • No temp files left on disk
  • Special characters in scenario name handled

4.2 Validation

Validate Downloaded File:

# Open in EPANET to verify it's valid
epanet2 scenario_5.inp

# Or use CLI validation
epanetcli validate scenario_5.inp

Check for modifications:

# Compare to base file
diff base.inp scenario_5.inp

# Should see database modifications in output

4.3 Documentation Updates

API Documentation: Add to dwd-api-aspnet/API-Testing-README.md:

### Download Scenario INP File

**Endpoint:** `GET /api/Scenario/download/{id}`

**Description:** Downloads the EPANET input file for a scenario with all database modifications applied.

**Parameters:**
- `id` (path, integer, required): Scenario ID

**Response:**
- **200 OK**: File download
- Content-Type: `text/plain`
- Content-Disposition: `attachment; filename="<scenario-name>_scenario_<id>.inp"`
- **404 Not Found**: Scenario doesn't exist
- **500 Internal Server Error**: Export failed

**Example:**
```bash
curl -OJ http://localhost:5000/api/Scenario/download/5

**User Documentation:**
Add to project README or user guide:
```markdown
## Exporting Scenarios

To download the complete EPANET input file for a scenario:

1. Navigate to the Scenarios page
2. Find your scenario in the list
3. Click the Download icon (⬇️) in the Actions column
4. The `.inp` file will download to your browser's download folder

The exported file includes all modifications from:
- Node and link properties
- Custom options
- Simple and rule-based controls
- Patterns and curves
- Energy settings
- Reaction settings

You can open this file in EPANET or share it with colleagues.

4.4 Update .gitignore

Location: epanetconsolecore/.gitignore

Add:

# Export temporary files
exports/*.inp
exports/*.rpt
exports/*.out
!exports/.gitkeep

Location: dwd-api-aspnet/.gitignore

Ensure exports directory is ignored if created there as well.


Implementation Order

  1. Start with Backend Core (Phase 1)

    • Easiest to test independently
    • Can test via command line before API integration
    • Foundation for everything else
  2. Add API Endpoint (Phase 2)

    • Test with Postman/curl before frontend
    • Verify file download works
  3. Add Frontend UI (Phase 3)

    • Add buttons and handlers
    • Test end-to-end flow
  4. Testing & Polish (Phase 4)

    • Comprehensive testing
    • Fix bugs
    • Update documentation

Testing at Each Phase

After Phase 1 (Backend Core):

cd epanetconsolecore/EpanetConsoleCore
dotnet run -- "connection-string" 5 --export-only

# Expected output:
# EXPORT_SUCCESS:C:\path\to\exports\scenario_5_20251017_143522.inp

# Verify file exists and is valid EPANET INP

After Phase 2 (API):

# Test with curl
curl -OJ http://localhost:5000/api/Scenario/download/5

# Or Postman: GET http://localhost:5000/api/Scenario/download/5

After Phase 3 (Frontend):

# Run frontend dev server
cd dwd-frontend-react
npm start

# Click download button in browser
# Verify file downloads correctly

Rollback Plan

If issues are discovered after deployment:

  1. Minimal Impact Rollback:

    • Remove download button from frontend (UI-only change)
    • API endpoint can remain (unused)
    • No database changes to roll back
  2. Full Rollback:

    • Revert all code changes
    • No data loss (read-only operation)

Success Criteria

  • User can download INP file from UI
  • Download completes in < 30 seconds
  • File contains all database modifications
  • No temp files accumulate on disk
  • Error messages are clear
  • Code passes review
  • Documentation updated

Ready to start implementation! 🚀

Proceed with Phase 1 when ready.